diff --git a/src/client/api/socketio.ts b/src/client/api/socketio.ts index e5a92178..5f187b77 100644 --- a/src/client/api/socketio.ts +++ b/src/client/api/socketio.ts @@ -12,6 +12,11 @@ const useSocketStore = create<{ })); export function createSocketIOClient(workspaceId: string) { + const prev = useSocketStore.getState().socket; + if (prev) { + prev.disconnect(); + } + const socket = io(`/${workspaceId}`, { transports: ['websocket'], reconnectionDelayMax: 10000, diff --git a/src/client/components/DevContainer.tsx b/src/client/components/DevContainer.tsx new file mode 100644 index 00000000..b90597d4 --- /dev/null +++ b/src/client/components/DevContainer.tsx @@ -0,0 +1,11 @@ +import { isDev } from '@/utils/env'; +import React, { PropsWithChildren } from 'react'; + +export const DevContainer: React.FC = React.memo((props) => { + if (isDev) { + return <>{props.children}; + } + + return null; +}); +DevContainer.displayName = 'DevContainer'; diff --git a/src/client/components/WorkspaceSwitcher.tsx b/src/client/components/WorkspaceSwitcher.tsx index d1263c51..fd713d23 100644 --- a/src/client/components/WorkspaceSwitcher.tsx +++ b/src/client/components/WorkspaceSwitcher.tsx @@ -1,14 +1,35 @@ -import React from 'react'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; +import React, { useState } from 'react'; import { cn } from '@/utils/style'; -import { useUserInfo } from '@/store/user'; -import { RiRocket2Fill } from 'react-icons/ri'; +import { setUserInfo, useUserInfo } from '@/store/user'; +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 { isCollapsed: boolean; @@ -16,41 +37,241 @@ interface WorkspaceSwitcherProps { export const WorkspaceSwitcher: React.FC = React.memo( (props) => { 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) { return null; } + const currentWorkspace = userInfo.currentWorkspace; + return ( - setNewWorkspaceName(e.target.value)} + /> - - ))} - - + + + + + + + + ); + + // return ( + // + // ); } ); WorkspaceSwitcher.displayName = 'WorkspaceSwitcher'; diff --git a/src/client/components/layout/UserConfig.tsx b/src/client/components/layout/UserConfig.tsx index 069ff01a..7d99c2a4 100644 --- a/src/client/components/layout/UserConfig.tsx +++ b/src/client/components/layout/UserConfig.tsx @@ -16,13 +16,19 @@ import { } from '@/components/ui/dropdown-menu'; import { useEvent } from '@/hooks/useEvent'; 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 { useTranslation, setLanguage } from '@i18next-toolkit/react'; import { useNavigate } from '@tanstack/react-router'; import { version } from '@/utils/env'; import React from 'react'; import { LuMoreVertical } from 'react-icons/lu'; +import { trpc } from '@/api/trpc'; interface UserConfigProps { isCollapsed: boolean; @@ -46,6 +52,11 @@ export const UserConfig: React.FC = React.memo((props) => { return []; }); + const switchWorkspaceMutation = trpc.workspace.switch.useMutation({ + onSuccess: (userInfo) => { + setUserInfo(userInfo); + }, + }); const handleChangeColorSchema = useEvent((colorScheme) => { useSettingsStore.setState({ @@ -125,7 +136,12 @@ export const UserConfig: React.FC = React.memo((props) => { + switchWorkspaceMutation.mutateAsync({ + workspaceId: workspace.id, + }) + } > {workspace.name} diff --git a/src/client/components/ui/avatar.tsx b/src/client/components/ui/avatar.tsx index 623fa704..fad7e2bc 100644 --- a/src/client/components/ui/avatar.tsx +++ b/src/client/components/ui/avatar.tsx @@ -54,6 +54,7 @@ const AvatarFallback = React.forwardRef< 'bg-muted flex h-full w-full items-center justify-center rounded-full', className )} + delayMs={5000} {...props} /> )); diff --git a/src/client/components/ui/command.tsx b/src/client/components/ui/command.tsx index b18e7566..524ef172 100644 --- a/src/client/components/ui/command.tsx +++ b/src/client/components/ui/command.tsx @@ -13,7 +13,7 @@ const Command = React.forwardRef< { return ( - + {children} @@ -44,7 +44,7 @@ const CommandInput = React.forwardRef< (({ className, ...props }, ref) => ( )); @@ -115,7 +115,7 @@ const CommandItem = React.forwardRef< { return count; } -const createUserSelect = { +export const createUserSelect = { id: true, username: true, nickname: true, diff --git a/src/server/trpc/routers/workspace.ts b/src/server/trpc/routers/workspace.ts index ce20c5c2..88648a31 100644 --- a/src/server/trpc/routers/workspace.ts +++ b/src/server/trpc/routers/workspace.ts @@ -1,5 +1,6 @@ import { OpenApiMetaInfo, + protectProedure, publicProcedure, router, workspaceOwnerProcedure, @@ -7,13 +8,119 @@ import { } from '../trpc.js'; import { z } from 'zod'; 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 { OPENAPI_TAG } from '../../utils/const.js'; import { OpenApiMeta } from 'trpc-openapi'; import { getServerCount } from '../../model/serverStatus.js'; +import { ROLES, slugRegex } from '@tianji/shared'; +import { createUserSelect } from '../../model/user.js'; 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 .input( z.object({ @@ -40,7 +147,7 @@ export const workspaceRouter = router({ .meta( buildWorkspaceOpenapi({ method: 'GET', - path: '/getServiceCount', + path: '/{workspaceId}/getServiceCount', }) ) .output( @@ -103,6 +210,9 @@ export const workspaceRouter = router({ feed, }; }), + /** + * @deprecated + */ updateDashboardOrder: workspaceOwnerProcedure .input( z.object({ @@ -121,6 +231,9 @@ export const workspaceRouter = router({ }, }); }), + /** + * @deprecated + */ saveDashboardLayout: workspaceOwnerProcedure .input( z.object({ @@ -147,7 +260,7 @@ function buildWorkspaceOpenapi(meta: OpenApiMetaInfo): OpenApiMeta { tags: [OPENAPI_TAG.WORKSPACE], protect: true, ...meta, - path: `/workspace/{workspaceId}${meta.path}`, + path: `/workspace/${meta.path}`, }, }; }