ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: администрирование воркспейсов и feature-gates в God Mode
This commit is contained in:
@@ -17,6 +17,7 @@ import { Loader, ToggleSwitch } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { PageWrapper } from "@/components/common/page-wrapper";
|
||||
import { WorkspaceFeaturesModal, WorkspaceMembersModal } from "@/components/workspace/admin-modals";
|
||||
import { WorkspaceListItem } from "@/components/workspace/list-item";
|
||||
// hooks
|
||||
import { useInstance, useWorkspace } from "@/hooks/store";
|
||||
@@ -26,6 +27,8 @@ import type { Route } from "./+types/page";
|
||||
const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props: Route.ComponentProps) {
|
||||
// states
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const [membersWorkspaceId, setMembersWorkspaceId] = useState<string | null>(null);
|
||||
const [featuresWorkspaceId, setFeaturesWorkspaceId] = useState<string | null>(null);
|
||||
// store
|
||||
const { formattedConfig, fetchInstanceConfigurations, updateInstanceConfigurations } = useInstance();
|
||||
const {
|
||||
@@ -53,14 +56,14 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
|
||||
const updateConfigPromise = updateInstanceConfigurations(payload);
|
||||
|
||||
setPromiseToast(updateConfigPromise, {
|
||||
loading: "Saving configuration",
|
||||
loading: "Сохранение конфигурации",
|
||||
success: {
|
||||
title: "Success",
|
||||
message: () => "Configuration saved successfully",
|
||||
title: "Сохранено",
|
||||
message: () => "Конфигурация обновлена",
|
||||
},
|
||||
error: {
|
||||
title: "Error",
|
||||
message: () => "Failed to save configuration",
|
||||
title: "Ошибка",
|
||||
message: () => "Не удалось сохранить конфигурацию",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -77,8 +80,8 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
|
||||
return (
|
||||
<PageWrapper
|
||||
header={{
|
||||
title: "Workspaces on this instance",
|
||||
description: "See all workspaces and control who can create them.",
|
||||
title: "Воркспейсы инстанса",
|
||||
description: "Просматривайте все рабочие пространства и управляйте правом создания новых.",
|
||||
}}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
@@ -86,9 +89,9 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
|
||||
<div className={cn("flex w-full items-center gap-14 rounded-sm")}>
|
||||
<div className="flex grow items-center gap-4">
|
||||
<div className="grow">
|
||||
<div className="pb-1 text-16 font-medium">Prevent anyone else from creating a workspace.</div>
|
||||
<div className="pb-1 text-16 font-medium">Запретить пользователям создавать воркспейсы</div>
|
||||
<div className={cn("text-11 leading-5 font-regular text-tertiary")}>
|
||||
Toggling this on will let only you create workspaces. You will have to invite users to new workspaces.
|
||||
Если включить, создавать рабочие пространства сможет только администратор инстанса.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,25 +122,30 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
|
||||
<div className="flex items-center justify-between gap-2 pt-6">
|
||||
<div className="flex flex-col items-start gap-x-2">
|
||||
<div className="flex items-center gap-2 text-16 font-medium">
|
||||
All workspaces on this instance <span className="text-tertiary">• {workspaceIds.length}</span>
|
||||
Все воркспейсы инстанса <span className="text-tertiary">• {workspaceIds.length}</span>
|
||||
{workspaceLoader && ["mutation", "pagination"].includes(workspaceLoader) && (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("text-11 leading-5 font-regular text-tertiary")}>
|
||||
You can't yet delete workspaces and you can only go to the workspace if you are an Admin or a
|
||||
Member.
|
||||
Удаление пока недоступно. Открыть воркспейс можно только при наличии роли администратора или
|
||||
участника.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/workspace/create" className={getButtonStyling("primary", "base")}>
|
||||
Create workspace
|
||||
Создать воркспейс
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
{workspaceIds.map((workspaceId) => (
|
||||
<WorkspaceListItem key={workspaceId} workspaceId={workspaceId} />
|
||||
<WorkspaceListItem
|
||||
key={workspaceId}
|
||||
workspaceId={workspaceId}
|
||||
onMembersClick={setMembersWorkspaceId}
|
||||
onFeaturesClick={setFeaturesWorkspaceId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasNextPage && (
|
||||
@@ -148,7 +156,7 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
|
||||
onClick={() => fetchNextWorkspaces()}
|
||||
disabled={workspaceLoader === "pagination"}
|
||||
>
|
||||
Load more
|
||||
Загрузить еще
|
||||
{workspaceLoader === "pagination" && <LoaderIcon className="h-3 w-3 animate-spin" />}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -162,11 +170,21 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
|
||||
<Loader.Item height="92px" width="100%" />
|
||||
</Loader>
|
||||
)}
|
||||
<WorkspaceMembersModal
|
||||
isOpen={!!membersWorkspaceId}
|
||||
workspaceId={membersWorkspaceId}
|
||||
onClose={() => setMembersWorkspaceId(null)}
|
||||
/>
|
||||
<WorkspaceFeaturesModal
|
||||
isOpen={!!featuresWorkspaceId}
|
||||
workspaceId={featuresWorkspaceId}
|
||||
onClose={() => setFeaturesWorkspaceId(null)}
|
||||
/>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
export const meta: Route.MetaFunction = () => [{ title: "Workspace Management - God Mode" }];
|
||||
export const meta: Route.MetaFunction = () => [{ title: "Воркспейсы - NODE.DC" }];
|
||||
|
||||
export default WorkspaceManagementPage;
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Fragment, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import useSWR from "swr";
|
||||
import { Loader, ShieldCheck, Trash2, UsersRound, X } from "lucide-react";
|
||||
// plane imports
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
|
||||
import { InstanceWorkspaceService } from "@plane/services";
|
||||
import type { TInstanceWorkspaceFeature, TInstanceWorkspaceMember } from "@plane/types";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
|
||||
type TWorkspaceAdminModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
workspaceId: string | null;
|
||||
};
|
||||
|
||||
const instanceWorkspaceService = new InstanceWorkspaceService();
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ label: "Гость", value: 5 },
|
||||
{ label: "Участник", value: 15 },
|
||||
{ label: "Администратор", value: 20 },
|
||||
];
|
||||
|
||||
const ROLE_LABELS: Record<number, string> = {
|
||||
5: "Гость",
|
||||
15: "Участник",
|
||||
20: "Администратор",
|
||||
};
|
||||
|
||||
const ACCESS_MODE_LABELS: Record<TInstanceWorkspaceFeature["access_mode"], string> = {
|
||||
all_workspace_members: "Весь воркспейс",
|
||||
admins_only: "Только админы",
|
||||
selected_projects: "По контурам",
|
||||
selected_members: "По людям",
|
||||
};
|
||||
|
||||
function getMemberName(member: TInstanceWorkspaceMember) {
|
||||
return member.member.display_name || member.member.email || "Пользователь";
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
if (error && typeof error === "object" && "error" in error && typeof error.error === "string") return error.error;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function WorkspaceModalShell(props: TWorkspaceAdminModalProps & { children: ReactNode; title: string; icon: ReactNode }) {
|
||||
const { children, icon, isOpen, onClose, title, workspaceId } = props;
|
||||
const { getWorkspaceById } = useWorkspace();
|
||||
const workspace = workspaceId ? getWorkspaceById(workspaceId) : undefined;
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-40" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-150"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="translate-y-2 opacity-0 scale-95"
|
||||
enterTo="translate-y-0 opacity-100 scale-100"
|
||||
leave="ease-in duration-150"
|
||||
leaveFrom="translate-y-0 opacity-100 scale-100"
|
||||
leaveTo="translate-y-2 opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="nodedc-glass-modal relative w-full max-w-5xl overflow-hidden rounded-[1.75rem] bg-surface-1 text-left shadow-raised-200 transition-all">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-subtle p-5">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-layer-2 text-accent-primary">
|
||||
{icon}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<Dialog.Title as="h3" className="truncate text-18 font-semibold text-primary">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
<div className="mt-1 truncate text-13 text-secondary">
|
||||
{workspace ? `${workspace.name} / [${workspace.slug}]` : "Воркспейс"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="nodedc-settings-secondary-button flex h-10 min-h-10 w-10 shrink-0 items-center justify-center rounded-full p-0 !px-0"
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-[72vh] overflow-y-auto p-5">{children}</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceMembersModal(props: TWorkspaceAdminModalProps) {
|
||||
const { isOpen, onClose, workspaceId } = props;
|
||||
const [mutatingMemberId, setMutatingMemberId] = useState<string | null>(null);
|
||||
const { data, isLoading, mutate } = useSWR(
|
||||
isOpen && workspaceId ? ["INSTANCE_WORKSPACE_MEMBERS", workspaceId] : null,
|
||||
() => instanceWorkspaceService.listMembers(workspaceId as string)
|
||||
);
|
||||
|
||||
const handleRoleChange = async (workspaceMemberId: string, role: number) => {
|
||||
if (!workspaceId) return;
|
||||
setMutatingMemberId(workspaceMemberId);
|
||||
try {
|
||||
await instanceWorkspaceService.updateMemberRole(workspaceId, workspaceMemberId, role);
|
||||
await mutate();
|
||||
setToast({ type: TOAST_TYPE.SUCCESS, title: "Роль участника обновлена" });
|
||||
} catch (error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Не удалось обновить роль",
|
||||
message: getErrorMessage(error, "Проверьте ограничения по администраторам воркспейса и проектов."),
|
||||
});
|
||||
} finally {
|
||||
setMutatingMemberId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (workspaceMember: TInstanceWorkspaceMember) => {
|
||||
if (!workspaceId) return;
|
||||
const confirmed = window.confirm(`Отключить ${getMemberName(workspaceMember)} от этого воркспейса и его проектов?`);
|
||||
if (!confirmed) return;
|
||||
|
||||
setMutatingMemberId(workspaceMember.id);
|
||||
try {
|
||||
await instanceWorkspaceService.removeMember(workspaceId, workspaceMember.id);
|
||||
await mutate();
|
||||
setToast({ type: TOAST_TYPE.SUCCESS, title: "Доступ к воркспейсу отключен" });
|
||||
} catch (error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Не удалось отключить участника",
|
||||
message: getErrorMessage(error, "Пользователь может быть единственным администратором."),
|
||||
});
|
||||
} finally {
|
||||
setMutatingMemberId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkspaceModalShell {...props} title="Участники воркспейса" icon={<UsersRound className="h-5 w-5" />}>
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-40 items-center justify-center text-secondary">
|
||||
<Loader className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-[1.35rem] bg-layer-1">
|
||||
<div className="grid grid-cols-[minmax(14rem,1.7fr)_11rem_8rem_9rem_7rem] gap-3 border-b border-subtle px-4 py-3 text-11 font-semibold uppercase text-tertiary">
|
||||
<div>Пользователь</div>
|
||||
<div>Роль</div>
|
||||
<div>Проекты</div>
|
||||
<div>Админ проектов</div>
|
||||
<div className="text-right">Доступ</div>
|
||||
</div>
|
||||
<div className="divide-y divide-subtle">
|
||||
{(data ?? []).map((workspaceMember) => (
|
||||
<div
|
||||
key={workspaceMember.id}
|
||||
className="grid grid-cols-[minmax(14rem,1.7fr)_11rem_8rem_9rem_7rem] items-center gap-3 px-4 py-3 text-13"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-primary">{getMemberName(workspaceMember)}</div>
|
||||
<div className="truncate text-12 text-tertiary">{workspaceMember.member.email}</div>
|
||||
</div>
|
||||
<select
|
||||
className="nodedc-settings-select h-9 min-h-9 w-full px-3 text-12"
|
||||
value={workspaceMember.role}
|
||||
disabled={mutatingMemberId === workspaceMember.id}
|
||||
onChange={(event) => handleRoleChange(workspaceMember.id, Number(event.target.value))}
|
||||
aria-label={`Роль: ${getMemberName(workspaceMember)}`}
|
||||
>
|
||||
{ROLE_OPTIONS.map((role) => (
|
||||
<option key={role.value} value={role.value}>
|
||||
{role.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="text-secondary">{workspaceMember.active_project_count}</div>
|
||||
<div className="text-secondary">{workspaceMember.admin_project_count}</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(workspaceMember)}
|
||||
disabled={mutatingMemberId === workspaceMember.id}
|
||||
className="nodedc-settings-secondary-button flex h-9 min-h-9 items-center gap-2 px-3 text-12"
|
||||
aria-label={`Отключить доступ: ${getMemberName(workspaceMember)}`}
|
||||
>
|
||||
{mutatingMemberId === workspaceMember.id ? (
|
||||
<Loader className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Отключить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{(data ?? []).length === 0 && <div className="px-4 py-8 text-center text-13 text-tertiary">Активных участников нет</div>}
|
||||
</div>
|
||||
)}
|
||||
</WorkspaceModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceFeaturesModal(props: TWorkspaceAdminModalProps) {
|
||||
const { isOpen, workspaceId } = props;
|
||||
const [mutatingFeatureKey, setMutatingFeatureKey] = useState<string | null>(null);
|
||||
const { data, isLoading, mutate } = useSWR(
|
||||
isOpen && workspaceId ? ["INSTANCE_WORKSPACE_FEATURES", workspaceId] : null,
|
||||
() => instanceWorkspaceService.retrieveFeatures(workspaceId as string)
|
||||
);
|
||||
|
||||
const handleToggle = async (feature: TInstanceWorkspaceFeature) => {
|
||||
if (!workspaceId) return;
|
||||
setMutatingFeatureKey(feature.key);
|
||||
try {
|
||||
await instanceWorkspaceService.updateFeature(workspaceId, feature.key, !feature.is_enabled);
|
||||
await mutate();
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: feature.is_enabled ? "Фича отключена для воркспейса" : "Фича выдана воркспейсу",
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Не удалось обновить доступ",
|
||||
message: getErrorMessage(error, "Попробуйте еще раз."),
|
||||
});
|
||||
} finally {
|
||||
setMutatingFeatureKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkspaceModalShell {...props} title="Функции воркспейса" icon={<ShieldCheck className="h-5 w-5" />}>
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-40 items-center justify-center text-secondary">
|
||||
<Loader className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{(data?.features ?? []).map((feature) => (
|
||||
<div key={feature.key} className="nodedc-settings-card flex items-center justify-between gap-5 p-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-15 font-semibold text-primary">{feature.title}</div>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-11 font-medium",
|
||||
feature.is_enabled ? "bg-accent-primary text-on-color" : "bg-layer-2 text-tertiary"
|
||||
)}
|
||||
>
|
||||
{feature.is_enabled ? "Доступ выдан" : "Не выдано"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-13 text-secondary">{feature.description}</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-11 text-tertiary">
|
||||
<span className="rounded-full bg-layer-2 px-2 py-1">
|
||||
Workspace: {feature.workspace_setting_enabled ? "включено" : "выключено"}
|
||||
</span>
|
||||
<span className="rounded-full bg-layer-2 px-2 py-1">Доступ: {ACCESS_MODE_LABELS[feature.access_mode]}</span>
|
||||
<span className="rounded-full bg-layer-2 px-2 py-1">
|
||||
OpenAI key: {feature.has_workspace_key ? "есть" : "нет"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<ToggleSwitch
|
||||
value={feature.is_enabled}
|
||||
onChange={() => handleToggle(feature)}
|
||||
size="sm"
|
||||
disabled={mutatingFeatureKey === feature.key}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(data?.features ?? []).length === 0 && <div className="px-4 py-8 text-center text-13 text-tertiary">Функции не найдены</div>}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-5 flex justify-end">
|
||||
<Button variant="secondary" size="lg" onClick={props.onClose} className="nodedc-settings-secondary-button">
|
||||
Закрыть
|
||||
</Button>
|
||||
</div>
|
||||
</WorkspaceModalShell>
|
||||
);
|
||||
}
|
||||
@@ -5,20 +5,26 @@
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { ExternalLink, Sparkles, UsersRound } from "lucide-react";
|
||||
|
||||
// plane internal packages
|
||||
import { WEB_BASE_URL } from "@plane/constants";
|
||||
import { NewTabIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { getFileURL } from "@plane/utils";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
|
||||
type TWorkspaceListItemProps = {
|
||||
onFeaturesClick: (workspaceId: string) => void;
|
||||
onMembersClick: (workspaceId: string) => void;
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
export const WorkspaceListItem = observer(function WorkspaceListItem({ workspaceId }: TWorkspaceListItemProps) {
|
||||
export const WorkspaceListItem = observer(function WorkspaceListItem({
|
||||
onFeaturesClick,
|
||||
onMembersClick,
|
||||
workspaceId,
|
||||
}: TWorkspaceListItemProps) {
|
||||
// store hooks
|
||||
const { getWorkspaceById } = useWorkspace();
|
||||
// derived values
|
||||
@@ -26,14 +32,11 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
|
||||
|
||||
if (!workspace) return null;
|
||||
return (
|
||||
<a
|
||||
<div
|
||||
key={workspaceId}
|
||||
href={`${WEB_BASE_URL}/${encodeURIComponent(workspace.slug)}`}
|
||||
target="_blank"
|
||||
className="group flex items-center justify-between gap-2.5 truncate rounded-lg border border-subtle bg-layer-1 p-3 hover:border-subtle-1 hover:bg-layer-1-hover hover:shadow-raised-100"
|
||||
rel="noreferrer"
|
||||
className="nodedc-settings-card group flex items-center justify-between gap-3 rounded-lg border border-subtle bg-layer-1 p-3 hover:border-subtle-1 hover:bg-layer-1-hover hover:shadow-raised-100"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<span
|
||||
className={`relative mt-1 flex h-8 w-8 flex-shrink-0 items-center justify-center p-2 text-11 uppercase ${
|
||||
!workspace?.logo_url && "rounded-lg bg-accent-primary text-on-color"
|
||||
@@ -43,29 +46,29 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
|
||||
<img
|
||||
src={getFileURL(workspace.logo_url)}
|
||||
className="absolute top-0 left-0 h-full w-full rounded-sm object-cover"
|
||||
alt="Workspace Logo"
|
||||
alt="Логотип воркспейса"
|
||||
/>
|
||||
) : (
|
||||
(workspace?.name?.[0] ?? "...")
|
||||
)}
|
||||
</span>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<div className="flex min-w-0 flex-col items-start gap-1">
|
||||
<div className="flex w-full flex-wrap items-center gap-2.5">
|
||||
<h3 className={`text-14 font-medium capitalize`}>{workspace.name}</h3>/
|
||||
<Tooltip tooltipContent="The unique URL of your workspace">
|
||||
<h3 className={`truncate text-14 font-medium capitalize`}>{workspace.name}</h3>/
|
||||
<Tooltip tooltipContent="Уникальный URL воркспейса">
|
||||
<h4 className="text-13 text-tertiary">[{workspace.slug}]</h4>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{workspace.owner.email && (
|
||||
<div className="flex items-center gap-1 text-11">
|
||||
<h3 className="font-medium text-secondary">Owned by:</h3>
|
||||
<h3 className="font-medium text-secondary">Владелец:</h3>
|
||||
<h4 className="text-tertiary">{workspace.owner.email}</h4>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2.5 text-11">
|
||||
{workspace.total_projects !== null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<h3 className="font-medium text-secondary">Total projects:</h3>
|
||||
<h3 className="font-medium text-secondary">Проектов:</h3>
|
||||
<h4 className="text-tertiary">{workspace.total_projects}</h4>
|
||||
</span>
|
||||
)}
|
||||
@@ -73,7 +76,7 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
|
||||
<>
|
||||
•
|
||||
<span className="flex items-center gap-1">
|
||||
<h3 className="font-medium text-secondary">Total members:</h3>
|
||||
<h3 className="font-medium text-secondary">Участников:</h3>
|
||||
<h4 className="text-tertiary">{workspace.total_members}</h4>
|
||||
</span>
|
||||
</>
|
||||
@@ -81,9 +84,39 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<NewTabIcon width={14} height={16} className="text-placeholder group-hover:text-secondary" />
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Tooltip tooltipContent="Доступный функционал">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFeaturesClick(workspaceId)}
|
||||
className="flex size-9 items-center justify-center rounded-xl border border-white/8 bg-white/[0.035] p-0 text-tertiary transition hover:border-white/14 hover:bg-white/8 hover:text-primary"
|
||||
aria-label="Доступный функционал"
|
||||
>
|
||||
<Sparkles className="size-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip tooltipContent="Участники воркспейса">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMembersClick(workspaceId)}
|
||||
className="flex size-9 items-center justify-center rounded-xl border border-white/8 bg-white/[0.035] p-0 text-tertiary transition hover:border-white/14 hover:bg-white/8 hover:text-primary"
|
||||
aria-label="Участники воркспейса"
|
||||
>
|
||||
<UsersRound className="size-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip tooltipContent="Открыть воркспейс">
|
||||
<a
|
||||
href={`${WEB_BASE_URL}/${encodeURIComponent(workspace.slug)}`}
|
||||
target="_blank"
|
||||
className="flex size-9 items-center justify-center rounded-xl border border-white/8 bg-white/[0.035] p-0 text-tertiary transition hover:border-white/14 hover:bg-white/8 hover:text-primary"
|
||||
rel="noreferrer"
|
||||
aria-label="Открыть воркспейс"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user