ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: Voice Tasker preview и трейс активности
This commit is contained in:
parent
d867a89a1b
commit
9a91af372e
|
|
@ -17,6 +17,7 @@ from plane.app.views import (
|
||||||
UserLastProjectWithWorkspaceEndpoint,
|
UserLastProjectWithWorkspaceEndpoint,
|
||||||
WorkspaceThemeViewSet,
|
WorkspaceThemeViewSet,
|
||||||
WorkspaceUserProfileStatsEndpoint,
|
WorkspaceUserProfileStatsEndpoint,
|
||||||
|
WorkspaceActivityEndpoint,
|
||||||
WorkspaceUserActivityEndpoint,
|
WorkspaceUserActivityEndpoint,
|
||||||
WorkspaceUserProfileEndpoint,
|
WorkspaceUserProfileEndpoint,
|
||||||
WorkspaceUserProfileIssuesEndpoint,
|
WorkspaceUserProfileIssuesEndpoint,
|
||||||
|
|
@ -134,6 +135,11 @@ urlpatterns = [
|
||||||
WorkspaceUserProfileStatsEndpoint.as_view(),
|
WorkspaceUserProfileStatsEndpoint.as_view(),
|
||||||
name="workspace-user-stats",
|
name="workspace-user-stats",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/activity/",
|
||||||
|
WorkspaceActivityEndpoint.as_view(),
|
||||||
|
name="workspace-activity",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/user-activity/<uuid:user_id>/",
|
"workspaces/<str:slug>/user-activity/<uuid:user_id>/",
|
||||||
WorkspaceUserActivityEndpoint.as_view(),
|
WorkspaceUserActivityEndpoint.as_view(),
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ from .workspace.label import WorkspaceLabelsEndpoint
|
||||||
from .workspace.state import WorkspaceStatesEndpoint
|
from .workspace.state import WorkspaceStatesEndpoint
|
||||||
from .workspace.user import (
|
from .workspace.user import (
|
||||||
UserLastProjectWithWorkspaceEndpoint,
|
UserLastProjectWithWorkspaceEndpoint,
|
||||||
|
WorkspaceActivityEndpoint,
|
||||||
WorkspaceUserProfileIssuesEndpoint,
|
WorkspaceUserProfileIssuesEndpoint,
|
||||||
WorkspaceUserPropertiesEndpoint,
|
WorkspaceUserPropertiesEndpoint,
|
||||||
WorkspaceUserProfileEndpoint,
|
WorkspaceUserProfileEndpoint,
|
||||||
|
|
|
||||||
|
|
@ -2589,6 +2589,85 @@ class VoiceTaskCommitEndpoint(BaseAPIView):
|
||||||
)
|
)
|
||||||
|
|
||||||
if action == "create_task":
|
if action == "create_task":
|
||||||
|
existing_issue = voice_session.created_task
|
||||||
|
if is_voice_task_issue_available(existing_issue):
|
||||||
|
issue = existing_issue
|
||||||
|
if not can_user_update_voice_task_issue(request.user, workspace, issue):
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"code": "issue_permission_denied",
|
||||||
|
"error": "Voice Task draft could not update the created work item.",
|
||||||
|
},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
project = Project.objects.get(id=resolution["project"]["id"], workspace=workspace)
|
||||||
|
payload = build_voice_task_issue_payload(draft, resolution, voice_session.transcript)
|
||||||
|
payload_without_project = {key: value for key, value in payload.items() if key != "project_id"}
|
||||||
|
payload_without_project["external_source"] = VOICE_TASK_EXTERNAL_SOURCE
|
||||||
|
payload_without_project["external_id"] = str(voice_session.id)
|
||||||
|
current_instance = json.dumps(IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder)
|
||||||
|
requested_data = json.dumps(
|
||||||
|
{**payload_without_project, "project_id": str(project.id)},
|
||||||
|
cls=DjangoJSONEncoder,
|
||||||
|
)
|
||||||
|
|
||||||
|
if issue.project_id != project.id:
|
||||||
|
issue = move_voice_task_issue_to_project(issue, project, resolution["state"], request.user)
|
||||||
|
|
||||||
|
serializer = IssueCreateSerializer(
|
||||||
|
issue,
|
||||||
|
data=payload_without_project,
|
||||||
|
partial=True,
|
||||||
|
context={"project_id": project.id},
|
||||||
|
)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"code": "issue_validation_failed",
|
||||||
|
"error": "Voice Task draft could not update the created work item.",
|
||||||
|
"details": serializer.errors,
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer.save()
|
||||||
|
issue.refresh_from_db()
|
||||||
|
voice_session.created_task = issue
|
||||||
|
voice_session.parsed_json = draft
|
||||||
|
voice_session.save(update_fields=["created_task", "parsed_json", "updated_at"])
|
||||||
|
|
||||||
|
issue_activity.delay(
|
||||||
|
type="issue.activity.updated",
|
||||||
|
requested_data=requested_data,
|
||||||
|
actor_id=str(request.user.id),
|
||||||
|
issue_id=str(issue.id),
|
||||||
|
project_id=str(project.id),
|
||||||
|
current_instance=current_instance,
|
||||||
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=base_host(request=request, is_app=True),
|
||||||
|
)
|
||||||
|
model_activity.delay(
|
||||||
|
model_name="issue",
|
||||||
|
model_id=str(issue.id),
|
||||||
|
requested_data=json.loads(requested_data),
|
||||||
|
current_instance=current_instance,
|
||||||
|
actor_id=request.user.id,
|
||||||
|
slug=slug,
|
||||||
|
origin=base_host(request=request, is_app=True),
|
||||||
|
)
|
||||||
|
issue_description_version_task.delay(
|
||||||
|
updated_issue=current_instance,
|
||||||
|
issue_id=str(issue.id),
|
||||||
|
user_id=request.user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
response_status = status.HTTP_200_OK
|
||||||
|
commit_status = "updated"
|
||||||
|
else:
|
||||||
project = Project.objects.get(id=resolution["project"]["id"], workspace=workspace)
|
project = Project.objects.get(id=resolution["project"]["id"], workspace=workspace)
|
||||||
payload = build_voice_task_issue_payload(draft, resolution, voice_session.transcript)
|
payload = build_voice_task_issue_payload(draft, resolution, voice_session.transcript)
|
||||||
payload_without_project = {key: value for key, value in payload.items() if key != "project_id"}
|
payload_without_project = {key: value for key, value in payload.items() if key != "project_id"}
|
||||||
|
|
|
||||||
|
|
@ -406,6 +406,35 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceActivityEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [WorkspaceEntityPermission]
|
||||||
|
|
||||||
|
def get(self, request, slug):
|
||||||
|
projects = request.query_params.getlist("project", [])
|
||||||
|
|
||||||
|
queryset = (
|
||||||
|
IssueActivity.objects.filter(
|
||||||
|
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||||
|
workspace__slug=slug,
|
||||||
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
project__archived_at__isnull=True,
|
||||||
|
)
|
||||||
|
.select_related("actor", "workspace", "issue", "project")
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
if projects:
|
||||||
|
queryset = queryset.filter(project__in=projects)
|
||||||
|
|
||||||
|
return self.paginate(
|
||||||
|
order_by=request.GET.get("order_by", "-created_at"),
|
||||||
|
request=request,
|
||||||
|
queryset=queryset,
|
||||||
|
on_results=lambda issue_activities: IssueActivitySerializer(issue_activities, many=True).data,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||||
def get(self, request, slug, user_id):
|
def get(self, request, slug, user_id):
|
||||||
filters = issue_filters(request.query_params, "GET")
|
filters = issue_filters(request.query_params, "GET")
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,13 @@ 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 type { TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from "@plane/types";
|
import { Avatar } from "@plane/propel/avatar";
|
||||||
|
import type { IIssueActivity, TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from "@plane/types";
|
||||||
|
import { calculateTimeAgo, generateWorkItemLink, getFileURL, renderFormattedDate } from "@plane/utils";
|
||||||
|
import { ActivityIcon } from "@/components/core/activity";
|
||||||
// plane web services
|
// plane web services
|
||||||
import { WorkspaceService } from "@/services/workspace.service";
|
import { WorkspaceService } from "@/services/workspace.service";
|
||||||
|
import { useUser } from "@/hooks/store/user";
|
||||||
import { getActivityProjectId } from "../../home.utils";
|
import { getActivityProjectId } from "../../home.utils";
|
||||||
import { RecentsEmptyState } from "../empty-states";
|
import { RecentsEmptyState } from "../empty-states";
|
||||||
import { EWidgetKeys, WidgetLoader } from "../loaders";
|
import { EWidgetKeys, WidgetLoader } from "../loaders";
|
||||||
|
|
@ -30,6 +34,55 @@ 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" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const PRIORITY_LABELS: Record<string, string> = {
|
||||||
|
urgent: "Срочный",
|
||||||
|
high: "Высокий",
|
||||||
|
medium: "Средний",
|
||||||
|
low: "Низкий",
|
||||||
|
none: "Без приоритета",
|
||||||
|
};
|
||||||
|
|
||||||
|
const activityValue = (value: string | null | undefined) => {
|
||||||
|
if (!value) return null;
|
||||||
|
return PRIORITY_LABELS[value] ?? value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const activityDate = (value: string | null | undefined) => {
|
||||||
|
if (!value) return null;
|
||||||
|
return renderFormattedDate(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const activityMessage = (activity: IIssueActivity) => {
|
||||||
|
if (!activity.field && activity.verb === "created") return "создал рабочий элемент";
|
||||||
|
|
||||||
|
switch (activity.field) {
|
||||||
|
case "assignees":
|
||||||
|
return activity.old_value === ""
|
||||||
|
? `добавил исполнителя ${activityValue(activity.new_value) ?? ""}`.trim()
|
||||||
|
: `убрал исполнителя ${activityValue(activity.old_value) ?? ""}`.trim();
|
||||||
|
case "state":
|
||||||
|
return `изменил статус на ${activityValue(activity.new_value) ?? "новое значение"}`;
|
||||||
|
case "priority":
|
||||||
|
return `изменил приоритет на ${activityValue(activity.new_value) ?? "новое значение"}`;
|
||||||
|
case "target_date":
|
||||||
|
return `изменил срок на ${activityDate(activity.new_value) ?? "новую дату"}`;
|
||||||
|
case "start_date":
|
||||||
|
return `изменил дату начала на ${activityDate(activity.new_value) ?? "новую дату"}`;
|
||||||
|
case "name":
|
||||||
|
return "изменил название";
|
||||||
|
case "description":
|
||||||
|
return "обновил описание";
|
||||||
|
case "labels":
|
||||||
|
return "изменил метки";
|
||||||
|
case "estimate_point":
|
||||||
|
return "изменил оценку";
|
||||||
|
case "archived_at":
|
||||||
|
return activity.new_value === "restore" ? "восстановил рабочий элемент" : "архивировал рабочий элемент";
|
||||||
|
default:
|
||||||
|
return "обновил рабочий элемент";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
type TRecentWidgetProps = THomeWidgetProps & {
|
type TRecentWidgetProps = THomeWidgetProps & {
|
||||||
presetFilter?: TRecentActivityFilterKeys;
|
presetFilter?: TRecentActivityFilterKeys;
|
||||||
showFilterSelect?: boolean;
|
showFilterSelect?: boolean;
|
||||||
|
|
@ -37,16 +90,102 @@ type TRecentWidgetProps = THomeWidgetProps & {
|
||||||
recents?: TActivityEntityData[];
|
recents?: TActivityEntityData[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function RecentActivityTraceItem({
|
||||||
|
activity,
|
||||||
|
currentUserId,
|
||||||
|
workspaceSlug,
|
||||||
|
}: {
|
||||||
|
activity: IIssueActivity;
|
||||||
|
currentUserId?: string;
|
||||||
|
workspaceSlug: string;
|
||||||
|
}) {
|
||||||
|
const actorName =
|
||||||
|
currentUserId === activity.actor_detail?.id ? "Вы" : activity.actor_detail?.display_name || "Пользователь";
|
||||||
|
const issueIdentifier =
|
||||||
|
activity.project_detail?.identifier && activity.issue_detail?.sequence_id
|
||||||
|
? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`
|
||||||
|
: null;
|
||||||
|
const issueTitle = activity.issue_detail?.name ?? "рабочем элементе";
|
||||||
|
const issueLink =
|
||||||
|
activity.issue && activity.issue_detail
|
||||||
|
? generateWorkItemLink({
|
||||||
|
workspaceSlug,
|
||||||
|
projectId: activity.project,
|
||||||
|
issueId: activity.issue,
|
||||||
|
projectIdentifier: activity.project_detail?.identifier,
|
||||||
|
sequenceId: activity.issue_detail?.sequence_id,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className="group flex min-w-0 items-start gap-3 rounded-[18px] px-3 py-2.5 transition-colors hover:bg-white/[0.03]">
|
||||||
|
<Avatar
|
||||||
|
name={activity.actor_detail?.display_name}
|
||||||
|
src={getFileURL(activity.actor_detail?.avatar_url)}
|
||||||
|
size="sm"
|
||||||
|
shape="circle"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 text-13 leading-5 text-secondary">
|
||||||
|
<span className="font-medium text-primary">{actorName}</span>{" "}
|
||||||
|
<span>{activityMessage(activity)}</span>{" "}
|
||||||
|
<span className="font-medium text-primary">
|
||||||
|
{issueIdentifier ? `${issueIdentifier}: ${issueTitle}` : issueTitle}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2 pt-0.5">
|
||||||
|
<span className="text-11 whitespace-nowrap text-placeholder">{calculateTimeAgo(activity.created_at)}</span>
|
||||||
|
<span className="grid size-6 place-items-center rounded-full bg-white/[0.04] text-tertiary">
|
||||||
|
{activity.field ? <ActivityIcon activity={activity} /> : <WorkItemsIcon className="size-3" />}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!issueLink) return content;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a href={issueLink} className="block">
|
||||||
|
{content}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const RecentActivityWidget = observer(function RecentActivityWidget(props: TRecentWidgetProps) {
|
export const RecentActivityWidget = observer(function RecentActivityWidget(props: TRecentWidgetProps) {
|
||||||
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 { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { data: currentUser } = useUser();
|
||||||
// ref
|
// ref
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const shouldUseActivityTrace = filter === filters[0].name || filter === "issue";
|
||||||
|
|
||||||
|
const { data: fetchedActivity, isLoading: isActivityLoading } = useSWR(
|
||||||
|
workspaceSlug && shouldUseActivityTrace
|
||||||
|
? `WORKSPACE_ACTIVITY_TRACE_${workspaceSlug}_${projectId ?? "all"}`
|
||||||
|
: null,
|
||||||
|
workspaceSlug
|
||||||
|
? () =>
|
||||||
|
workspaceService.fetchWorkspaceActivity(workspaceSlug.toString(), {
|
||||||
|
per_page: 20,
|
||||||
|
...(projectId ? { project: projectId } : {}),
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
{
|
||||||
|
revalidateIfStale: false,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
revalidateOnReconnect: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const { data: fetchedRecents, isLoading } = useSWR(
|
const { data: fetchedRecents, isLoading } = useSWR(
|
||||||
workspaceSlug && !preloadedRecents ? `WORKSPACE_RECENT_ACTIVITY_${workspaceSlug}_${filter}` : null,
|
workspaceSlug && !preloadedRecents && !shouldUseActivityTrace
|
||||||
|
? `WORKSPACE_RECENT_ACTIVITY_${workspaceSlug}_${filter}`
|
||||||
|
: null,
|
||||||
workspaceSlug
|
workspaceSlug
|
||||||
? () =>
|
? () =>
|
||||||
workspaceService.fetchWorkspaceRecents(
|
workspaceService.fetchWorkspaceRecents(
|
||||||
|
|
@ -61,6 +200,8 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const activityTrace = useMemo(() => fetchedActivity?.results ?? [], [fetchedActivity?.results]);
|
||||||
|
|
||||||
const recents = useMemo(() => {
|
const recents = useMemo(() => {
|
||||||
const source = preloadedRecents ?? fetchedRecents ?? [];
|
const source = preloadedRecents ?? fetchedRecents ?? [];
|
||||||
const filteredByType = source.filter((activity) =>
|
const filteredByType = source.filter((activity) =>
|
||||||
|
|
@ -74,6 +215,9 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
|
||||||
});
|
});
|
||||||
}, [fetchedRecents, filter, preloadedRecents, projectId]);
|
}, [fetchedRecents, filter, preloadedRecents, projectId]);
|
||||||
|
|
||||||
|
const isWidgetLoading = shouldUseActivityTrace ? isActivityLoading : isLoading;
|
||||||
|
const isWidgetEmpty = shouldUseActivityTrace ? activityTrace.length === 0 : recents.length === 0;
|
||||||
|
|
||||||
const resolveRecent = (activity: TActivityEntityData) => {
|
const resolveRecent = (activity: TActivityEntityData) => {
|
||||||
switch (activity.entity_name) {
|
switch (activity.entity_name) {
|
||||||
case "page":
|
case "page":
|
||||||
|
|
@ -88,7 +232,7 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isLoading && recents.length === 0)
|
if (!isWidgetLoading && isWidgetEmpty)
|
||||||
return (
|
return (
|
||||||
<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">
|
||||||
|
|
@ -108,8 +252,20 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
|
||||||
{showFilterSelect && <FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />}
|
{showFilterSelect && <FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />}
|
||||||
</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">
|
||||||
{isLoading && <WidgetLoader widgetKey={WIDGET_KEY} />}
|
{isWidgetLoading && <WidgetLoader widgetKey={WIDGET_KEY} />}
|
||||||
{!isLoading && recents.map((activity) => <div key={activity.id}>{resolveRecent(activity)}</div>)}
|
{!isWidgetLoading &&
|
||||||
|
shouldUseActivityTrace &&
|
||||||
|
activityTrace.map((activity) => (
|
||||||
|
<RecentActivityTraceItem
|
||||||
|
key={activity.id}
|
||||||
|
activity={activity}
|
||||||
|
currentUserId={currentUser?.id}
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!isWidgetLoading &&
|
||||||
|
!shouldUseActivityTrace &&
|
||||||
|
recents.map((activity) => <div key={activity.id}>{resolveRecent(activity)}</div>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
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,
|
||||||
|
|
@ -17,7 +18,6 @@ import {
|
||||||
Flag,
|
Flag,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
LoaderCircle,
|
|
||||||
Mic,
|
Mic,
|
||||||
Pause,
|
Pause,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
|
@ -86,14 +86,6 @@ const voiceTaskCloseButtonClassName =
|
||||||
const VOICE_TASK_TIME_HOURS = Array.from({ length: 24 }, (_, index) => index.toString().padStart(2, "0"));
|
const VOICE_TASK_TIME_HOURS = Array.from({ length: 24 }, (_, index) => index.toString().padStart(2, "0"));
|
||||||
const VOICE_TASK_TIME_MINUTES = Array.from({ length: 60 }, (_, index) => index.toString().padStart(2, "0"));
|
const VOICE_TASK_TIME_MINUTES = Array.from({ length: 60 }, (_, index) => index.toString().padStart(2, "0"));
|
||||||
const VOICE_TASK_TIME_WHEEL_ITEM_HEIGHT = 36;
|
const VOICE_TASK_TIME_WHEEL_ITEM_HEIGHT = 36;
|
||||||
const VOICE_TASK_WAVEFORM_BAR_COUNT = 32;
|
|
||||||
const VOICE_TASK_SPEECH_MIN_HZ = 85;
|
|
||||||
const VOICE_TASK_SPEECH_MAX_HZ = 3800;
|
|
||||||
|
|
||||||
function clampVoiceTaskLevel(value: number) {
|
|
||||||
if (!Number.isFinite(value)) return 0;
|
|
||||||
return Math.max(0, Math.min(1, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSupportedMimeType() {
|
function getSupportedMimeType() {
|
||||||
if (typeof MediaRecorder === "undefined") return "";
|
if (typeof MediaRecorder === "undefined") return "";
|
||||||
|
|
@ -194,6 +186,21 @@ function getVoiceTaskAssigneeIds(result: TVoiceTaskUploadResult | null) {
|
||||||
return resolvedAssignees.flatMap((assignee) => (assignee?.id ? [assignee.id] : []));
|
return resolvedAssignees.flatMap((assignee) => (assignee?.id ? [assignee.id] : []));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hydrateVoiceTaskDraftFromResolution(result: TVoiceTaskUploadResult): TVoiceTaskUploadResult {
|
||||||
|
if (!result.draft) return result;
|
||||||
|
|
||||||
|
const resolvedAssigneeIds = getVoiceTaskAssigneeIds(result);
|
||||||
|
if (result.draft.assignee_ids?.length || resolvedAssigneeIds.length === 0) return result;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
draft: {
|
||||||
|
...result.draft,
|
||||||
|
assignee_ids: resolvedAssigneeIds,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getPriorityLabel(priority: TVoiceTaskPriority) {
|
function getPriorityLabel(priority: TVoiceTaskPriority) {
|
||||||
return priority ? PRIORITY_LABELS[priority] : "Не распознано";
|
return priority ? PRIORITY_LABELS[priority] : "Не распознано";
|
||||||
}
|
}
|
||||||
|
|
@ -394,31 +401,50 @@ function VoiceTaskAudioPlayer({ audioUrl }: { audioUrl: string }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function VoiceTaskWaveform({ isRecording, levels }: { isRecording: boolean; levels: number[] }) {
|
function VoiceTaskWaveform({ mediaRecorder }: { mediaRecorder: MediaRecorder | null }) {
|
||||||
const fallbackLevels = useMemo(() => Array.from({ length: VOICE_TASK_WAVEFORM_BAR_COUNT }, () => 0), []);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const renderedLevels = isRecording && levels.length ? levels : fallbackLevels;
|
const [visualizerWidth, setVisualizerWidth] = useState(360);
|
||||||
|
const [accentColor, setAccentColor] = useState("rgb(185, 255, 82)");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const accentRgb = getComputedStyle(root).getPropertyValue("--nodedc-accent-rgb").trim();
|
||||||
|
const channels = accentRgb.split(/[,\s]+/).filter(Boolean).slice(0, 3);
|
||||||
|
|
||||||
|
if (channels.length === 3) setAccentColor(`rgb(${channels.join(", ")})`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const node = containerRef.current;
|
||||||
|
if (!node || typeof ResizeObserver === "undefined") return;
|
||||||
|
|
||||||
|
const updateWidth = () => setVisualizerWidth(Math.max(1, Math.floor(node.getBoundingClientRect().width)));
|
||||||
|
updateWidth();
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(updateWidth);
|
||||||
|
observer.observe(node);
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mediaRecorder) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-28 overflow-hidden rounded-[28px] bg-black/20 px-5">
|
<div className="relative h-28 overflow-hidden rounded-[28px] bg-black/20 px-5">
|
||||||
<span className="absolute top-1/2 right-5 left-5 h-px -translate-y-1/2 bg-[rgb(var(--nodedc-accent-rgb))]/20" />
|
<div ref={containerRef} className="h-full w-full py-6">
|
||||||
<div
|
<LiveAudioVisualizer
|
||||||
className="relative z-10 grid h-full items-center justify-items-center gap-1"
|
mediaRecorder={mediaRecorder}
|
||||||
style={{ gridTemplateColumns: `repeat(${VOICE_TASK_WAVEFORM_BAR_COUNT}, minmax(0, 1fr))` }}
|
width={visualizerWidth}
|
||||||
>
|
height={64}
|
||||||
{renderedLevels.map((level, index) => (
|
barWidth={5}
|
||||||
<span
|
gap={5}
|
||||||
key={index}
|
backgroundColor="transparent"
|
||||||
className={cn(
|
barColor={accentColor}
|
||||||
"w-1.5 rounded-full bg-[rgb(var(--nodedc-accent-rgb))] transition-[height,opacity] duration-[55ms]",
|
fftSize={1024}
|
||||||
!isRecording && "animate-pulse"
|
minDecibels={-92}
|
||||||
)}
|
maxDecibels={-18}
|
||||||
style={{
|
smoothingTimeConstant={0.68}
|
||||||
height: `${Math.round(6 + Math.max(0, Math.min(1, level)) * 88)}px`,
|
|
||||||
opacity: isRecording ? 0.36 + Math.min(1, level) * 0.64 : 0.42,
|
|
||||||
animationDelay: `${index * 35}ms`,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -426,12 +452,8 @@ function VoiceTaskWaveform({ isRecording, levels }: { isRecording: boolean; leve
|
||||||
|
|
||||||
function VoiceTaskProcessingState() {
|
function VoiceTaskProcessingState() {
|
||||||
return (
|
return (
|
||||||
<div className="mt-6 flex items-center justify-center py-12">
|
<div className="mt-2 flex h-40 items-center justify-center">
|
||||||
<div className="relative flex size-28 items-center justify-center rounded-full bg-[rgb(var(--nodedc-accent-rgb))]/10">
|
<span className="nodedc-voice-task-processing-loader" aria-hidden="true" />
|
||||||
<span className="absolute inset-2 rounded-full border border-[rgb(var(--nodedc-accent-rgb))]/20" />
|
|
||||||
<span className="absolute inset-0 rounded-full bg-[rgb(var(--nodedc-accent-rgb))]/10 blur-2xl" />
|
|
||||||
<LoaderCircle className="relative size-12 animate-spin text-[rgb(var(--nodedc-accent-rgb))]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -731,10 +753,11 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||||
const [audioLevels, setAudioLevels] = useState<number[]>([]);
|
const [recordingMediaRecorder, setRecordingMediaRecorder] = useState<MediaRecorder | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [parseResult, setParseResult] = useState<TVoiceTaskUploadResult | null>(null);
|
const [parseResult, setParseResult] = useState<TVoiceTaskUploadResult | null>(null);
|
||||||
const [commitResult, setCommitResult] = useState<TVoiceTaskCommitResult | null>(null);
|
const [commitResult, setCommitResult] = useState<TVoiceTaskCommitResult | null>(null);
|
||||||
|
const [hasDraftChanges, setHasDraftChanges] = useState(false);
|
||||||
const [selectedTargetIssue, setSelectedTargetIssue] = useState<TVoiceTaskTargetOption | null>(null);
|
const [selectedTargetIssue, setSelectedTargetIssue] = useState<TVoiceTaskTargetOption | null>(null);
|
||||||
|
|
||||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
|
@ -744,9 +767,6 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
const timerRef = useRef<number | null>(null);
|
const timerRef = useRef<number | null>(null);
|
||||||
const closeResetTimerRef = useRef<number | null>(null);
|
const closeResetTimerRef = useRef<number | null>(null);
|
||||||
const startedAtRef = useRef(0);
|
const startedAtRef = useRef(0);
|
||||||
const audioContextRef = useRef<AudioContext | null>(null);
|
|
||||||
const audioSourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
|
||||||
const audioVisualizerFrameRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
const { data: preflight } = useSWR(
|
const { data: preflight } = useSWR(
|
||||||
workspaceSlug ? `VOICE_TASK_PREFLIGHT_${workspaceSlug}` : null,
|
workspaceSlug ? `VOICE_TASK_PREFLIGHT_${workspaceSlug}` : null,
|
||||||
|
|
@ -780,146 +800,6 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const stopAudioVisualization = useCallback(() => {
|
|
||||||
if (audioVisualizerFrameRef.current) {
|
|
||||||
window.cancelAnimationFrame(audioVisualizerFrameRef.current);
|
|
||||||
audioVisualizerFrameRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
audioSourceRef.current?.disconnect();
|
|
||||||
audioSourceRef.current = null;
|
|
||||||
|
|
||||||
if (audioContextRef.current && audioContextRef.current.state !== "closed") {
|
|
||||||
void audioContextRef.current.close();
|
|
||||||
}
|
|
||||||
audioContextRef.current = null;
|
|
||||||
setAudioLevels([]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startAudioVisualization = useCallback(
|
|
||||||
(stream: MediaStream) => {
|
|
||||||
stopAudioVisualization();
|
|
||||||
|
|
||||||
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);
|
|
||||||
const previousLevels = Array.from({ length: VOICE_TASK_WAVEFORM_BAR_COUNT }, () => 0);
|
|
||||||
let smoothedVoiceLevel = 0;
|
|
||||||
let rollingNoiseFloor = 0.035;
|
|
||||||
let rollingVoiceCeiling = 0.28;
|
|
||||||
|
|
||||||
analyser.fftSize = 1024;
|
|
||||||
analyser.minDecibels = -90;
|
|
||||||
analyser.maxDecibels = -12;
|
|
||||||
analyser.smoothingTimeConstant = 0.38;
|
|
||||||
const frequencyData = new Uint8Array(analyser.frequencyBinCount);
|
|
||||||
const timeDomainData = new Uint8Array(analyser.fftSize);
|
|
||||||
source.connect(analyser);
|
|
||||||
audioContextRef.current = audioContext;
|
|
||||||
audioSourceRef.current = source;
|
|
||||||
void audioContext.resume();
|
|
||||||
|
|
||||||
const tick = () => {
|
|
||||||
analyser.getByteFrequencyData(frequencyData);
|
|
||||||
analyser.getByteTimeDomainData(timeDomainData);
|
|
||||||
|
|
||||||
let rmsSum = 0;
|
|
||||||
for (const value of timeDomainData) {
|
|
||||||
const centeredValue = (value - 128) / 128;
|
|
||||||
rmsSum += centeredValue * centeredValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rms = Math.sqrt(rmsSum / timeDomainData.length);
|
|
||||||
const nyquist = audioContext.sampleRate / 2;
|
|
||||||
const voiceStartBin = Math.max(1, Math.floor((VOICE_TASK_SPEECH_MIN_HZ / nyquist) * frequencyData.length));
|
|
||||||
const voiceEndBin = Math.min(
|
|
||||||
frequencyData.length - 1,
|
|
||||||
Math.ceil((VOICE_TASK_SPEECH_MAX_HZ / nyquist) * frequencyData.length)
|
|
||||||
);
|
|
||||||
let bandEnergy = 0;
|
|
||||||
let bandWeight = 0;
|
|
||||||
|
|
||||||
for (let bin = voiceStartBin; bin <= voiceEndBin; bin++) {
|
|
||||||
const frequency = (bin / frequencyData.length) * nyquist;
|
|
||||||
const voiceWeight = frequency < 160 ? 0.55 : frequency > 3200 ? 0.65 : frequency < 260 ? 0.82 : 1;
|
|
||||||
bandEnergy += Math.pow(frequencyData[bin] / 255, 1.12) * voiceWeight;
|
|
||||||
bandWeight += voiceWeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
const frequencyEnergy = bandWeight > 0 ? bandEnergy / bandWeight : 0;
|
|
||||||
const rmsEnergy = clampVoiceTaskLevel((rms - 0.006) / 0.13);
|
|
||||||
const rawVoiceEnergy = frequencyEnergy * 0.68 + rmsEnergy * 0.32;
|
|
||||||
const floorSpeed = rawVoiceEnergy > rollingNoiseFloor ? 0.004 : 0.045;
|
|
||||||
rollingNoiseFloor = clampVoiceTaskLevel(rollingNoiseFloor + (rawVoiceEnergy - rollingNoiseFloor) * floorSpeed);
|
|
||||||
rollingNoiseFloor = Math.max(0.018, Math.min(0.12, rollingNoiseFloor));
|
|
||||||
rollingVoiceCeiling =
|
|
||||||
rawVoiceEnergy > rollingVoiceCeiling
|
|
||||||
? rollingVoiceCeiling + (rawVoiceEnergy - rollingVoiceCeiling) * 0.08
|
|
||||||
: rollingVoiceCeiling * 0.996 + 0.001;
|
|
||||||
rollingVoiceCeiling = Math.max(rollingNoiseFloor + 0.16, Math.min(0.58, rollingVoiceCeiling));
|
|
||||||
|
|
||||||
const adaptiveRange = Math.max(0.14, rollingVoiceCeiling - rollingNoiseFloor);
|
|
||||||
const absoluteVoice = clampVoiceTaskLevel((rawVoiceEnergy - 0.026) / 0.26);
|
|
||||||
const adaptiveVoice = clampVoiceTaskLevel((rawVoiceEnergy - rollingNoiseFloor * 1.12) / adaptiveRange);
|
|
||||||
const isMutedFrame = rawVoiceEnergy < rollingNoiseFloor + 0.012 && rms < 0.012;
|
|
||||||
const compressedVoice = isMutedFrame
|
|
||||||
? 0
|
|
||||||
: (1 - Math.exp(-(absoluteVoice * 0.66 + adaptiveVoice * 0.34) * 1.62)) * 0.92;
|
|
||||||
const voiceAttack = compressedVoice > smoothedVoiceLevel ? 0.46 : 0.2;
|
|
||||||
smoothedVoiceLevel += (compressedVoice - smoothedVoiceLevel) * voiceAttack;
|
|
||||||
|
|
||||||
const now = performance.now();
|
|
||||||
const phase = now / 72;
|
|
||||||
|
|
||||||
const nextLevels = Array.from({ length: VOICE_TASK_WAVEFORM_BAR_COUNT }, (_, index) => {
|
|
||||||
const barPosition = index / Math.max(1, VOICE_TASK_WAVEFORM_BAR_COUNT - 1);
|
|
||||||
const frequencyOffset = Math.sin(barPosition * Math.PI * 0.5);
|
|
||||||
const bandCenter = voiceStartBin + Math.floor((voiceEndBin - voiceStartBin) * frequencyOffset);
|
|
||||||
const bandRadius = Math.max(1, Math.floor((voiceEndBin - voiceStartBin) / VOICE_TASK_WAVEFORM_BAR_COUNT));
|
|
||||||
let localFrequencyEnergy = 0;
|
|
||||||
let localFrequencyCount = 0;
|
|
||||||
|
|
||||||
for (
|
|
||||||
let bin = Math.max(voiceStartBin, bandCenter - bandRadius);
|
|
||||||
bin <= Math.min(voiceEndBin, bandCenter + bandRadius);
|
|
||||||
bin++
|
|
||||||
) {
|
|
||||||
localFrequencyEnergy += frequencyData[bin] / 255;
|
|
||||||
localFrequencyCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const spectralTexture =
|
|
||||||
localFrequencyCount > 0
|
|
||||||
? clampVoiceTaskLevel((localFrequencyEnergy / localFrequencyCount - rollingNoiseFloor) / adaptiveRange)
|
|
||||||
: 0;
|
|
||||||
const centerLift = Math.sin(barPosition * Math.PI);
|
|
||||||
const shapeWeight = 0.72 + Math.pow(centerLift, 0.8) * 0.28;
|
|
||||||
const motion = 0.92 + Math.sin(phase + index * 0.78) * 0.045 + Math.sin(phase * 1.47 + index * 1.73) * 0.025;
|
|
||||||
const targetLevel = clampVoiceTaskLevel(
|
|
||||||
(smoothedVoiceLevel * 0.78 + spectralTexture * smoothedVoiceLevel * 0.22) * shapeWeight * motion
|
|
||||||
);
|
|
||||||
const previousLevel = previousLevels[index] ?? 0;
|
|
||||||
const smoothing = targetLevel > previousLevel ? 0.52 : 0.24;
|
|
||||||
const smoothedLevel = previousLevel + (targetLevel - previousLevel) * smoothing;
|
|
||||||
|
|
||||||
previousLevels[index] = smoothedLevel;
|
|
||||||
return smoothedLevel;
|
|
||||||
});
|
|
||||||
|
|
||||||
setAudioLevels(nextLevels);
|
|
||||||
audioVisualizerFrameRef.current = window.requestAnimationFrame(tick);
|
|
||||||
};
|
|
||||||
|
|
||||||
tick();
|
|
||||||
},
|
|
||||||
[stopAudioVisualization]
|
|
||||||
);
|
|
||||||
|
|
||||||
const stopStream = useCallback(() => {
|
const stopStream = useCallback(() => {
|
||||||
streamRef.current?.getTracks().forEach((track) => track.stop());
|
streamRef.current?.getTracks().forEach((track) => track.stop());
|
||||||
streamRef.current = null;
|
streamRef.current = null;
|
||||||
|
|
@ -928,11 +808,12 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
const resetVoiceTaskState = useCallback(() => {
|
const resetVoiceTaskState = useCallback(() => {
|
||||||
setAudioBlob(null);
|
setAudioBlob(null);
|
||||||
setAudioUrl(null);
|
setAudioUrl(null);
|
||||||
setAudioLevels([]);
|
setRecordingMediaRecorder(null);
|
||||||
setDuration(0);
|
setDuration(0);
|
||||||
setError(null);
|
setError(null);
|
||||||
setParseResult(null);
|
setParseResult(null);
|
||||||
setCommitResult(null);
|
setCommitResult(null);
|
||||||
|
setHasDraftChanges(false);
|
||||||
setSelectedTargetIssue(null);
|
setSelectedTargetIssue(null);
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -950,17 +831,17 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
if (recorder && recorder.state === "recording") {
|
if (recorder && recorder.state === "recording") {
|
||||||
recorder.stop();
|
recorder.stop();
|
||||||
if (discard) {
|
if (discard) {
|
||||||
stopAudioVisualization();
|
|
||||||
stopStream();
|
stopStream();
|
||||||
}
|
}
|
||||||
|
setRecordingMediaRecorder(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
stopAudioVisualization();
|
|
||||||
stopStream();
|
stopStream();
|
||||||
mediaRecorderRef.current = null;
|
mediaRecorderRef.current = null;
|
||||||
|
setRecordingMediaRecorder(null);
|
||||||
},
|
},
|
||||||
[clearTimer, stopAudioVisualization, stopStream]
|
[clearTimer, stopStream]
|
||||||
);
|
);
|
||||||
|
|
||||||
const resetRecording = useCallback(() => {
|
const resetRecording = useCallback(() => {
|
||||||
|
|
@ -989,10 +870,9 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
() => () => {
|
() => () => {
|
||||||
clearTimer();
|
clearTimer();
|
||||||
clearCloseResetTimer();
|
clearCloseResetTimer();
|
||||||
stopAudioVisualization();
|
|
||||||
stopStream();
|
stopStream();
|
||||||
},
|
},
|
||||||
[clearCloseResetTimer, clearTimer, stopAudioVisualization, stopStream]
|
[clearCloseResetTimer, clearTimer, stopStream]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1044,8 +924,8 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
|
|
||||||
chunksRef.current = [];
|
chunksRef.current = [];
|
||||||
mediaRecorderRef.current = null;
|
mediaRecorderRef.current = null;
|
||||||
|
setRecordingMediaRecorder(null);
|
||||||
if (isDiscarded) discardedRecorderRef.current = null;
|
if (isDiscarded) discardedRecorderRef.current = null;
|
||||||
stopAudioVisualization();
|
|
||||||
stopStream();
|
stopStream();
|
||||||
|
|
||||||
if (isDiscarded || !chunks.length) return;
|
if (isDiscarded || !chunks.length) return;
|
||||||
|
|
@ -1055,7 +935,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
};
|
};
|
||||||
|
|
||||||
recorder.start();
|
recorder.start();
|
||||||
startAudioVisualization(stream);
|
setRecordingMediaRecorder(recorder);
|
||||||
startedAtRef.current = Date.now();
|
startedAtRef.current = Date.now();
|
||||||
setDuration(0);
|
setDuration(0);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -1069,7 +949,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
} catch {
|
} catch {
|
||||||
setError("Не удалось получить доступ к микрофону.");
|
setError("Не удалось получить доступ к микрофону.");
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
stopAudioVisualization();
|
setRecordingMediaRecorder(null);
|
||||||
stopStream();
|
stopStream();
|
||||||
clearTimer();
|
clearTimer();
|
||||||
}
|
}
|
||||||
|
|
@ -1082,6 +962,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
setError(null);
|
setError(null);
|
||||||
setParseResult(null);
|
setParseResult(null);
|
||||||
setCommitResult(null);
|
setCommitResult(null);
|
||||||
|
setHasDraftChanges(false);
|
||||||
|
|
||||||
const audioType = audioBlob.type || "audio/webm";
|
const audioType = audioBlob.type || "audio/webm";
|
||||||
const extension = audioType.includes("mp4") ? "m4a" : "webm";
|
const extension = audioType.includes("mp4") ? "m4a" : "webm";
|
||||||
|
|
@ -1099,15 +980,43 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await workspaceAIService.uploadVoiceTaskAudio(workspaceSlug, formData);
|
const result = hydrateVoiceTaskDraftFromResolution(
|
||||||
|
await workspaceAIService.uploadVoiceTaskAudio(workspaceSlug, formData)
|
||||||
|
);
|
||||||
setParseResult(result);
|
setParseResult(result);
|
||||||
setSelectedTargetIssue(getTargetOptionFromResolution(result));
|
setSelectedTargetIssue(getTargetOptionFromResolution(result));
|
||||||
setStatus("success");
|
setStatus("success");
|
||||||
|
setHasDraftChanges(false);
|
||||||
|
|
||||||
|
const shouldAutoCommit =
|
||||||
|
result.voice_session_id && result.draft?.intent === "create_task" && result.resolution?.can_commit;
|
||||||
|
|
||||||
|
if (shouldAutoCommit) {
|
||||||
|
try {
|
||||||
|
await commitVoiceTaskResult(result, { confirmDelete: false, notify: false });
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.SUCCESS,
|
||||||
|
title: "Карточка добавлена в Kanban",
|
||||||
|
message: "Можно проверить поля и применить правки к уже созданной задаче.",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
typeof err === "object" && err && "error" in err
|
||||||
|
? String(err.error)
|
||||||
|
: "Карточку распознали, но не смогли автоматически добавить в Kanban.";
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.ERROR,
|
||||||
|
title: "Черновик готов",
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
setToast({
|
setToast({
|
||||||
type: TOAST_TYPE.SUCCESS,
|
type: TOAST_TYPE.SUCCESS,
|
||||||
title: "Черновик готов",
|
title: "Черновик готов",
|
||||||
message: "Transcript и draft получены.",
|
message: "Transcript и draft получены.",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message =
|
const message =
|
||||||
typeof err === "object" && err && "error" in err ? String(err.error) : "Не удалось отправить аудио.";
|
typeof err === "object" && err && "error" in err ? String(err.error) : "Не удалось отправить аудио.";
|
||||||
|
|
@ -1147,17 +1056,28 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const commitVoiceTask = async () => {
|
async function commitVoiceTaskResult(
|
||||||
if (!parseResult?.voice_session_id || !parseResult.draft) return;
|
sourceResult: TVoiceTaskUploadResult,
|
||||||
|
{
|
||||||
|
closeOnSuccess = false,
|
||||||
|
confirmDelete = true,
|
||||||
|
notify = true,
|
||||||
|
}: {
|
||||||
|
closeOnSuccess?: boolean;
|
||||||
|
confirmDelete?: boolean;
|
||||||
|
notify?: boolean;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
if (!sourceResult.voice_session_id || !sourceResult.draft) return null;
|
||||||
|
|
||||||
const action = parseResult.draft.intent;
|
const action = sourceResult.draft.intent;
|
||||||
if (action === "unknown") return;
|
if (action === "unknown") return null;
|
||||||
|
|
||||||
if (action === "delete_task") {
|
if (action === "delete_task" && confirmDelete) {
|
||||||
const targetTitle =
|
const targetTitle =
|
||||||
parseResult.resolution?.target_task?.key || parseResult.resolution?.target_task?.title || "последнюю задачу";
|
sourceResult.resolution?.target_task?.key || sourceResult.resolution?.target_task?.title || "последнюю задачу";
|
||||||
const confirmed = window.confirm(`Удалить ${targetTitle}?`);
|
const confirmed = window.confirm(`Удалить ${targetTitle}?`);
|
||||||
if (!confirmed) return;
|
if (!confirmed) return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus("committing");
|
setStatus("committing");
|
||||||
|
|
@ -1165,30 +1085,47 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await workspaceAIService.commitVoiceTask(workspaceSlug, {
|
const result = await workspaceAIService.commitVoiceTask(workspaceSlug, {
|
||||||
voice_session_id: parseResult.voice_session_id,
|
voice_session_id: sourceResult.voice_session_id,
|
||||||
action,
|
action,
|
||||||
draft: parseResult.draft,
|
draft: sourceResult.draft,
|
||||||
});
|
});
|
||||||
await refreshVisibleIssueStores();
|
await refreshVisibleIssueStores();
|
||||||
setCommitResult(result);
|
setCommitResult(result);
|
||||||
|
setHasDraftChanges(false);
|
||||||
setStatus("committed");
|
setStatus("committed");
|
||||||
|
if (notify) {
|
||||||
setToast({
|
setToast({
|
||||||
type: TOAST_TYPE.SUCCESS,
|
type: TOAST_TYPE.SUCCESS,
|
||||||
title: getCommitSuccessTitle(result),
|
title: getCommitSuccessTitle(result),
|
||||||
message: getCommitSuccessMessage(result),
|
message: getCommitSuccessMessage(result),
|
||||||
});
|
});
|
||||||
handleClose();
|
}
|
||||||
|
if (closeOnSuccess) handleClose();
|
||||||
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message =
|
const message =
|
||||||
typeof err === "object" && err && "error" in err ? String(err.error) : "Не удалось применить Voice Task.";
|
typeof err === "object" && err && "error" in err ? String(err.error) : "Не удалось применить Voice Task.";
|
||||||
setError(message);
|
setError(message);
|
||||||
setStatus("error");
|
setStatus("success");
|
||||||
|
if (notify) {
|
||||||
setToast({
|
setToast({
|
||||||
type: TOAST_TYPE.ERROR,
|
type: TOAST_TYPE.ERROR,
|
||||||
title: "Voice Task не применен",
|
title: "Voice Task не применен",
|
||||||
message,
|
message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitVoiceTask = async () => {
|
||||||
|
if (!parseResult?.voice_session_id || !parseResult.draft) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await commitVoiceTaskResult(parseResult, { closeOnSuccess: !commitResult?.task_id });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateDraft = useCallback((patch: Partial<TVoiceTaskDraft>) => {
|
const updateDraft = useCallback((patch: Partial<TVoiceTaskDraft>) => {
|
||||||
|
|
@ -1202,7 +1139,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
setCommitResult(null);
|
setHasDraftChanges(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const draft = parseResult?.draft;
|
const draft = parseResult?.draft;
|
||||||
|
|
@ -1225,7 +1162,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
draft.intent !== "unknown" &&
|
draft.intent !== "unknown" &&
|
||||||
!isUploading &&
|
!isUploading &&
|
||||||
!isCommitting &&
|
!isCommitting &&
|
||||||
!commitResult?.task_id &&
|
(!commitResult?.task_id || hasDraftChanges) &&
|
||||||
(draft.intent === "create_task"
|
(draft.intent === "create_task"
|
||||||
? selectedProjectId && draft.title?.trim()
|
? selectedProjectId && draft.title?.trim()
|
||||||
: selectedTargetTaskId && (draft.intent === "delete_task" || selectedProjectId))
|
: selectedTargetTaskId && (draft.intent === "delete_task" || selectedProjectId))
|
||||||
|
|
@ -1281,16 +1218,11 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-12 text-tertiary">{recordingStatusLabel}</div>
|
<div className="mt-1 text-12 text-tertiary">{recordingStatusLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
{isRecording && (
|
|
||||||
<div className="flex size-14 items-center justify-center rounded-full bg-[rgb(var(--nodedc-accent-rgb))]/15 text-[rgb(var(--nodedc-accent-rgb))]">
|
|
||||||
<Mic className="size-6 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{isRecording ? (
|
{isRecording ? (
|
||||||
<VoiceTaskWaveform isRecording={isRecording} levels={audioLevels} />
|
<VoiceTaskWaveform mediaRecorder={recordingMediaRecorder} />
|
||||||
) : audioUrl ? (
|
) : audioUrl ? (
|
||||||
<VoiceTaskAudioPlayer audioUrl={audioUrl} />
|
<VoiceTaskAudioPlayer audioUrl={audioUrl} />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -1546,7 +1478,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{commitResult?.task_key && (
|
{commitResult?.task_key && !hasDraftChanges && (
|
||||||
<div className="border-green-500/25 bg-green-500/10 text-green-300 rounded-[22px] border px-4 py-3 text-12">
|
<div className="border-green-500/25 bg-green-500/10 text-green-300 rounded-[22px] border px-4 py-3 text-12">
|
||||||
{getCommitSuccessMessage(commitResult)}
|
{getCommitSuccessMessage(commitResult)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1565,7 +1497,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
<Mic className="mr-2 size-4" />
|
<Mic className="mr-2 size-4" />
|
||||||
Перезаписать
|
Перезаписать
|
||||||
</Button>
|
</Button>
|
||||||
{!commitResult?.task_id && (
|
{(!commitResult?.task_id || hasDraftChanges) && (
|
||||||
<Button
|
<Button
|
||||||
variant={draft.intent === "delete_task" ? "error-fill" : "primary"}
|
variant={draft.intent === "delete_task" ? "error-fill" : "primary"}
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|
@ -1573,14 +1505,18 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
loading={isCommitting}
|
loading={isCommitting}
|
||||||
disabled={!canPublishDraft}
|
disabled={!canPublishDraft}
|
||||||
>
|
>
|
||||||
{draft.intent === "update_task" ? (
|
{commitResult?.task_id && hasDraftChanges ? (
|
||||||
|
<Pencil className="mr-2 size-4" />
|
||||||
|
) : draft.intent === "update_task" ? (
|
||||||
<Pencil className="mr-2 size-4" />
|
<Pencil className="mr-2 size-4" />
|
||||||
) : draft.intent === "delete_task" ? (
|
) : draft.intent === "delete_task" ? (
|
||||||
<Trash2 className="mr-2 size-4" />
|
<Trash2 className="mr-2 size-4" />
|
||||||
) : (
|
) : (
|
||||||
<CheckCircle2 className="mr-2 size-4" />
|
<CheckCircle2 className="mr-2 size-4" />
|
||||||
)}
|
)}
|
||||||
{getCommitButtonLabel(draft.intent)}
|
{commitResult?.task_id && hasDraftChanges
|
||||||
|
? "Применить изменения"
|
||||||
|
: getCommitButtonLabel(draft.intent)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import type {
|
||||||
TSearchEntityRequestPayload,
|
TSearchEntityRequestPayload,
|
||||||
TWidgetEntityData,
|
TWidgetEntityData,
|
||||||
TActivityEntityData,
|
TActivityEntityData,
|
||||||
|
IUserActivityResponse,
|
||||||
IWorkspaceSidebarNavigationItem,
|
IWorkspaceSidebarNavigationItem,
|
||||||
IWorkspaceSidebarNavigation,
|
IWorkspaceSidebarNavigation,
|
||||||
IWorkspaceUserPropertiesResponse,
|
IWorkspaceUserPropertiesResponse,
|
||||||
|
|
@ -353,6 +354,23 @@ export class WorkspaceService extends APIService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchWorkspaceActivity(
|
||||||
|
workspaceSlug: string,
|
||||||
|
params: {
|
||||||
|
per_page: number;
|
||||||
|
cursor?: string;
|
||||||
|
project?: string;
|
||||||
|
}
|
||||||
|
): Promise<IUserActivityResponse> {
|
||||||
|
return this.get(`/api/workspaces/${workspaceSlug}/activity/`, {
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// widgets
|
// widgets
|
||||||
async fetchWorkspaceWidgets(workspaceSlug: string): Promise<TWidgetEntityData[]> {
|
async fetchWorkspaceWidgets(workspaceSlug: string): Promise<TWidgetEntityData[]> {
|
||||||
return this.get(`/api/workspaces/${workspaceSlug}/home-preferences/`)
|
return this.get(`/api/workspaces/${workspaceSlug}/home-preferences/`)
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -3936,4 +3936,63 @@
|
||||||
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;
|
color: var(--text-color-primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-voice-task-processing-loader {
|
||||||
|
width: 5rem;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
color: rgb(var(--nodedc-accent-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-voice-task-processing-loader::before,
|
||||||
|
.nodedc-voice-task-processing-loader::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-voice-task-processing-loader::before {
|
||||||
|
inset: 1.125rem;
|
||||||
|
border: 0.5rem solid currentColor;
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
box-shadow: 0 0 1.25rem rgba(var(--nodedc-accent-rgb), 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-voice-task-processing-loader::after {
|
||||||
|
width: 1rem;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: currentColor;
|
||||||
|
box-shadow: 0 0 1.125rem rgba(var(--nodedc-accent-rgb), 0.28);
|
||||||
|
offset-anchor: center;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nodedc-voice-task-processing-loader {
|
||||||
|
0% {
|
||||||
|
offset-distance: 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
25% {
|
||||||
|
offset-distance: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
offset-distance: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
75% {
|
||||||
|
offset-distance: 75%;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
offset-distance: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -668,6 +668,9 @@ 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)
|
||||||
|
|
@ -7464,6 +7467,12 @@ 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:
|
||||||
|
|
@ -15074,6 +15083,11 @@ 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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue