From 7d520c7aaf2d95419908ff1547a5616bfaadd4b6 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 25 Apr 2026 19:23:56 +0300 Subject: [PATCH] =?UTF-8?q?UI=20-=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E?= =?UTF-8?q?=D0=95=D0=9A=D0=A2=D0=9D=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C?= =?UTF-8?q?=D0=A3=D0=9D=D0=98=D0=9A=D0=90=D0=A6=D0=98=D0=AF:=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=BD=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D0=B3=D0=B0=D1=86=D0=B8=D1=8F=20workspace=20settin?= =?UTF-8?q?gs=20=D0=B8=20Voice=20Tasker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(workspace)/ai-voice-tasker/page.tsx | 366 +--------------- .../components/navigation/app-rail-root.tsx | 28 +- .../settings/workspace/sidebar/header.tsx | 13 +- .../settings/ai-voice-tasker-settings.tsx | 389 ++++++++++++++++++ .../settings/workspace-settings-modal.tsx | 210 ++++++++-- .../workspace-settings-modal.utils.ts | 53 +++ .../workspace/sidebar/dropdown-item.tsx | 17 +- .../workspace/sidebar/user-menu-root.tsx | 16 +- .../sidebar/workspace-menu-header.tsx | 11 +- 9 files changed, 663 insertions(+), 440 deletions(-) create mode 100644 plane-src/apps/web/core/components/workspace/settings/ai-voice-tasker-settings.tsx create mode 100644 plane-src/apps/web/core/components/workspace/settings/workspace-settings-modal.utils.ts diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/ai-voice-tasker/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/ai-voice-tasker/page.tsx index 467a4b9..183f86e 100644 --- a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/ai-voice-tasker/page.tsx +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/ai-voice-tasker/page.tsx @@ -4,390 +4,28 @@ * See the LICENSE file for details. */ -import { useEffect, useMemo, useState } from "react"; import { observer } from "mobx-react"; -import useSWR, { mutate } from "swr"; -import { BrainCircuit, KeyRound, Mic, ShieldCheck } from "lucide-react"; -// plane imports -import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; -import { Button } from "@plane/propel/button"; -import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import type { TWorkspaceAIAccessMode, TWorkspaceAISettings, TWorkspaceAISettingsPayload } from "@plane/types"; -import { Input, ToggleSwitch } from "@plane/ui"; -import { cn } from "@plane/utils"; // components -import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; import { PageHead } from "@/components/core/page-title"; import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; -import { SettingsHeading } from "@/components/settings/heading"; +import { AIVoiceTaskerSettingsContent } from "@/components/workspace/settings/ai-voice-tasker-settings"; // hooks -import { useProject } from "@/hooks/store/use-project"; import { useWorkspace } from "@/hooks/store/use-workspace"; -import { useUserPermissions } from "@/hooks/store/user"; -// services -import { WorkspaceAIService } from "@/services/workspace-ai.service"; // local imports import type { Route } from "./+types/page"; import { AIVoiceTaskerWorkspaceSettingsHeader } from "./header"; -const workspaceAIService = new WorkspaceAIService(); - -type TFormState = { - voice_tasker_enabled: boolean; - transcription_model: string; - structuring_model: string; - default_project_id: string; - access_mode: TWorkspaceAIAccessMode; - max_audio_duration_seconds: number; - per_user_hourly_limit: number; - workspace_hourly_limit: number; - openai_api_key: string; -}; - -const getInitialFormState = (settings?: TWorkspaceAISettings): TFormState => ({ - voice_tasker_enabled: settings?.voice_tasker_enabled ?? false, - transcription_model: settings?.transcription_model ?? "gpt-4o-mini-transcribe", - structuring_model: settings?.structuring_model ?? "gpt-4o-mini", - default_project_id: settings?.default_project_id ?? "", - access_mode: settings?.access_mode ?? "all_workspace_members", - max_audio_duration_seconds: settings?.max_audio_duration_seconds ?? 120, - per_user_hourly_limit: settings?.per_user_hourly_limit ?? 30, - workspace_hourly_limit: settings?.workspace_hourly_limit ?? 300, - openai_api_key: "", -}); - function AIVoiceTaskerSettingsPage({ params }: Route.ComponentProps) { const { workspaceSlug } = params; - const [formState, setFormState] = useState(getInitialFormState()); - const [isSaving, setIsSaving] = useState(false); - const [isTesting, setIsTesting] = useState(false); - // store hooks const { currentWorkspace } = useWorkspace(); - const { fetchProjects, projectMap } = useProject(); - const { workspaceUserInfo, allowPermissions } = useUserPermissions(); - // derived values - const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - AI / Voice Tasker` : undefined; - const { data: settings, isLoading } = useSWR( - canPerformWorkspaceAdminActions ? `WORKSPACE_AI_SETTINGS_${workspaceSlug}` : null, - canPerformWorkspaceAdminActions ? () => workspaceAIService.retrieveSettings(workspaceSlug) : null - ); - - useSWR( - canPerformWorkspaceAdminActions ? `WORKSPACE_AI_SETTINGS_PROJECTS_${workspaceSlug}` : null, - canPerformWorkspaceAdminActions ? () => fetchProjects(workspaceSlug) : null - ); - - const projects = useMemo( - () => - Object.values(projectMap) - .filter((project) => project.workspace === currentWorkspace?.id && !project.archived_at) - .sort((a, b) => a.name.localeCompare(b.name)), - [currentWorkspace?.id, projectMap] - ); - - useEffect(() => { - if (settings) setFormState(getInitialFormState(settings)); - }, [settings]); - - const updateFormValue = (key: T, value: TFormState[T]) => { - setFormState((prev) => ({ ...prev, [key]: value })); - }; - - const handleSave = async () => { - setIsSaving(true); - const payload: TWorkspaceAISettingsPayload = { - voice_tasker_enabled: formState.voice_tasker_enabled, - transcription_model: formState.transcription_model.trim(), - structuring_model: formState.structuring_model.trim(), - default_project_id: formState.default_project_id || null, - access_mode: formState.access_mode, - max_audio_duration_seconds: formState.max_audio_duration_seconds, - per_user_hourly_limit: formState.per_user_hourly_limit, - workspace_hourly_limit: formState.workspace_hourly_limit, - }; - - if (formState.openai_api_key.trim()) payload.openai_api_key = formState.openai_api_key.trim(); - - try { - const response = await workspaceAIService.updateSettings(workspaceSlug, payload); - await mutate(`WORKSPACE_AI_SETTINGS_${workspaceSlug}`, response, false); - setFormState(getInitialFormState(response)); - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Настройки Voice Tasker сохранены", - }); - } catch { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Не удалось сохранить настройки Voice Tasker", - }); - } finally { - setIsSaving(false); - } - }; - - const handleTestConnection = async () => { - setIsTesting(true); - try { - await workspaceAIService.testConnection(workspaceSlug); - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "OpenAI connection OK", - }); - } catch { - setToast({ - type: TOAST_TYPE.ERROR, - title: "OpenAI connection failed", - }); - } finally { - setIsTesting(false); - } - }; - - if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { - return ; - } - return ( }> -
- - - {isLoading || !settings ? ( -
- Загрузка настроек... -
- ) : ( - <> -
-
-
- -
-

Voice Tasker

-

- Глобальная voice-кнопка будет доступна только после включения функции и сохраненного OpenAI key. -

-
-
- updateFormValue("voice_tasker_enabled", !formState.voice_tasker_enabled)} - size="sm" - /> -
- -
- - - - - - - - - - - updateFormValue("max_audio_duration_seconds", value)} - /> - -
-
- -
- - } - /> -
- - updateFormValue("openai_api_key", event.target.value)} - placeholder={settings.credential.has_key ? "sk-... не изменять" : "sk-..."} - className="w-full" - /> - - -
-
- -
- -
- - updateFormValue("transcription_model", event.target.value)} - className="w-full" - /> - - - updateFormValue("structuring_model", event.target.value)} - className="w-full" - /> - - - updateFormValue("per_user_hourly_limit", value)} - /> - - - updateFormValue("workspace_hourly_limit", value)} - /> - -
-
- -
- -
- - )} -
+
); } -type TFieldProps = { - children: React.ReactNode; - label: string; -}; - -function Field({ children, label }: TFieldProps) { - return ( - - ); -} - -type TNumberInputProps = { - max: number; - min: number; - onChange: (value: number) => void; - suffix: string; - value: number; -}; - -function NumberInput({ max, min, onChange, suffix, value }: TNumberInputProps) { - return ( -
- onChange(Number(event.target.value))} - className="h-9 min-w-0 flex-1 rounded-md bg-transparent px-3 text-sm text-primary outline-none" - /> - {suffix} -
- ); -} - -type TSectionHeaderProps = { - description: string; - icon: React.ElementType; - right?: React.ReactNode; - title: string; -}; - -function SectionHeader({ description, icon: Icon, right, title }: TSectionHeaderProps) { - return ( -
-
- -
-

{title}

-

{description}

-
-
- {right} -
- ); -} - -type TCredentialStatusProps = { - hasKey: boolean; - keyLast4: string; -}; - -function CredentialStatus({ hasKey, keyLast4 }: TCredentialStatusProps) { - return ( -
- - {hasKey ? `sk-...${keyLast4}` : "No key"} -
- ); -} - export default observer(AIVoiceTaskerSettingsPage); diff --git a/plane-src/apps/web/core/components/navigation/app-rail-root.tsx b/plane-src/apps/web/core/components/navigation/app-rail-root.tsx index ae51746..9b8ee2c 100644 --- a/plane-src/apps/web/core/components/navigation/app-rail-root.tsx +++ b/plane-src/apps/web/core/components/navigation/app-rail-root.tsx @@ -5,6 +5,7 @@ */ "use client"; +import { useEffect, useState } from "react"; import { observer } from "mobx-react"; import { useParams, usePathname, useSearchParams } from "next/navigation"; import { SettingsIcon } from "lucide-react"; @@ -13,6 +14,10 @@ import { CheckIcon } from "@plane/propel/icons"; import { cn } from "@plane/utils"; // components import { AppSidebarItem } from "@/components/sidebar/sidebar-item"; +import { + openWorkspaceSettingsModal, + WORKSPACE_SETTINGS_MODAL_EVENT, +} from "@/components/workspace/settings/workspace-settings-modal.utils"; // hooks import { useAppRailPreferences } from "@/hooks/use-navigation-preferences"; import { useAppRailVisibility } from "@/lib/app-rail/context"; @@ -29,16 +34,28 @@ export const AppRailRoot = observer(() => { // preferences const { preferences, updateDisplayMode } = useAppRailPreferences(); const { isCollapsed, toggleAppRail } = useAppRailVisibility(); + const [isWorkspaceSettingsModalOpen, setIsWorkspaceSettingsModalOpen] = useState( + searchParams?.get("workspaceSettings") === "general" || searchParams?.get("workspaceSettings") === "ai-voice-tasker" + ); // derived values - const settingsModalSearchParams = new URLSearchParams(searchParams?.toString()); - settingsModalSearchParams.set("workspaceSettings", "general"); - const workspaceSettingsHref = `${pathname || `/${workspaceSlug}`}?${settingsModalSearchParams.toString()}`; const isWorkspaceSettingsPath = (pathname.includes(`/${workspaceSlug}/settings`) && !projectId) || - searchParams?.get("workspaceSettings") === "general"; + searchParams?.get("workspaceSettings") === "general" || + searchParams?.get("workspaceSettings") === "ai-voice-tasker" || + isWorkspaceSettingsModalOpen; const showLabel = preferences.displayMode === "icon_with_label"; const railWidth = showLabel ? "3.75rem" : "3rem"; + useEffect(() => { + const handleModalEvent = (event: Event) => { + setIsWorkspaceSettingsModalOpen(Boolean((event as CustomEvent<{ isOpen: boolean }>).detail?.isOpen)); + }; + + window.addEventListener(WORKSPACE_SETTINGS_MODAL_EVENT, handleModalEvent); + + return () => window.removeEventListener(WORKSPACE_SETTINGS_MODAL_EVENT, handleModalEvent); + }, []); + return (
{ item={{ label: "Settings", icon: , - href: workspaceSettingsHref, + onClick: () => openWorkspaceSettingsModal("general"), isActive: isWorkspaceSettingsPath, showLabel, }} + variant="button" />
diff --git a/plane-src/apps/web/core/components/settings/workspace/sidebar/header.tsx b/plane-src/apps/web/core/components/settings/workspace/sidebar/header.tsx index c838e97..888187f 100644 --- a/plane-src/apps/web/core/components/settings/workspace/sidebar/header.tsx +++ b/plane-src/apps/web/core/components/settings/workspace/sidebar/header.tsx @@ -19,7 +19,14 @@ import { useWorkspace } from "@/hooks/store/use-workspace"; // plane web imports import { SubscriptionPill } from "@/plane-web/components/common/subscription/subscription-pill"; -export const WorkspaceSettingsSidebarHeader = observer(function WorkspaceSettingsSidebarHeader() { +type TWorkspaceSettingsSidebarHeaderProps = { + onBack?: () => void; +}; + +export const WorkspaceSettingsSidebarHeader = observer(function WorkspaceSettingsSidebarHeader( + props: TWorkspaceSettingsSidebarHeaderProps +) { + const { onBack } = props; // router const router = useAppRouter(); // store hooks @@ -35,14 +42,14 @@ export const WorkspaceSettingsSidebarHeader = observer(function WorkspaceSetting if (!currentWorkspaceRole) return null; return ( -
+
router.push(`/${currentWorkspace?.slug}/`)} + onClick={() => (onBack ? onBack() : router.push(`/${currentWorkspace?.slug}/`))} />

{t("workspace_settings.label")}

diff --git a/plane-src/apps/web/core/components/workspace/settings/ai-voice-tasker-settings.tsx b/plane-src/apps/web/core/components/workspace/settings/ai-voice-tasker-settings.tsx new file mode 100644 index 0000000..67a35cc --- /dev/null +++ b/plane-src/apps/web/core/components/workspace/settings/ai-voice-tasker-settings.tsx @@ -0,0 +1,389 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useEffect, useMemo, useState } from "react"; +import type { ElementType, ReactNode } from "react"; +import { observer } from "mobx-react"; +import useSWR, { mutate } from "swr"; +import { BrainCircuit, KeyRound, Mic, ShieldCheck } from "lucide-react"; +// plane imports +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TWorkspaceAIAccessMode, TWorkspaceAISettings, TWorkspaceAISettingsPayload } from "@plane/types"; +import { Input, ToggleSwitch } from "@plane/ui"; +import { cn } from "@plane/utils"; +// components +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { SettingsHeading } from "@/components/settings/heading"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUserPermissions } from "@/hooks/store/user"; +// services +import { WorkspaceAIService } from "@/services/workspace-ai.service"; + +const workspaceAIService = new WorkspaceAIService(); + +type TFormState = { + voice_tasker_enabled: boolean; + transcription_model: string; + structuring_model: string; + default_project_id: string; + access_mode: TWorkspaceAIAccessMode; + max_audio_duration_seconds: number; + per_user_hourly_limit: number; + workspace_hourly_limit: number; + openai_api_key: string; +}; + +type TProps = { + showHeading?: boolean; + workspaceSlug: string; +}; + +const getInitialFormState = (settings?: TWorkspaceAISettings): TFormState => ({ + voice_tasker_enabled: settings?.voice_tasker_enabled ?? false, + transcription_model: settings?.transcription_model ?? "gpt-4o-mini-transcribe", + structuring_model: settings?.structuring_model ?? "gpt-4o-mini", + default_project_id: settings?.default_project_id ?? "", + access_mode: settings?.access_mode ?? "all_workspace_members", + max_audio_duration_seconds: settings?.max_audio_duration_seconds ?? 120, + per_user_hourly_limit: settings?.per_user_hourly_limit ?? 30, + workspace_hourly_limit: settings?.workspace_hourly_limit ?? 300, + openai_api_key: "", +}); + +export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSettingsContent(props: TProps) { + const { showHeading = true, workspaceSlug } = props; + const [formState, setFormState] = useState(getInitialFormState()); + const [isSaving, setIsSaving] = useState(false); + const [isTesting, setIsTesting] = useState(false); + // store hooks + const { currentWorkspace } = useWorkspace(); + const { fetchProjects, projectMap } = useProject(); + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + // derived values + const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + + const { data: settings, isLoading } = useSWR( + canPerformWorkspaceAdminActions ? `WORKSPACE_AI_SETTINGS_${workspaceSlug}` : null, + canPerformWorkspaceAdminActions ? () => workspaceAIService.retrieveSettings(workspaceSlug) : null + ); + + useSWR( + canPerformWorkspaceAdminActions ? `WORKSPACE_AI_SETTINGS_PROJECTS_${workspaceSlug}` : null, + canPerformWorkspaceAdminActions ? () => fetchProjects(workspaceSlug) : null + ); + + const projects = useMemo( + () => + Object.values(projectMap) + .filter((project) => project.workspace === currentWorkspace?.id && !project.archived_at) + .sort((a, b) => a.name.localeCompare(b.name)), + [currentWorkspace?.id, projectMap] + ); + + useEffect(() => { + if (settings) setFormState(getInitialFormState(settings)); + }, [settings]); + + const updateFormValue = (key: T, value: TFormState[T]) => { + setFormState((prev) => ({ ...prev, [key]: value })); + }; + + const handleSave = async () => { + setIsSaving(true); + const payload: TWorkspaceAISettingsPayload = { + voice_tasker_enabled: formState.voice_tasker_enabled, + transcription_model: formState.transcription_model.trim(), + structuring_model: formState.structuring_model.trim(), + default_project_id: formState.default_project_id || null, + access_mode: formState.access_mode, + max_audio_duration_seconds: formState.max_audio_duration_seconds, + per_user_hourly_limit: formState.per_user_hourly_limit, + workspace_hourly_limit: formState.workspace_hourly_limit, + }; + + if (formState.openai_api_key.trim()) payload.openai_api_key = formState.openai_api_key.trim(); + + try { + const response = await workspaceAIService.updateSettings(workspaceSlug, payload); + await mutate(`WORKSPACE_AI_SETTINGS_${workspaceSlug}`, response, false); + setFormState(getInitialFormState(response)); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Настройки Voice Tasker сохранены", + }); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Не удалось сохранить настройки Voice Tasker", + }); + } finally { + setIsSaving(false); + } + }; + + const handleTestConnection = async () => { + setIsTesting(true); + try { + await workspaceAIService.testConnection(workspaceSlug); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "OpenAI connection OK", + }); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "OpenAI connection failed", + }); + } finally { + setIsTesting(false); + } + }; + + if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { + return ; + } + + return ( +
+ {showHeading && ( + + )} + + {isLoading || !settings ? ( +
Загрузка настроек...
+ ) : ( + <> +
+ updateFormValue("voice_tasker_enabled", !formState.voice_tasker_enabled)} + size="sm" + /> + } + /> + +
+ + + + + + + + + + + updateFormValue("max_audio_duration_seconds", value)} + /> + +
+
+ +
+ } + /> +
+ + updateFormValue("openai_api_key", event.target.value)} + placeholder={settings.credential.has_key ? "sk-... не изменять" : "sk-..."} + className="nodedc-settings-input w-full" + /> + + +
+
+ +
+ +
+ + updateFormValue("transcription_model", event.target.value)} + className="nodedc-settings-input w-full" + /> + + + updateFormValue("structuring_model", event.target.value)} + className="nodedc-settings-input w-full" + /> + + + updateFormValue("per_user_hourly_limit", value)} + /> + + + updateFormValue("workspace_hourly_limit", value)} + /> + +
+
+ +
+ +
+ + )} +
+ ); +}); + +type TFieldProps = { + children: ReactNode; + label: string; +}; + +function Field({ children, label }: TFieldProps) { + return ( + + ); +} + +type TNumberInputProps = { + max: number; + min: number; + onChange: (value: number) => void; + suffix: string; + value: number; +}; + +function NumberInput({ max, min, onChange, suffix, value }: TNumberInputProps) { + return ( +
+ onChange(Number(event.target.value))} + className="min-h-12 min-w-0 flex-1 bg-transparent px-4 text-sm text-primary outline-none" + /> + {suffix} +
+ ); +} + +type TSectionHeaderProps = { + description: string; + icon: ElementType; + right?: ReactNode; + title: string; +}; + +function SectionHeader({ description, icon: Icon, right, title }: TSectionHeaderProps) { + return ( +
+
+ + + +
+

{title}

+

{description}

+
+
+ {right} +
+ ); +} + +type TCredentialStatusProps = { + hasKey: boolean; + keyLast4: string; +}; + +function CredentialStatus({ hasKey, keyLast4 }: TCredentialStatusProps) { + return ( +
+ + {hasKey ? `sk-...${keyLast4}` : "No key"} +
+ ); +} diff --git a/plane-src/apps/web/core/components/workspace/settings/workspace-settings-modal.tsx b/plane-src/apps/web/core/components/workspace/settings/workspace-settings-modal.tsx index 58c9531..df0746d 100644 --- a/plane-src/apps/web/core/components/workspace/settings/workspace-settings-modal.tsx +++ b/plane-src/apps/web/core/components/workspace/settings/workspace-settings-modal.tsx @@ -4,38 +4,106 @@ * See the LICENSE file for details. */ +import { useEffect, useState } from "react"; import { observer } from "mobx-react"; -import { useLocation, useNavigate } from "react-router"; import { X } from "lucide-react"; // plane imports +import { + EUserPermissionsLevel, + GROUPED_WORKSPACE_SETTINGS, + WORKSPACE_SETTINGS_CATEGORIES, +} from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { ScrollArea } from "@plane/propel/scrollarea"; +import type { TWorkspaceSettingsTabs } from "@plane/types"; import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; +import { joinUrlPath } from "@plane/utils"; // components +import { SettingsSidebarItem } from "@/components/settings/sidebar/item"; +import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon"; +import { WorkspaceSettingsSidebarHeader } from "@/components/settings/workspace/sidebar/header"; +import { AIVoiceTaskerSettingsContent } from "@/components/workspace/settings/ai-voice-tasker-settings"; import { WorkspaceDetails } from "@/components/workspace/settings/workspace-details"; // hooks +import { useUserPermissions } from "@/hooks/store/user"; import { useWorkspace } from "@/hooks/store/use-workspace"; +// local imports +import { + closeWorkspaceSettingsModal, + getWorkspaceSettingsModalTabFromSearch, + openWorkspaceSettingsModal, + WORKSPACE_SETTINGS_MODAL_EVENT, + type TWorkspaceSettingsModalTab, +} from "./workspace-settings-modal.utils"; + +const HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set(["billing-and-plans"]); +const MODAL_TABS = new Set(["general", "ai-voice-tasker"]); + +const getInitialTab = (): TWorkspaceSettingsModalTab => { + if (typeof window === "undefined") return "general"; + + return getWorkspaceSettingsModalTabFromSearch(window.location.search) ?? "general"; +}; + +const getInitialOpenState = () => { + if (typeof window === "undefined") return false; + + return Boolean(getWorkspaceSettingsModalTabFromSearch(window.location.search)); +}; export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal() { - const location = useLocation(); - const navigate = useNavigate(); + const [activeTab, setActiveTab] = useState(getInitialTab); + const [isOpen, setIsOpen] = useState(getInitialOpenState); + // store hooks const { currentWorkspace } = useWorkspace(); + const { allowPermissions } = useUserPermissions(); - const searchParams = new URLSearchParams(location.search); - const activeModal = searchParams.get("workspaceSettings"); - const isOpen = activeModal === "general"; + useEffect(() => { + const syncFromLocation = () => { + const tab = getWorkspaceSettingsModalTabFromSearch(window.location.search); + setIsOpen(Boolean(tab)); + if (tab) setActiveTab(tab); + }; + + const handleModalEvent = (event: Event) => { + const detail = (event as CustomEvent<{ isOpen: boolean; tab?: TWorkspaceSettingsModalTab }>).detail; + + setIsOpen(detail.isOpen); + if (detail.tab) setActiveTab(detail.tab); + }; + + window.addEventListener(WORKSPACE_SETTINGS_MODAL_EVENT, handleModalEvent); + window.addEventListener("popstate", syncFromLocation); + + syncFromLocation(); + + return () => { + window.removeEventListener(WORKSPACE_SETTINGS_MODAL_EVENT, handleModalEvent); + window.removeEventListener("popstate", syncFromLocation); + }; + }, []); const handleClose = () => { - const nextSearchParams = new URLSearchParams(location.search); - nextSearchParams.delete("workspaceSettings"); + closeWorkspaceSettingsModal(); + }; - navigate( - { - pathname: location.pathname, - search: nextSearchParams.toString() ? `?${nextSearchParams.toString()}` : "", - hash: location.hash, - }, - { replace: true } - ); + const handleSelectItem = (itemKey: TWorkspaceSettingsTabs, itemHref: string) => { + if (MODAL_TABS.has(itemKey)) { + openWorkspaceSettingsModal(itemKey as TWorkspaceSettingsModalTab, true); + return; + } + + if (!currentWorkspace?.slug) return; + + window.location.assign(joinUrlPath(currentWorkspace.slug, itemHref)); + }; + + const renderContent = () => { + if (activeTab === "ai-voice-tasker" && currentWorkspace?.slug) { + return ; + } + + return ; }; return ( @@ -44,32 +112,100 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal() handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.VIIXL} - className="h-[88vh] max-h-[920px] overflow-hidden border border-white/8 bg-[rgba(12,12,16,0.94)]" + className="h-[88vh] max-h-[920px] overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] shadow-[0_28px_80px_rgba(0,0,0,0.42)]" > -
-
-
-
Настройки workspace
-
- {currentWorkspace?.name ?? "Workspace"} / основные параметры -
-
- +
+
+ +
- -
- +
+
+
+
Настройки workspace
+
+ {currentWorkspace?.name ?? "Workspace"} / {activeTab === "ai-voice-tasker" ? "AI / Voice Tasker" : "основные параметры"} +
+
+
- + + +
{renderContent()}
+
+
); }); + +type TWorkspaceModalSidebarProps = { + activeTab: TWorkspaceSettingsModalTab; + allowPermissions: ReturnType["allowPermissions"]; + onSelectItem: (itemKey: TWorkspaceSettingsTabs, itemHref: string) => void; + workspaceSlug?: string; +}; + +function WorkspaceModalSidebar({ activeTab, allowPermissions, onSelectItem, workspaceSlug }: TWorkspaceModalSidebarProps) { + const { t } = useTranslation(); + + return ( + +
+ {WORKSPACE_SETTINGS_CATEGORIES.map((category) => { + const accessibleItems = GROUPED_WORKSPACE_SETTINGS[category].filter( + (item) => + !HIDDEN_WORKSPACE_SETTINGS_KEYS.has(item.key) && + allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug) + ); + + if (accessibleItems.length === 0) return null; + + return ( +
+
+ {t(category)} +
+
+ {accessibleItems.map((item) => { + const Icon = WORKSPACE_SETTINGS_ICONS[item.key]; + const isActive = item.key === activeTab; + const isModalTab = MODAL_TABS.has(item.key); + + return ( + onSelectItem(item.key, item.href)} + isActive={isActive} + icon={Icon} + label={t(item.i18n_label)} + /> + ); + })} +
+
+ ); + })} +
+
+ ); +} diff --git a/plane-src/apps/web/core/components/workspace/settings/workspace-settings-modal.utils.ts b/plane-src/apps/web/core/components/workspace/settings/workspace-settings-modal.utils.ts new file mode 100644 index 0000000..73475e5 --- /dev/null +++ b/plane-src/apps/web/core/components/workspace/settings/workspace-settings-modal.utils.ts @@ -0,0 +1,53 @@ +export const WORKSPACE_SETTINGS_MODAL_QUERY_KEY = "workspaceSettings"; +export const WORKSPACE_SETTINGS_MODAL_EVENT = "nodedc:workspace-settings-modal"; + +export type TWorkspaceSettingsModalTab = "general" | "ai-voice-tasker"; + +type TWorkspaceSettingsModalEventDetail = { + isOpen: boolean; + tab?: TWorkspaceSettingsModalTab; +}; + +const dispatchWorkspaceSettingsModalEvent = (detail: TWorkspaceSettingsModalEventDetail) => { + window.dispatchEvent(new CustomEvent(WORKSPACE_SETTINGS_MODAL_EVENT, { detail })); +}; + +export const getWorkspaceSettingsModalTabFromSearch = (search: string): TWorkspaceSettingsModalTab | undefined => { + const value = new URLSearchParams(search).get(WORKSPACE_SETTINGS_MODAL_QUERY_KEY); + + if (value === "general" || value === "ai-voice-tasker") return value; + + return undefined; +}; + +export const setWorkspaceSettingsModalSearch = (tab: TWorkspaceSettingsModalTab, replace = false) => { + if (typeof window === "undefined") return; + + const url = new URL(window.location.href); + url.searchParams.set(WORKSPACE_SETTINGS_MODAL_QUERY_KEY, tab); + + window.history[replace ? "replaceState" : "pushState"](window.history.state, "", url); +}; + +export const clearWorkspaceSettingsModalSearch = () => { + if (typeof window === "undefined") return; + + const url = new URL(window.location.href); + url.searchParams.delete(WORKSPACE_SETTINGS_MODAL_QUERY_KEY); + + window.history.replaceState(window.history.state, "", url); +}; + +export const openWorkspaceSettingsModal = (tab: TWorkspaceSettingsModalTab = "general", replace = false) => { + if (typeof window === "undefined") return; + + setWorkspaceSettingsModalSearch(tab, replace); + dispatchWorkspaceSettingsModalEvent({ isOpen: true, tab }); +}; + +export const closeWorkspaceSettingsModal = () => { + if (typeof window === "undefined") return; + + clearWorkspaceSettingsModalSearch(); + dispatchWorkspaceSettingsModalEvent({ isOpen: false }); +}; diff --git a/plane-src/apps/web/core/components/workspace/sidebar/dropdown-item.tsx b/plane-src/apps/web/core/components/workspace/sidebar/dropdown-item.tsx index 1c6b117..894eb56 100644 --- a/plane-src/apps/web/core/components/workspace/sidebar/dropdown-item.tsx +++ b/plane-src/apps/web/core/components/workspace/sidebar/dropdown-item.tsx @@ -6,7 +6,7 @@ import { observer } from "mobx-react"; import Link from "next/link"; -import { useParams, usePathname, useSearchParams } from "next/navigation"; +import { useParams } from "next/navigation"; import { Settings, UserPlus } from "lucide-react"; import { Menu } from "@headlessui/react"; // plane imports @@ -15,6 +15,8 @@ import { useTranslation } from "@plane/i18n"; import { CheckIcon } from "@plane/propel/icons"; import type { IWorkspace } from "@plane/types"; import { cn, getFileURL, getUserRole } from "@plane/utils"; +// components +import { openWorkspaceSettingsModal } from "@/components/workspace/settings/workspace-settings-modal.utils"; // plane web imports import { SubscriptionPill } from "@/plane-web/components/common/subscription/subscription-pill"; @@ -29,13 +31,8 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps) const { workspace, activeWorkspace, handleItemClick, handleWorkspaceNavigation, handleClose } = props; // router const { workspaceSlug } = useParams(); - const pathname = usePathname(); - const searchParams = useSearchParams(); // hooks const { t } = useTranslation(); - const settingsModalSearchParams = new URLSearchParams(searchParams?.toString()); - settingsModalSearchParams.set("workspaceSettings", "general"); - const workspaceSettingsHref = `${pathname || `/${workspace.slug}`}?${settingsModalSearchParams.toString()}`; return (
{[EUserPermissions.ADMIN, EUserPermissions.MEMBER].includes(workspace?.role) && ( - { + e.preventDefault(); e.stopPropagation(); + openWorkspaceSettingsModal("general"); handleClose(); }} className="flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[1.25rem] border-0 bg-white/[0.05] px-5 py-2.5 text-secondary shadow-none outline-none transition-colors hover:bg-white/[0.09] hover:text-primary" > {t("settings")} - + )} {[EUserPermissions.ADMIN].includes(workspace?.role) && ( {t("settings")} -