UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: модальная навигация workspace settings и Voice Tasker
This commit is contained in:
parent
f060d4dedd
commit
7d520c7aaf
|
|
@ -4,390 +4,28 @@
|
||||||
* See the LICENSE file for details.
|
* See the LICENSE file for details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { observer } from "mobx-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
|
// components
|
||||||
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
|
|
||||||
import { PageHead } from "@/components/core/page-title";
|
import { PageHead } from "@/components/core/page-title";
|
||||||
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||||
import { SettingsHeading } from "@/components/settings/heading";
|
import { AIVoiceTaskerSettingsContent } from "@/components/workspace/settings/ai-voice-tasker-settings";
|
||||||
// hooks
|
// hooks
|
||||||
import { useProject } from "@/hooks/store/use-project";
|
|
||||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||||
import { useUserPermissions } from "@/hooks/store/user";
|
|
||||||
// services
|
|
||||||
import { WorkspaceAIService } from "@/services/workspace-ai.service";
|
|
||||||
// local imports
|
// local imports
|
||||||
import type { Route } from "./+types/page";
|
import type { Route } from "./+types/page";
|
||||||
import { AIVoiceTaskerWorkspaceSettingsHeader } from "./header";
|
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) {
|
function AIVoiceTaskerSettingsPage({ params }: Route.ComponentProps) {
|
||||||
const { workspaceSlug } = params;
|
const { workspaceSlug } = params;
|
||||||
const [formState, setFormState] = useState<TFormState>(getInitialFormState());
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [isTesting, setIsTesting] = useState(false);
|
|
||||||
// store hooks
|
|
||||||
const { currentWorkspace } = useWorkspace();
|
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 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 = <T extends keyof TFormState>(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 <NotAuthorizedView section="settings" className="h-auto" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsContentWrapper header={<AIVoiceTaskerWorkspaceSettingsHeader />}>
|
<SettingsContentWrapper header={<AIVoiceTaskerWorkspaceSettingsHeader />}>
|
||||||
<PageHead title={pageTitle} />
|
<PageHead title={pageTitle} />
|
||||||
<div className="flex w-full flex-col gap-7">
|
<AIVoiceTaskerSettingsContent workspaceSlug={workspaceSlug} />
|
||||||
<SettingsHeading
|
|
||||||
title="AI / Voice Tasker"
|
|
||||||
description="Workspace-level настройки голосовой постановки задач. OpenAI key хранится только на backend и не отдается пользователям."
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isLoading || !settings ? (
|
|
||||||
<div className="rounded-md border-[0.5px] border-subtle bg-layer-1 p-5 text-sm text-secondary">
|
|
||||||
Загрузка настроек...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<section className="rounded-md border-[0.5px] border-subtle bg-layer-1">
|
|
||||||
<div className="flex items-start justify-between gap-4 border-b-[0.5px] border-subtle px-5 py-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Mic className="mt-0.5 size-4 text-tertiary" />
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-primary">Voice Tasker</h3>
|
|
||||||
<p className="mt-1 max-w-2xl text-xs text-tertiary">
|
|
||||||
Глобальная voice-кнопка будет доступна только после включения функции и сохраненного OpenAI key.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ToggleSwitch
|
|
||||||
value={formState.voice_tasker_enabled}
|
|
||||||
onChange={() => updateFormValue("voice_tasker_enabled", !formState.voice_tasker_enabled)}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-5 px-5 py-5 md:grid-cols-2">
|
|
||||||
<Field label="Provider">
|
|
||||||
<Input value="OpenAI" disabled className="w-full" />
|
|
||||||
</Field>
|
|
||||||
<Field label="Access mode">
|
|
||||||
<select
|
|
||||||
value={formState.access_mode}
|
|
||||||
onChange={(event) => updateFormValue("access_mode", event.target.value as TWorkspaceAIAccessMode)}
|
|
||||||
className="h-9 w-full rounded-md border-[0.5px] border-subtle bg-layer-2 px-3 text-sm text-primary outline-none"
|
|
||||||
>
|
|
||||||
<option value="all_workspace_members">All workspace members</option>
|
|
||||||
<option value="admins_only">Admins only</option>
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
<Field label="Default project fallback">
|
|
||||||
<select
|
|
||||||
value={formState.default_project_id}
|
|
||||||
onChange={(event) => updateFormValue("default_project_id", event.target.value)}
|
|
||||||
className="h-9 w-full rounded-md border-[0.5px] border-subtle bg-layer-2 px-3 text-sm text-primary outline-none"
|
|
||||||
>
|
|
||||||
<option value="">None</option>
|
|
||||||
{projects.map((project) => (
|
|
||||||
<option key={project.id} value={project.id}>
|
|
||||||
{project.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
<Field label="Max audio duration">
|
|
||||||
<NumberInput
|
|
||||||
value={formState.max_audio_duration_seconds}
|
|
||||||
min={10}
|
|
||||||
max={600}
|
|
||||||
suffix="seconds"
|
|
||||||
onChange={(value) => updateFormValue("max_audio_duration_seconds", value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-md border-[0.5px] border-subtle bg-layer-1">
|
|
||||||
<SectionHeader
|
|
||||||
icon={KeyRound}
|
|
||||||
title="OpenAI credential"
|
|
||||||
description="Key заменяется только если ввести новый. В API response возвращается только last4."
|
|
||||||
right={
|
|
||||||
<CredentialStatus
|
|
||||||
hasKey={settings.credential.has_key}
|
|
||||||
keyLast4={settings.credential.key_last4}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className="grid gap-5 px-5 py-5 md:grid-cols-[1fr_auto] md:items-end">
|
|
||||||
<Field label="OpenAI API Key">
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
value={formState.openai_api_key}
|
|
||||||
onChange={(event) => updateFormValue("openai_api_key", event.target.value)}
|
|
||||||
placeholder={settings.credential.has_key ? "sk-... не изменять" : "sk-..."}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="lg"
|
|
||||||
loading={isTesting}
|
|
||||||
disabled={!settings.credential.has_key || isSaving}
|
|
||||||
onClick={handleTestConnection}
|
|
||||||
>
|
|
||||||
Test connection
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-md border-[0.5px] border-subtle bg-layer-1">
|
|
||||||
<SectionHeader
|
|
||||||
icon={BrainCircuit}
|
|
||||||
title="Models and limits"
|
|
||||||
description="MVP использует один workspace key для транскрибации и структурирования."
|
|
||||||
/>
|
|
||||||
<div className="grid gap-5 px-5 py-5 md:grid-cols-2">
|
|
||||||
<Field label="Transcription model">
|
|
||||||
<Input
|
|
||||||
value={formState.transcription_model}
|
|
||||||
onChange={(event) => updateFormValue("transcription_model", event.target.value)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Structuring model">
|
|
||||||
<Input
|
|
||||||
value={formState.structuring_model}
|
|
||||||
onChange={(event) => updateFormValue("structuring_model", event.target.value)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Per-user limit">
|
|
||||||
<NumberInput
|
|
||||||
value={formState.per_user_hourly_limit}
|
|
||||||
min={1}
|
|
||||||
max={1000}
|
|
||||||
suffix="tasks/hour"
|
|
||||||
onChange={(value) => updateFormValue("per_user_hourly_limit", value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Workspace limit">
|
|
||||||
<NumberInput
|
|
||||||
value={formState.workspace_hourly_limit}
|
|
||||||
min={1}
|
|
||||||
max={10000}
|
|
||||||
suffix="tasks/hour"
|
|
||||||
onChange={(value) => updateFormValue("workspace_hourly_limit", value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3">
|
|
||||||
<Button variant="primary" size="lg" loading={isSaving} disabled={isTesting} onClick={handleSave}>
|
|
||||||
Сохранить настройки
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SettingsContentWrapper>
|
</SettingsContentWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type TFieldProps = {
|
|
||||||
children: React.ReactNode;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function Field({ children, label }: TFieldProps) {
|
|
||||||
return (
|
|
||||||
<label className="flex flex-col gap-1.5">
|
|
||||||
<span className="text-xs font-medium text-secondary">{label}</span>
|
|
||||||
{children}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type TNumberInputProps = {
|
|
||||||
max: number;
|
|
||||||
min: number;
|
|
||||||
onChange: (value: number) => void;
|
|
||||||
suffix: string;
|
|
||||||
value: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function NumberInput({ max, min, onChange, suffix, value }: TNumberInputProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center rounded-md border-[0.5px] border-subtle bg-layer-2">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
value={value}
|
|
||||||
onChange={(event) => 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"
|
|
||||||
/>
|
|
||||||
<span className="shrink-0 border-l-[0.5px] border-subtle px-3 text-xs text-tertiary">{suffix}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type TSectionHeaderProps = {
|
|
||||||
description: string;
|
|
||||||
icon: React.ElementType;
|
|
||||||
right?: React.ReactNode;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function SectionHeader({ description, icon: Icon, right, title }: TSectionHeaderProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-start justify-between gap-4 border-b-[0.5px] border-subtle px-5 py-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Icon className="mt-0.5 size-4 text-tertiary" />
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-primary">{title}</h3>
|
|
||||||
<p className="mt-1 max-w-2xl text-xs text-tertiary">{description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{right}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type TCredentialStatusProps = {
|
|
||||||
hasKey: boolean;
|
|
||||||
keyLast4: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function CredentialStatus({ hasKey, keyLast4 }: TCredentialStatusProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex shrink-0 items-center gap-1.5 rounded-md border-[0.5px] px-2.5 py-1 text-xs",
|
|
||||||
hasKey ? "border-green-500/30 bg-green-500/10 text-green-600" : "border-subtle bg-layer-2 text-tertiary"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ShieldCheck className="size-3.5" />
|
|
||||||
{hasKey ? `sk-...${keyLast4}` : "No key"}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default observer(AIVoiceTaskerSettingsPage);
|
export default observer(AIVoiceTaskerSettingsPage);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||||
import { SettingsIcon } from "lucide-react";
|
import { SettingsIcon } from "lucide-react";
|
||||||
|
|
@ -13,6 +14,10 @@ import { CheckIcon } from "@plane/propel/icons";
|
||||||
import { cn } from "@plane/utils";
|
import { cn } from "@plane/utils";
|
||||||
// components
|
// components
|
||||||
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
|
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
|
||||||
|
import {
|
||||||
|
openWorkspaceSettingsModal,
|
||||||
|
WORKSPACE_SETTINGS_MODAL_EVENT,
|
||||||
|
} from "@/components/workspace/settings/workspace-settings-modal.utils";
|
||||||
// hooks
|
// hooks
|
||||||
import { useAppRailPreferences } from "@/hooks/use-navigation-preferences";
|
import { useAppRailPreferences } from "@/hooks/use-navigation-preferences";
|
||||||
import { useAppRailVisibility } from "@/lib/app-rail/context";
|
import { useAppRailVisibility } from "@/lib/app-rail/context";
|
||||||
|
|
@ -29,16 +34,28 @@ export const AppRailRoot = observer(() => {
|
||||||
// preferences
|
// preferences
|
||||||
const { preferences, updateDisplayMode } = useAppRailPreferences();
|
const { preferences, updateDisplayMode } = useAppRailPreferences();
|
||||||
const { isCollapsed, toggleAppRail } = useAppRailVisibility();
|
const { isCollapsed, toggleAppRail } = useAppRailVisibility();
|
||||||
|
const [isWorkspaceSettingsModalOpen, setIsWorkspaceSettingsModalOpen] = useState(
|
||||||
|
searchParams?.get("workspaceSettings") === "general" || searchParams?.get("workspaceSettings") === "ai-voice-tasker"
|
||||||
|
);
|
||||||
// derived values
|
// derived values
|
||||||
const settingsModalSearchParams = new URLSearchParams(searchParams?.toString());
|
|
||||||
settingsModalSearchParams.set("workspaceSettings", "general");
|
|
||||||
const workspaceSettingsHref = `${pathname || `/${workspaceSlug}`}?${settingsModalSearchParams.toString()}`;
|
|
||||||
const isWorkspaceSettingsPath =
|
const isWorkspaceSettingsPath =
|
||||||
(pathname.includes(`/${workspaceSlug}/settings`) && !projectId) ||
|
(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 showLabel = preferences.displayMode === "icon_with_label";
|
||||||
const railWidth = showLabel ? "3.75rem" : "3rem";
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="z-[26] h-full flex-shrink-0 bg-canvas transition-all duration-300 ease-in-out"
|
className="z-[26] h-full flex-shrink-0 bg-canvas transition-all duration-300 ease-in-out"
|
||||||
|
|
@ -63,10 +80,11 @@ export const AppRailRoot = observer(() => {
|
||||||
item={{
|
item={{
|
||||||
label: "Settings",
|
label: "Settings",
|
||||||
icon: <SettingsIcon className="size-5" />,
|
icon: <SettingsIcon className="size-5" />,
|
||||||
href: workspaceSettingsHref,
|
onClick: () => openWorkspaceSettingsModal("general"),
|
||||||
isActive: isWorkspaceSettingsPath,
|
isActive: isWorkspaceSettingsPath,
|
||||||
showLabel,
|
showLabel,
|
||||||
}}
|
}}
|
||||||
|
variant="button"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,14 @@ import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||||
// plane web imports
|
// plane web imports
|
||||||
import { SubscriptionPill } from "@/plane-web/components/common/subscription/subscription-pill";
|
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
|
// router
|
||||||
const router = useAppRouter();
|
const router = useAppRouter();
|
||||||
// store hooks
|
// store hooks
|
||||||
|
|
@ -35,14 +42,14 @@ export const WorkspaceSettingsSidebarHeader = observer(function WorkspaceSetting
|
||||||
if (!currentWorkspaceRole) return null;
|
if (!currentWorkspaceRole) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<div className="flex items-center gap-2 px-3 pb-3 text-body-md-medium">
|
<div className="flex items-center gap-2 px-3 pb-3 text-body-md-medium">
|
||||||
<IconButton
|
<IconButton
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="base"
|
size="base"
|
||||||
icon={ArrowLeft}
|
icon={ArrowLeft}
|
||||||
className="nodedc-toolbar-icon-button"
|
className="nodedc-toolbar-icon-button"
|
||||||
onClick={() => router.push(`/${currentWorkspace?.slug}/`)}
|
onClick={() => (onBack ? onBack() : router.push(`/${currentWorkspace?.slug}/`))}
|
||||||
/>
|
/>
|
||||||
<p>{t("workspace_settings.label")}</p>
|
<p>{t("workspace_settings.label")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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<TFormState>(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 = <T extends keyof TFormState>(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 <NotAuthorizedView section="settings" className="h-auto" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col gap-7">
|
||||||
|
{showHeading && (
|
||||||
|
<SettingsHeading
|
||||||
|
title="AI / Voice Tasker"
|
||||||
|
description="Workspace-level настройки голосовой постановки задач. OpenAI key хранится только на backend и не отдается пользователям."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading || !settings ? (
|
||||||
|
<div className="nodedc-settings-card px-5 py-5 text-sm text-secondary">Загрузка настроек...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<section className="nodedc-settings-card overflow-hidden">
|
||||||
|
<SectionHeader
|
||||||
|
icon={Mic}
|
||||||
|
title="Voice Tasker"
|
||||||
|
description="Глобальная voice-кнопка будет доступна только после включения функции и сохраненного OpenAI key."
|
||||||
|
right={
|
||||||
|
<ToggleSwitch
|
||||||
|
value={formState.voice_tasker_enabled}
|
||||||
|
onChange={() => updateFormValue("voice_tasker_enabled", !formState.voice_tasker_enabled)}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-5 px-5 py-5 md:grid-cols-2">
|
||||||
|
<Field label="Provider">
|
||||||
|
<Input value="OpenAI" disabled className="nodedc-settings-input w-full cursor-not-allowed !bg-white/4" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Access mode">
|
||||||
|
<select
|
||||||
|
value={formState.access_mode}
|
||||||
|
onChange={(event) => updateFormValue("access_mode", event.target.value as TWorkspaceAIAccessMode)}
|
||||||
|
className="nodedc-settings-select w-full px-4 text-sm"
|
||||||
|
>
|
||||||
|
<option value="all_workspace_members">All workspace members</option>
|
||||||
|
<option value="admins_only">Admins only</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Default project fallback">
|
||||||
|
<select
|
||||||
|
value={formState.default_project_id}
|
||||||
|
onChange={(event) => updateFormValue("default_project_id", event.target.value)}
|
||||||
|
className="nodedc-settings-select w-full px-4 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<option key={project.id} value={project.id}>
|
||||||
|
{project.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Max audio duration">
|
||||||
|
<NumberInput
|
||||||
|
value={formState.max_audio_duration_seconds}
|
||||||
|
min={10}
|
||||||
|
max={600}
|
||||||
|
suffix="seconds"
|
||||||
|
onChange={(value) => updateFormValue("max_audio_duration_seconds", value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="nodedc-settings-card overflow-hidden">
|
||||||
|
<SectionHeader
|
||||||
|
icon={KeyRound}
|
||||||
|
title="OpenAI credential"
|
||||||
|
description="Key заменяется только если ввести новый. В API response возвращается только last4."
|
||||||
|
right={<CredentialStatus hasKey={settings.credential.has_key} keyLast4={settings.credential.key_last4} />}
|
||||||
|
/>
|
||||||
|
<div className="grid gap-5 px-5 py-5 md:grid-cols-[1fr_auto] md:items-end">
|
||||||
|
<Field label="OpenAI API Key">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={formState.openai_api_key}
|
||||||
|
onChange={(event) => updateFormValue("openai_api_key", event.target.value)}
|
||||||
|
placeholder={settings.credential.has_key ? "sk-... не изменять" : "sk-..."}
|
||||||
|
className="nodedc-settings-input w-full"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
className="nodedc-settings-chip min-w-[10rem]"
|
||||||
|
loading={isTesting}
|
||||||
|
disabled={!settings.credential.has_key || isSaving}
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
>
|
||||||
|
Test connection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="nodedc-settings-card overflow-hidden">
|
||||||
|
<SectionHeader
|
||||||
|
icon={BrainCircuit}
|
||||||
|
title="Models and limits"
|
||||||
|
description="MVP использует один workspace key для транскрибации и структурирования."
|
||||||
|
/>
|
||||||
|
<div className="grid gap-5 px-5 py-5 md:grid-cols-2">
|
||||||
|
<Field label="Transcription model">
|
||||||
|
<Input
|
||||||
|
value={formState.transcription_model}
|
||||||
|
onChange={(event) => updateFormValue("transcription_model", event.target.value)}
|
||||||
|
className="nodedc-settings-input w-full"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Structuring model">
|
||||||
|
<Input
|
||||||
|
value={formState.structuring_model}
|
||||||
|
onChange={(event) => updateFormValue("structuring_model", event.target.value)}
|
||||||
|
className="nodedc-settings-input w-full"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Per-user limit">
|
||||||
|
<NumberInput
|
||||||
|
value={formState.per_user_hourly_limit}
|
||||||
|
min={1}
|
||||||
|
max={1000}
|
||||||
|
suffix="tasks/hour"
|
||||||
|
onChange={(value) => updateFormValue("per_user_hourly_limit", value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Workspace limit">
|
||||||
|
<NumberInput
|
||||||
|
value={formState.workspace_hourly_limit}
|
||||||
|
min={1}
|
||||||
|
max={10000}
|
||||||
|
suffix="tasks/hour"
|
||||||
|
onChange={(value) => updateFormValue("workspace_hourly_limit", value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="nodedc-settings-save-button min-w-[13rem]"
|
||||||
|
loading={isSaving}
|
||||||
|
disabled={isTesting}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
Сохранить настройки
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
type TFieldProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Field({ children, label }: TFieldProps) {
|
||||||
|
return (
|
||||||
|
<label className="flex flex-col gap-2.5">
|
||||||
|
<span className="text-body-sm-medium text-tertiary">{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type TNumberInputProps = {
|
||||||
|
max: number;
|
||||||
|
min: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
suffix: string;
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function NumberInput({ max, min, onChange, suffix, value }: TNumberInputProps) {
|
||||||
|
return (
|
||||||
|
<div className="nodedc-settings-input flex min-h-12 items-center overflow-hidden">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(Number(event.target.value))}
|
||||||
|
className="min-h-12 min-w-0 flex-1 bg-transparent px-4 text-sm text-primary outline-none"
|
||||||
|
/>
|
||||||
|
<span className="shrink-0 px-4 text-xs text-tertiary">{suffix}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type TSectionHeaderProps = {
|
||||||
|
description: string;
|
||||||
|
icon: ElementType;
|
||||||
|
right?: ReactNode;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function SectionHeader({ description, icon: Icon, right, title }: TSectionHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-4 px-5 py-5">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="grid size-10 shrink-0 place-items-center rounded-full bg-white/5 text-tertiary">
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-15 font-semibold text-primary">{title}</h3>
|
||||||
|
<p className="mt-1 max-w-2xl text-12 leading-5 text-tertiary">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{right}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type TCredentialStatusProps = {
|
||||||
|
hasKey: boolean;
|
||||||
|
keyLast4: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function CredentialStatus({ hasKey, keyLast4 }: TCredentialStatusProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"nodedc-settings-chip flex shrink-0 items-center gap-2 px-3.5 py-1.5 text-12 font-medium",
|
||||||
|
hasKey ? "text-primary" : "text-tertiary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ShieldCheck className="size-3.5" />
|
||||||
|
{hasKey ? `sk-...${keyLast4}` : "No key"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,38 +4,106 @@
|
||||||
* See the LICENSE file for details.
|
* See the LICENSE file for details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { useLocation, useNavigate } from "react-router";
|
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
// plane imports
|
// 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 { ScrollArea } from "@plane/propel/scrollarea";
|
||||||
|
import type { TWorkspaceSettingsTabs } from "@plane/types";
|
||||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||||
|
import { joinUrlPath } from "@plane/utils";
|
||||||
// components
|
// 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";
|
import { WorkspaceDetails } from "@/components/workspace/settings/workspace-details";
|
||||||
// hooks
|
// hooks
|
||||||
|
import { useUserPermissions } from "@/hooks/store/user";
|
||||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
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<TWorkspaceSettingsTabs>(["billing-and-plans"]);
|
||||||
|
const MODAL_TABS = new Set<TWorkspaceSettingsTabs>(["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() {
|
export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal() {
|
||||||
const location = useLocation();
|
const [activeTab, setActiveTab] = useState<TWorkspaceSettingsModalTab>(getInitialTab);
|
||||||
const navigate = useNavigate();
|
const [isOpen, setIsOpen] = useState(getInitialOpenState);
|
||||||
|
// store hooks
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
const { allowPermissions } = useUserPermissions();
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(location.search);
|
useEffect(() => {
|
||||||
const activeModal = searchParams.get("workspaceSettings");
|
const syncFromLocation = () => {
|
||||||
const isOpen = activeModal === "general";
|
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 handleClose = () => {
|
||||||
const nextSearchParams = new URLSearchParams(location.search);
|
closeWorkspaceSettingsModal();
|
||||||
nextSearchParams.delete("workspaceSettings");
|
};
|
||||||
|
|
||||||
navigate(
|
const handleSelectItem = (itemKey: TWorkspaceSettingsTabs, itemHref: string) => {
|
||||||
{
|
if (MODAL_TABS.has(itemKey)) {
|
||||||
pathname: location.pathname,
|
openWorkspaceSettingsModal(itemKey as TWorkspaceSettingsModalTab, true);
|
||||||
search: nextSearchParams.toString() ? `?${nextSearchParams.toString()}` : "",
|
return;
|
||||||
hash: location.hash,
|
}
|
||||||
},
|
|
||||||
{ replace: true }
|
if (!currentWorkspace?.slug) return;
|
||||||
);
|
|
||||||
|
window.location.assign(joinUrlPath(currentWorkspace.slug, itemHref));
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (activeTab === "ai-voice-tasker" && currentWorkspace?.slug) {
|
||||||
|
return <AIVoiceTaskerSettingsContent workspaceSlug={currentWorkspace.slug} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <WorkspaceDetails />;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -44,32 +112,100 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
|
||||||
handleClose={handleClose}
|
handleClose={handleClose}
|
||||||
position={EModalPosition.CENTER}
|
position={EModalPosition.CENTER}
|
||||||
width={EModalWidth.VIIXL}
|
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)]"
|
||||||
>
|
>
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
<div className="flex h-full min-h-0">
|
||||||
<div className="flex shrink-0 items-center justify-between gap-4 border-b border-white/6 px-6 py-5">
|
<div className="hidden h-full w-[296px] shrink-0 md:block">
|
||||||
<div className="min-w-0">
|
<WorkspaceSettingsSidebarHeader onBack={handleClose} />
|
||||||
<div className="text-18 font-semibold text-primary">Настройки workspace</div>
|
<WorkspaceModalSidebar
|
||||||
<div className="mt-1 truncate text-12 text-tertiary">
|
activeTab={activeTab}
|
||||||
{currentWorkspace?.name ?? "Workspace"} / основные параметры
|
onSelectItem={handleSelectItem}
|
||||||
</div>
|
allowPermissions={allowPermissions}
|
||||||
</div>
|
workspaceSlug={currentWorkspace?.slug}
|
||||||
<button
|
/>
|
||||||
type="button"
|
|
||||||
onClick={handleClose}
|
|
||||||
className="grid size-10 flex-shrink-0 place-items-center rounded-full bg-white/6 text-secondary transition hover:bg-white/10 hover:text-primary"
|
|
||||||
aria-label="Закрыть настройки workspace"
|
|
||||||
>
|
|
||||||
<X className="size-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea scrollType="hover" orientation="vertical" size="sm" className="min-h-0 flex-1 overflow-y-auto">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<div className="mx-auto w-full max-w-[74rem] px-5 py-6 lg:px-8">
|
<div className="flex shrink-0 items-center justify-between gap-4 px-6 py-5">
|
||||||
<WorkspaceDetails />
|
<div className="min-w-0">
|
||||||
|
<div className="text-18 font-semibold text-primary">Настройки workspace</div>
|
||||||
|
<div className="mt-1 truncate text-12 text-tertiary">
|
||||||
|
{currentWorkspace?.name ?? "Workspace"} / {activeTab === "ai-voice-tasker" ? "AI / Voice Tasker" : "основные параметры"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="grid size-10 flex-shrink-0 place-items-center rounded-full bg-white/6 text-secondary transition hover:bg-white/10 hover:text-primary"
|
||||||
|
aria-label="Закрыть настройки workspace"
|
||||||
|
>
|
||||||
|
<X className="size-5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
|
||||||
|
<ScrollArea scrollType="hover" orientation="vertical" size="sm" className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<div className="mx-auto w-full max-w-[74rem] px-5 pb-7 lg:px-8">{renderContent()}</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalCore>
|
</ModalCore>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type TWorkspaceModalSidebarProps = {
|
||||||
|
activeTab: TWorkspaceSettingsModalTab;
|
||||||
|
allowPermissions: ReturnType<typeof useUserPermissions>["allowPermissions"];
|
||||||
|
onSelectItem: (itemKey: TWorkspaceSettingsTabs, itemHref: string) => void;
|
||||||
|
workspaceSlug?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function WorkspaceModalSidebar({ activeTab, allowPermissions, onSelectItem, workspaceSlug }: TWorkspaceModalSidebarProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea
|
||||||
|
scrollType="hover"
|
||||||
|
orientation="vertical"
|
||||||
|
size="sm"
|
||||||
|
rootClassName="nodedc-settings-sidebar-shell h-[calc(100%-6.75rem)] w-full overflow-y-scroll px-3 py-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col divide-y divide-white/6">
|
||||||
|
{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 (
|
||||||
|
<div key={category} className="shrink-0 py-3.5 first:pt-0 last:pb-0">
|
||||||
|
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-tertiary">
|
||||||
|
{t(category)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{accessibleItems.map((item) => {
|
||||||
|
const Icon = WORKSPACE_SETTINGS_ICONS[item.key];
|
||||||
|
const isActive = item.key === activeTab;
|
||||||
|
const isModalTab = MODAL_TABS.has(item.key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSidebarItem
|
||||||
|
key={item.key}
|
||||||
|
as="button"
|
||||||
|
onClick={() => onSelectItem(item.key, item.href)}
|
||||||
|
isActive={isActive}
|
||||||
|
icon={Icon}
|
||||||
|
label={t(item.i18n_label)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<TWorkspaceSettingsModalEventDetail>(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 });
|
||||||
|
};
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { Settings, UserPlus } from "lucide-react";
|
import { Settings, UserPlus } from "lucide-react";
|
||||||
import { Menu } from "@headlessui/react";
|
import { Menu } from "@headlessui/react";
|
||||||
// plane imports
|
// plane imports
|
||||||
|
|
@ -15,6 +15,8 @@ import { useTranslation } from "@plane/i18n";
|
||||||
import { CheckIcon } from "@plane/propel/icons";
|
import { CheckIcon } from "@plane/propel/icons";
|
||||||
import type { IWorkspace } from "@plane/types";
|
import type { IWorkspace } from "@plane/types";
|
||||||
import { cn, getFileURL, getUserRole } from "@plane/utils";
|
import { cn, getFileURL, getUserRole } from "@plane/utils";
|
||||||
|
// components
|
||||||
|
import { openWorkspaceSettingsModal } from "@/components/workspace/settings/workspace-settings-modal.utils";
|
||||||
// plane web imports
|
// plane web imports
|
||||||
import { SubscriptionPill } from "@/plane-web/components/common/subscription/subscription-pill";
|
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;
|
const { workspace, activeWorkspace, handleItemClick, handleWorkspaceNavigation, handleClose } = props;
|
||||||
// router
|
// router
|
||||||
const { workspaceSlug } = useParams();
|
const { workspaceSlug } = useParams();
|
||||||
const pathname = usePathname();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
// hooks
|
// hooks
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const settingsModalSearchParams = new URLSearchParams(searchParams?.toString());
|
|
||||||
settingsModalSearchParams.set("workspaceSettings", "general");
|
|
||||||
const workspaceSettingsHref = `${pathname || `/${workspace.slug}`}?${settingsModalSearchParams.toString()}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -97,17 +94,19 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
|
||||||
<>
|
<>
|
||||||
<div className="mt-2 mb-1 grid grid-cols-2 gap-3">
|
<div className="mt-2 mb-1 grid grid-cols-2 gap-3">
|
||||||
{[EUserPermissions.ADMIN, EUserPermissions.MEMBER].includes(workspace?.role) && (
|
{[EUserPermissions.ADMIN, EUserPermissions.MEMBER].includes(workspace?.role) && (
|
||||||
<Link
|
<button
|
||||||
href={workspaceSettingsHref}
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
openWorkspaceSettingsModal("general");
|
||||||
handleClose();
|
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"
|
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"
|
||||||
>
|
>
|
||||||
<Settings className="my-auto h-4 w-4 flex-shrink-0" />
|
<Settings className="my-auto h-4 w-4 flex-shrink-0" />
|
||||||
<span className="my-auto text-13 font-medium whitespace-nowrap">{t("settings")}</span>
|
<span className="my-auto text-13 font-medium whitespace-nowrap">{t("settings")}</span>
|
||||||
</Link>
|
</button>
|
||||||
)}
|
)}
|
||||||
{[EUserPermissions.ADMIN].includes(workspace?.role) && (
|
{[EUserPermissions.ADMIN].includes(workspace?.role) && (
|
||||||
<Link
|
<Link
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { LogOut, Settings, Settings2 } from "lucide-react";
|
import { LogOut, Settings } from "lucide-react";
|
||||||
// plane imports
|
// plane imports
|
||||||
import { GOD_MODE_URL } from "@plane/constants";
|
import { GOD_MODE_URL } from "@plane/constants";
|
||||||
import { useTranslation } from "@plane/i18n";
|
import { useTranslation } from "@plane/i18n";
|
||||||
|
|
@ -105,20 +105,6 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
|
||||||
<Settings className="size-3.5 shrink-0" />
|
<Settings className="size-3.5 shrink-0" />
|
||||||
{t("settings")}
|
{t("settings")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
toggleProfileSettingsModal({
|
|
||||||
activeTab: "preferences",
|
|
||||||
isOpen: true,
|
|
||||||
});
|
|
||||||
closeDropdown();
|
|
||||||
}}
|
|
||||||
className="flex w-full items-center gap-2 rounded-[0.9rem] px-2 py-2 text-left text-secondary transition-colors hover:bg-white/6"
|
|
||||||
>
|
|
||||||
<Settings2 className="size-3.5 shrink-0" />
|
|
||||||
{t("preferences")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { MoreHorizontal, ArchiveIcon, Settings } from "lucide-react";
|
import { MoreHorizontal, ArchiveIcon, Settings } from "lucide-react";
|
||||||
import { Disclosure } from "@headlessui/react";
|
import { Disclosure } from "@headlessui/react";
|
||||||
// plane imports
|
// plane imports
|
||||||
|
|
@ -16,6 +16,8 @@ import { ChevronRightIcon } from "@plane/propel/icons";
|
||||||
import { EUserWorkspaceRoles } from "@plane/types";
|
import { EUserWorkspaceRoles } from "@plane/types";
|
||||||
import { ActionDropdown } from "@plane/ui";
|
import { ActionDropdown } from "@plane/ui";
|
||||||
import { cn } from "@plane/utils";
|
import { cn } from "@plane/utils";
|
||||||
|
// components
|
||||||
|
import { openWorkspaceSettingsModal } from "@/components/workspace/settings/workspace-settings-modal.utils";
|
||||||
// store hooks
|
// store hooks
|
||||||
import { useUserPermissions } from "@/hooks/store/user";
|
import { useUserPermissions } from "@/hooks/store/user";
|
||||||
|
|
||||||
|
|
@ -32,17 +34,12 @@ export const SidebarWorkspaceMenuHeader = observer(function SidebarWorkspaceMenu
|
||||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||||
// hooks
|
// hooks
|
||||||
const { workspaceSlug } = useParams();
|
const { workspaceSlug } = useParams();
|
||||||
const pathname = usePathname();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const { allowPermissions } = useUserPermissions();
|
const { allowPermissions } = useUserPermissions();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// TODO: fix types
|
// TODO: fix types
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const isAdmin = allowPermissions([EUserWorkspaceRoles.ADMIN] as any, EUserPermissionsLevel.WORKSPACE);
|
const isAdmin = allowPermissions([EUserWorkspaceRoles.ADMIN] as any, EUserPermissionsLevel.WORKSPACE);
|
||||||
const settingsModalSearchParams = new URLSearchParams(searchParams?.toString());
|
|
||||||
settingsModalSearchParams.set("workspaceSettings", "general");
|
|
||||||
const workspaceSettingsHref = `${pathname || `/${workspaceSlug}`}?${settingsModalSearchParams.toString()}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group/workspace-button mt-2.5 flex rounded-sm bg-surface-1 px-2 hover:bg-surface-2">
|
<div className="group/workspace-button mt-2.5 flex rounded-sm bg-surface-1 px-2 hover:bg-surface-2">
|
||||||
|
|
@ -79,7 +76,7 @@ export const SidebarWorkspaceMenuHeader = observer(function SidebarWorkspaceMenu
|
||||||
key: "settings",
|
key: "settings",
|
||||||
title: t("settings"),
|
title: t("settings"),
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
action: () => router.push(workspaceSettingsHref),
|
action: () => openWorkspaceSettingsModal("general"),
|
||||||
shouldRender: isAdmin,
|
shouldRender: isAdmin,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue