feat: add create workspace and switch workspace
This commit is contained in:
parent
ebd1e5eb66
commit
fac0838d8c
@ -12,6 +12,11 @@ const useSocketStore = create<{
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
export function createSocketIOClient(workspaceId: string) {
|
export function createSocketIOClient(workspaceId: string) {
|
||||||
|
const prev = useSocketStore.getState().socket;
|
||||||
|
if (prev) {
|
||||||
|
prev.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
const socket = io(`/${workspaceId}`, {
|
const socket = io(`/${workspaceId}`, {
|
||||||
transports: ['websocket'],
|
transports: ['websocket'],
|
||||||
reconnectionDelayMax: 10000,
|
reconnectionDelayMax: 10000,
|
||||||
|
11
src/client/components/DevContainer.tsx
Normal file
11
src/client/components/DevContainer.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { isDev } from '@/utils/env';
|
||||||
|
import React, { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
export const DevContainer: React.FC<PropsWithChildren> = React.memo((props) => {
|
||||||
|
if (isDev) {
|
||||||
|
return <>{props.children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
DevContainer.displayName = 'DevContainer';
|
@ -1,14 +1,35 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { cn } from '@/utils/style';
|
import { cn } from '@/utils/style';
|
||||||
import { useUserInfo } from '@/store/user';
|
import { setUserInfo, useUserInfo } from '@/store/user';
|
||||||
import { RiRocket2Fill } from 'react-icons/ri';
|
import { LuPlusCircle } from 'react-icons/lu';
|
||||||
|
import { useTranslation } from '@i18next-toolkit/react';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
|
} from './ui/command';
|
||||||
|
import { CaretSortIcon, CheckIcon } from '@radix-ui/react-icons';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from './ui/dialog';
|
||||||
|
import { Label } from './ui/label';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { useEvent, useEventWithLoading } from '@/hooks/useEvent';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||||
|
import { trpc } from '@/api/trpc';
|
||||||
|
import { showErrorToast } from '@/utils/error';
|
||||||
|
import { first, upperCase } from 'lodash-es';
|
||||||
|
|
||||||
interface WorkspaceSwitcherProps {
|
interface WorkspaceSwitcherProps {
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
@ -16,41 +37,241 @@ interface WorkspaceSwitcherProps {
|
|||||||
export const WorkspaceSwitcher: React.FC<WorkspaceSwitcherProps> = React.memo(
|
export const WorkspaceSwitcher: React.FC<WorkspaceSwitcherProps> = React.memo(
|
||||||
(props) => {
|
(props) => {
|
||||||
const userInfo = useUserInfo();
|
const userInfo = useUserInfo();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [showNewWorkspaceDialog, setShowNewWorkspaceDialog] = useState(false);
|
||||||
|
const [newWorkspaceName, setNewWorkspaceName] = useState('');
|
||||||
|
const createWorkspaceMutation = trpc.workspace.create.useMutation({
|
||||||
|
onSuccess: (userInfo) => {
|
||||||
|
setUserInfo(userInfo);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const switchWorkspaceMutation = trpc.workspace.switch.useMutation({
|
||||||
|
onSuccess: (userInfo) => {
|
||||||
|
setUserInfo(userInfo);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSwitchWorkspace = useEvent(
|
||||||
|
async (workspace: { id: string; name: string }) => {
|
||||||
|
setOpen(false);
|
||||||
|
|
||||||
|
if (userInfo?.currentWorkspace.id === workspace.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await switchWorkspaceMutation.mutateAsync({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
showErrorToast(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const [handleCreateNewWorkspace, isCreateLoading] = useEventWithLoading(
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
await createWorkspaceMutation.mutateAsync({
|
||||||
|
name: newWorkspaceName,
|
||||||
|
});
|
||||||
|
|
||||||
|
setShowNewWorkspaceDialog(false);
|
||||||
|
} catch (err) {
|
||||||
|
showErrorToast(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!userInfo) {
|
if (!userInfo) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const currentWorkspace = userInfo.currentWorkspace;
|
||||||
<Select value={userInfo.currentWorkspace.id}>
|
|
||||||
<SelectTrigger
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-2 [&>span]:line-clamp-1 [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:gap-1 [&>span]:truncate [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0',
|
|
||||||
props.isCollapsed &&
|
|
||||||
'flex h-9 w-9 shrink-0 items-center justify-center p-0 [&>span]:w-auto [&>svg]:hidden'
|
|
||||||
)}
|
|
||||||
aria-label="Select workspace"
|
|
||||||
>
|
|
||||||
<SelectValue placeholder="Select workspace">
|
|
||||||
<RiRocket2Fill />
|
|
||||||
|
|
||||||
<span className={cn('ml-2', props.isCollapsed && 'hidden')}>
|
return (
|
||||||
{userInfo.currentWorkspace.name}
|
<Dialog
|
||||||
</span>
|
open={showNewWorkspaceDialog}
|
||||||
</SelectValue>
|
onOpenChange={setShowNewWorkspaceDialog}
|
||||||
</SelectTrigger>
|
>
|
||||||
<SelectContent>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
{userInfo.workspaces.map((w) => (
|
<PopoverTrigger asChild>
|
||||||
<SelectItem key={w.workspace.id} value={w.workspace.id}>
|
<Button
|
||||||
<div className="flex items-center gap-3 [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0 [&_svg]:text-foreground">
|
variant="outline"
|
||||||
<RiRocket2Fill />
|
role="combobox"
|
||||||
{w.workspace.name}
|
aria-expanded={open}
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-between',
|
||||||
|
props.isCollapsed &&
|
||||||
|
'flex h-9 w-9 items-center justify-center p-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
className={cn('h-5 w-5', props.isCollapsed ? '' : 'mr-2')}
|
||||||
|
>
|
||||||
|
<AvatarImage
|
||||||
|
src={`https://avatar.vercel.sh/${currentWorkspace.name}.png`}
|
||||||
|
alt={currentWorkspace.name}
|
||||||
|
className="grayscale"
|
||||||
|
/>
|
||||||
|
<AvatarFallback>
|
||||||
|
{upperCase(first(currentWorkspace.name))}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<span className={cn(props.isCollapsed && 'hidden')}>
|
||||||
|
{currentWorkspace.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<CaretSortIcon
|
||||||
|
className={cn(
|
||||||
|
'ml-auto h-4 w-4 shrink-0 opacity-50',
|
||||||
|
props.isCollapsed && 'hidden'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>{t('No workspace found.')}</CommandEmpty>
|
||||||
|
<CommandGroup key="workspace" heading={t('Workspace')}>
|
||||||
|
{userInfo.workspaces.map(({ workspace }) => (
|
||||||
|
<CommandItem
|
||||||
|
key={workspace.id}
|
||||||
|
onSelect={() => {
|
||||||
|
handleSwitchWorkspace(workspace);
|
||||||
|
}}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
<Avatar className="mr-2 h-5 w-5">
|
||||||
|
<AvatarImage
|
||||||
|
src={`https://avatar.vercel.sh/${workspace.name}.png`}
|
||||||
|
alt={workspace.name}
|
||||||
|
className="grayscale"
|
||||||
|
/>
|
||||||
|
<AvatarFallback>
|
||||||
|
{upperCase(first(workspace.name))}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
{workspace.name}
|
||||||
|
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
'ml-auto h-4 w-4',
|
||||||
|
currentWorkspace.id === workspace.id
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'opacity-0'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
|
||||||
|
<CommandSeparator />
|
||||||
|
|
||||||
|
<CommandList>
|
||||||
|
<CommandGroup key="create">
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<CommandItem
|
||||||
|
aria-selected="false"
|
||||||
|
onSelect={() => {
|
||||||
|
setOpen(false);
|
||||||
|
setShowNewWorkspaceDialog(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LuPlusCircle className="mr-2" size={20} />
|
||||||
|
{t('Create Workspace')}
|
||||||
|
</CommandItem>
|
||||||
|
</DialogTrigger>
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('Create Workspace')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('Create a new workspace to cooperate with team members.')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div>
|
||||||
|
<div className="space-y-4 py-2 pb-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('Workspace Name')}</Label>
|
||||||
|
<Input
|
||||||
|
value={newWorkspaceName}
|
||||||
|
onChange={(e) => setNewWorkspaceName(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</SelectContent>
|
<DialogFooter>
|
||||||
</Select>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowNewWorkspaceDialog(false)}
|
||||||
|
>
|
||||||
|
{t('Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
loading={isCreateLoading}
|
||||||
|
onClick={handleCreateNewWorkspace}
|
||||||
|
>
|
||||||
|
{t('Create')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <Select value={userInfo.currentWorkspace.id}>
|
||||||
|
// <SelectTrigger
|
||||||
|
// className={cn(
|
||||||
|
// 'flex items-center gap-2 [&>span]:line-clamp-1 [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:gap-1 [&>span]:truncate [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0',
|
||||||
|
// props.isCollapsed &&
|
||||||
|
// 'flex h-9 w-9 shrink-0 items-center justify-center p-0 [&>span]:w-auto [&>svg]:hidden'
|
||||||
|
// )}
|
||||||
|
// aria-label="Select workspace"
|
||||||
|
// >
|
||||||
|
// <SelectValue placeholder="Select workspace">
|
||||||
|
// <RiRocket2Fill />
|
||||||
|
|
||||||
|
// <span className={cn('ml-2', props.isCollapsed && 'hidden')}>
|
||||||
|
// {userInfo.currentWorkspace.name}
|
||||||
|
// </span>
|
||||||
|
// </SelectValue>
|
||||||
|
// </SelectTrigger>
|
||||||
|
// <SelectContent>
|
||||||
|
// {userInfo.workspaces.map((w) => (
|
||||||
|
// <SelectItem key={w.workspace.id} value={w.workspace.id}>
|
||||||
|
// <div className="[&_svg]:text-foreground flex items-center gap-3 [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0">
|
||||||
|
// <RiRocket2Fill />
|
||||||
|
// {w.workspace.name}
|
||||||
|
// </div>
|
||||||
|
// </SelectItem>
|
||||||
|
// ))}
|
||||||
|
|
||||||
|
// <SelectSeparator />
|
||||||
|
|
||||||
|
// <SelectItem
|
||||||
|
// value="create"
|
||||||
|
// onClick={() => console.log('aa')}
|
||||||
|
// onSelect={() => console.log('bbb')}
|
||||||
|
// >
|
||||||
|
// <div className="[&_svg]:text-foreground flex items-center gap-3 [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0">
|
||||||
|
// <LuPlus />
|
||||||
|
// {t('Create Workspace')}
|
||||||
|
// </div>
|
||||||
|
// </SelectItem>
|
||||||
|
// </SelectContent>
|
||||||
|
// </Select>
|
||||||
|
// );
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
WorkspaceSwitcher.displayName = 'WorkspaceSwitcher';
|
WorkspaceSwitcher.displayName = 'WorkspaceSwitcher';
|
||||||
|
@ -16,13 +16,19 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { useEvent } from '@/hooks/useEvent';
|
import { useEvent } from '@/hooks/useEvent';
|
||||||
import { useSettingsStore } from '@/store/settings';
|
import { useSettingsStore } from '@/store/settings';
|
||||||
import { useCurrentWorkspaceId, useUserInfo, useUserStore } from '@/store/user';
|
import {
|
||||||
|
setUserInfo,
|
||||||
|
useCurrentWorkspaceId,
|
||||||
|
useUserInfo,
|
||||||
|
useUserStore,
|
||||||
|
} from '@/store/user';
|
||||||
import { languages } from '@/utils/constants';
|
import { languages } from '@/utils/constants';
|
||||||
import { useTranslation, setLanguage } from '@i18next-toolkit/react';
|
import { useTranslation, setLanguage } from '@i18next-toolkit/react';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { version } from '@/utils/env';
|
import { version } from '@/utils/env';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { LuMoreVertical } from 'react-icons/lu';
|
import { LuMoreVertical } from 'react-icons/lu';
|
||||||
|
import { trpc } from '@/api/trpc';
|
||||||
|
|
||||||
interface UserConfigProps {
|
interface UserConfigProps {
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
@ -46,6 +52,11 @@ export const UserConfig: React.FC<UserConfigProps> = React.memo((props) => {
|
|||||||
|
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
|
const switchWorkspaceMutation = trpc.workspace.switch.useMutation({
|
||||||
|
onSuccess: (userInfo) => {
|
||||||
|
setUserInfo(userInfo);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleChangeColorSchema = useEvent((colorScheme) => {
|
const handleChangeColorSchema = useEvent((colorScheme) => {
|
||||||
useSettingsStore.setState({
|
useSettingsStore.setState({
|
||||||
@ -125,7 +136,12 @@ export const UserConfig: React.FC<UserConfigProps> = React.memo((props) => {
|
|||||||
<DropdownMenuRadioItem
|
<DropdownMenuRadioItem
|
||||||
key={workspace.id}
|
key={workspace.id}
|
||||||
value={workspace.id}
|
value={workspace.id}
|
||||||
disabled={true}
|
disabled={workspace.id === workspaceId}
|
||||||
|
onSelect={() =>
|
||||||
|
switchWorkspaceMutation.mutateAsync({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{workspace.name}
|
{workspace.name}
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuRadioItem>
|
||||||
|
@ -54,6 +54,7 @@ const AvatarFallback = React.forwardRef<
|
|||||||
'bg-muted flex h-full w-full items-center justify-center rounded-full',
|
'bg-muted flex h-full w-full items-center justify-center rounded-full',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
delayMs={5000}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
@ -13,7 +13,7 @@ const Command = React.forwardRef<
|
|||||||
<CommandPrimitive
|
<CommandPrimitive
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-full w-full flex-col overflow-hidden rounded-md bg-white text-zinc-950 dark:bg-zinc-950 dark:text-zinc-50',
|
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -27,7 +27,7 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
|||||||
return (
|
return (
|
||||||
<Dialog {...props}>
|
<Dialog {...props}>
|
||||||
<DialogContent className="overflow-hidden p-0">
|
<DialogContent className="overflow-hidden p-0">
|
||||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-zinc-500 dark:[&_[cmdk-group-heading]]:text-zinc-400 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
{children}
|
{children}
|
||||||
</Command>
|
</Command>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@ -44,7 +44,7 @@ const CommandInput = React.forwardRef<
|
|||||||
<CommandPrimitive.Input
|
<CommandPrimitive.Input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-zinc-500 disabled:cursor-not-allowed disabled:opacity-50 dark:placeholder:text-zinc-400',
|
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -87,7 +87,7 @@ const CommandGroup = React.forwardRef<
|
|||||||
<CommandPrimitive.Group
|
<CommandPrimitive.Group
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'overflow-hidden p-1 text-zinc-950 dark:text-zinc-50 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-zinc-500 dark:[&_[cmdk-group-heading]]:text-zinc-400',
|
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -102,7 +102,7 @@ const CommandSeparator = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<CommandPrimitive.Separator
|
<CommandPrimitive.Separator
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('-mx-1 h-px bg-zinc-200 dark:bg-zinc-800', className)}
|
className={cn('bg-border -mx-1 h-px', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@ -115,7 +115,7 @@ const CommandItem = React.forwardRef<
|
|||||||
<CommandPrimitive.Item
|
<CommandPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-zinc-100 aria-selected:text-zinc-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:aria-selected:bg-zinc-800 dark:aria-selected:text-zinc-50',
|
'data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -131,7 +131,7 @@ const CommandShortcut = ({
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'ml-auto text-xs tracking-widest text-zinc-500 dark:text-zinc-400',
|
'text-muted-foreground ml-auto text-xs tracking-widest',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
27
src/client/utils/error.ts
Normal file
27
src/client/utils/error.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { TRPCClientError } from '@trpc/client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show common error toast with auto process error object
|
||||||
|
*/
|
||||||
|
export function showErrorToast(err: any) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
if (err instanceof TRPCClientError) {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(err.message);
|
||||||
|
toast.error(json[0].message);
|
||||||
|
} catch {
|
||||||
|
toast.error(err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof Error) {
|
||||||
|
toast.error(err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error(String(err));
|
||||||
|
}
|
@ -21,7 +21,7 @@ export async function getUserCount(): Promise<number> {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
const createUserSelect = {
|
export const createUserSelect = {
|
||||||
id: true,
|
id: true,
|
||||||
username: true,
|
username: true,
|
||||||
nickname: true,
|
nickname: true,
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
OpenApiMetaInfo,
|
OpenApiMetaInfo,
|
||||||
|
protectProedure,
|
||||||
publicProcedure,
|
publicProcedure,
|
||||||
router,
|
router,
|
||||||
workspaceOwnerProcedure,
|
workspaceOwnerProcedure,
|
||||||
@ -7,13 +8,119 @@ import {
|
|||||||
} from '../trpc.js';
|
} from '../trpc.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { prisma } from '../../model/_client.js';
|
import { prisma } from '../../model/_client.js';
|
||||||
import { workspaceDashboardLayoutSchema } from '../../model/_schema/index.js';
|
import {
|
||||||
|
userInfoSchema,
|
||||||
|
workspaceDashboardLayoutSchema,
|
||||||
|
} from '../../model/_schema/index.js';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { OPENAPI_TAG } from '../../utils/const.js';
|
import { OPENAPI_TAG } from '../../utils/const.js';
|
||||||
import { OpenApiMeta } from 'trpc-openapi';
|
import { OpenApiMeta } from 'trpc-openapi';
|
||||||
import { getServerCount } from '../../model/serverStatus.js';
|
import { getServerCount } from '../../model/serverStatus.js';
|
||||||
|
import { ROLES, slugRegex } from '@tianji/shared';
|
||||||
|
import { createUserSelect } from '../../model/user.js';
|
||||||
|
|
||||||
export const workspaceRouter = router({
|
export const workspaceRouter = router({
|
||||||
|
create: protectProedure
|
||||||
|
.meta(
|
||||||
|
buildWorkspaceOpenapi({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/create',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.max(60)
|
||||||
|
.min(4)
|
||||||
|
.regex(slugRegex, { message: 'no a valid name' }),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.output(userInfoSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const { name } = input;
|
||||||
|
const userId = ctx.user.id;
|
||||||
|
|
||||||
|
const existed = await prisma.workspace.findFirst({
|
||||||
|
where: {
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existed) {
|
||||||
|
throw new Error('This workspace has been existed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInfo = await prisma.$transaction(async (p) => {
|
||||||
|
const newWorkspace = await p.workspace.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return await p.user.update({
|
||||||
|
data: {
|
||||||
|
currentWorkspaceId: newWorkspace.id,
|
||||||
|
workspaces: {
|
||||||
|
create: {
|
||||||
|
workspaceId: newWorkspace.id,
|
||||||
|
role: ROLES.owner,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
select: createUserSelect,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return userInfo;
|
||||||
|
}),
|
||||||
|
switch: protectProedure
|
||||||
|
.meta(
|
||||||
|
buildWorkspaceOpenapi({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/switch',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
workspaceId: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.output(userInfoSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const userId = ctx.user.id;
|
||||||
|
const { workspaceId } = input;
|
||||||
|
|
||||||
|
const targetWorkspace = await prisma.workspace.findFirst({
|
||||||
|
where: {
|
||||||
|
id: workspaceId,
|
||||||
|
users: {
|
||||||
|
some: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetWorkspace) {
|
||||||
|
throw new Error('Target Workspace not found!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInfo = await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
currentWorkspaceId: targetWorkspace.id,
|
||||||
|
},
|
||||||
|
select: createUserSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return userInfo;
|
||||||
|
}),
|
||||||
getUserWorkspaceRole: publicProcedure
|
getUserWorkspaceRole: publicProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
@ -40,7 +147,7 @@ export const workspaceRouter = router({
|
|||||||
.meta(
|
.meta(
|
||||||
buildWorkspaceOpenapi({
|
buildWorkspaceOpenapi({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/getServiceCount',
|
path: '/{workspaceId}/getServiceCount',
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.output(
|
.output(
|
||||||
@ -103,6 +210,9 @@ export const workspaceRouter = router({
|
|||||||
feed,
|
feed,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
updateDashboardOrder: workspaceOwnerProcedure
|
updateDashboardOrder: workspaceOwnerProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
@ -121,6 +231,9 @@ export const workspaceRouter = router({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
saveDashboardLayout: workspaceOwnerProcedure
|
saveDashboardLayout: workspaceOwnerProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
@ -147,7 +260,7 @@ function buildWorkspaceOpenapi(meta: OpenApiMetaInfo): OpenApiMeta {
|
|||||||
tags: [OPENAPI_TAG.WORKSPACE],
|
tags: [OPENAPI_TAG.WORKSPACE],
|
||||||
protect: true,
|
protect: true,
|
||||||
...meta,
|
...meta,
|
||||||
path: `/workspace/{workspaceId}${meta.path}`,
|
path: `/workspace/${meta.path}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user