From 52f7a089cd10fb660c3811c445cd685ee0109972 Mon Sep 17 00:00:00 2001 From: LeeJongBeom <52884648+devleejb@users.noreply.github.com> Date: Wed, 24 Jan 2024 22:18:19 +0900 Subject: [PATCH] (FE) Apply URL Spec (#98) * Change login page url * Add new index page * Add login button to main header * Change height of main layout * Change workspace url spec * Change redirect url after login * Change url of workspace * Change component name from `Editor` to `Document` * Change `injectProtectedRoute` implementation to support protecting children * Change documentSlug to documentId in URL * Delete share mode in `DocumentIndex` * Change API path for document * Change document page url * Change cleanup code for docs * Remove share mode in share modal * Add document share page * Componentize DocumentView * Add share mode * Fix mode button background padding * Change document page to use `useYorkieDocument` * Add tooltip to avatar * Add back button to DocumentHeader * Remove slug in document db * Fix lint * Add check path to API * Fix formatting * Add name conflict checking API * Fix formatting * Remove default nickname * Add API for changing nickname * Change findOptions for user workspaces * Fix lint * Add Change nickname modal * Add name conflict checking on workspace * Add conflict checking to CreateModal * Add queryInvalidation on creating workspace * Move to the note page when created --- backend/prisma/schema.prisma | 3 +- backend/src/app.module.ts | 2 + backend/src/auth/auth.controller.ts | 3 +- backend/src/auth/auth.service.ts | 13 -- backend/src/check/check.controller.spec.ts | 18 +++ backend/src/check/check.controller.ts | 26 ++++ backend/src/check/check.module.ts | 10 ++ backend/src/check/check.service.spec.ts | 18 +++ backend/src/check/check.service.ts | 27 ++++ .../src/check/dto/check-name-conflict.dto.ts | 6 + .../check-name-conflict-response.type.ts | 6 + backend/src/documents/documents.controller.ts | 23 +-- backend/src/documents/documents.service.ts | 21 --- .../documents/types/document-domain.type.ts | 2 - .../types/find-document-response.type.ts | 3 - backend/src/users/dto/change-nickname.dto.ts | 6 + backend/src/users/types/user-domain.type.ts | 8 +- backend/src/users/users.controller.ts | 29 +++- backend/src/users/users.module.ts | 3 +- backend/src/users/users.service.ts | 77 ++++++---- .../find-workspace-document-response.type.ts | 3 + .../workspace-documents.controller.ts | 20 +++ .../workspace-documents.service.ts | 35 +++-- backend/src/workspaces/workspaces.module.ts | 3 +- backend/src/workspaces/workspaces.service.ts | 29 ++-- backend/tsconfig.json | 38 ++--- .../src/components/cards/DocumentCard.tsx | 5 +- frontend/src/components/common/GuestRoute.tsx | 2 +- .../components/drawers/WorkspaceDrawer.tsx | 9 +- .../src/components/editor/DocumentView.tsx | 71 +++++++++ .../{EditorHeader.tsx => DocumentHeader.tsx} | 34 ++-- .../src/components/headers/MainHeader.tsx | 16 +- .../{EditorLayout.tsx => DocumentLayout.tsx} | 8 +- .../src/components/layouts/MainLayout.tsx | 4 +- .../components/modals/ChangeNicknameModal.tsx | 88 +++++++++++ .../src/components/modals/CreateModal.tsx | 40 ++++- frontend/src/components/modals/ShareModal.tsx | 20 +-- .../popovers/WorkspaceListPopover.tsx | 3 +- frontend/src/hooks/api/check.ts | 22 +++ frontend/src/hooks/api/document.ts | 24 +-- frontend/src/hooks/api/types/check.d.ts | 7 + frontend/src/hooks/api/types/document.d.ts | 3 - frontend/src/hooks/api/types/user.d.ts | 10 +- .../hooks/api/types/workspaceDocument.d.ts | 2 + frontend/src/hooks/api/user.ts | 22 ++- frontend/src/hooks/api/workspace.ts | 9 +- frontend/src/hooks/api/workspaceDocument.ts | 25 ++- frontend/src/hooks/useYorkieDocument.ts | 51 ++++++ frontend/src/pages/Index.tsx | 57 +------ frontend/src/pages/document/Index.tsx | 145 ------------------ frontend/src/pages/login/Index.tsx | 60 ++++++++ .../src/pages/workspace/document/Index.tsx | 43 ++++++ .../pages/workspace/document/share/Index.tsx | 53 +++++++ frontend/src/pages/workspace/join/Index.tsx | 2 +- frontend/src/providers/AuthProvider.tsx | 11 +- frontend/src/routes.tsx | 50 ++++-- frontend/src/store/userSlice.ts | 2 +- 57 files changed, 891 insertions(+), 439 deletions(-) create mode 100644 backend/src/check/check.controller.spec.ts create mode 100644 backend/src/check/check.controller.ts create mode 100644 backend/src/check/check.module.ts create mode 100644 backend/src/check/check.service.spec.ts create mode 100644 backend/src/check/check.service.ts create mode 100644 backend/src/check/dto/check-name-conflict.dto.ts create mode 100644 backend/src/check/types/check-name-conflict-response.type.ts delete mode 100644 backend/src/documents/types/find-document-response.type.ts create mode 100644 backend/src/users/dto/change-nickname.dto.ts create mode 100644 backend/src/workspace-documents/types/find-workspace-document-response.type.ts create mode 100644 frontend/src/components/editor/DocumentView.tsx rename frontend/src/components/headers/{EditorHeader.tsx => DocumentHeader.tsx} (79%) rename frontend/src/components/layouts/{EditorLayout.tsx => DocumentLayout.tsx} (54%) create mode 100644 frontend/src/components/modals/ChangeNicknameModal.tsx create mode 100644 frontend/src/hooks/api/check.ts create mode 100644 frontend/src/hooks/api/types/check.d.ts create mode 100644 frontend/src/hooks/useYorkieDocument.ts delete mode 100644 frontend/src/pages/document/Index.tsx create mode 100644 frontend/src/pages/login/Index.tsx create mode 100644 frontend/src/pages/workspace/document/Index.tsx create mode 100644 frontend/src/pages/workspace/document/share/Index.tsx diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 97bcda47..406ec66e 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -14,7 +14,7 @@ model User { id String @id @default(auto()) @map("_id") @db.ObjectId socialProvider String @map("social_provider") socialUid String @unique @map("social_uid") - nickname String + nickname String? createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") userWorkspaceList UserWorkspace[] @@ -52,7 +52,6 @@ model Document { id String @id @default(auto()) @map("_id") @db.ObjectId yorkieDocumentId String @map("yorkie_document_id") title String - slug String content String? createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 50ba4575..2410d666 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -9,6 +9,7 @@ import { WorkspacesModule } from "./workspaces/workspaces.module"; import { WorkspaceUsersModule } from "./workspace-users/workspace-users.module"; import { WorkspaceDocumentsModule } from "./workspace-documents/workspace-documents.module"; import { DocumentsModule } from "./documents/documents.module"; +import { CheckModule } from "./check/check.module"; @Module({ imports: [ @@ -19,6 +20,7 @@ import { DocumentsModule } from "./documents/documents.module"; WorkspaceUsersModule, WorkspaceDocumentsModule, DocumentsModule, + CheckModule, ], controllers: [], providers: [ diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index a6eb4657..9bfc0215 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -30,8 +30,7 @@ export class AuthController { async login(@Req() req: LoginRequest): Promise { const user = await this.usersService.findOrCreate( req.user.socialProvider, - req.user.socialUid, - req.user.nickname + req.user.socialUid ); const accessToken = this.jwtService.sign({ sub: user.id, nickname: user.nickname }); diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 3bff1d09..beddfe5b 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -4,17 +4,4 @@ import { UsersService } from "src/users/users.service"; @Injectable() export class AuthService { constructor(private usersService: UsersService) {} - - async issueJwtToken(socialProvider: string, socialUid: string, nickname: string) { - const user = await this.usersService.findOrCreate(socialProvider, socialUid, nickname); - - if (user) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { socialProvider: _socaialProvider, socialUid: _social, ...result } = user; - - return result; - } - - return null; - } } diff --git a/backend/src/check/check.controller.spec.ts b/backend/src/check/check.controller.spec.ts new file mode 100644 index 00000000..1bd765cf --- /dev/null +++ b/backend/src/check/check.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { CheckController } from "./check.controller"; + +describe("CheckController", () => { + let controller: CheckController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [CheckController], + }).compile(); + + controller = module.get(CheckController); + }); + + it("should be defined", () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/backend/src/check/check.controller.ts b/backend/src/check/check.controller.ts new file mode 100644 index 00000000..561896d4 --- /dev/null +++ b/backend/src/check/check.controller.ts @@ -0,0 +1,26 @@ +import { Body, Controller, Post } from "@nestjs/common"; +import { CheckService } from "./check.service"; +import { CheckNameConflictDto } from "./dto/check-name-conflict.dto"; +import { CheckNameConflicReponse } from "./types/check-name-conflict-response.type"; +import { Public } from "src/utils/decorators/auth.decorator"; +import { ApiBody, ApiOkResponse, ApiOperation, ApiTags } from "@nestjs/swagger"; + +@ApiTags("Check") +@Controller("check") +export class CheckController { + constructor(private checkService: CheckService) {} + + @Public() + @Post("name-conflict") + @ApiOperation({ + summary: "Check Whether The Name Conflicts with Username or Title of Workspace.", + description: "If the name is conflict, it returns true.", + }) + @ApiBody({ type: CheckNameConflictDto }) + @ApiOkResponse({ type: CheckNameConflicReponse }) + async checkNameConflict( + @Body() checkNameConflictDto: CheckNameConflictDto + ): Promise { + return this.checkService.checkNameConflict(checkNameConflictDto.name); + } +} diff --git a/backend/src/check/check.module.ts b/backend/src/check/check.module.ts new file mode 100644 index 00000000..ce15f8cb --- /dev/null +++ b/backend/src/check/check.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { CheckService } from "./check.service"; +import { CheckController } from "./check.controller"; +import { PrismaService } from "src/db/prisma.service"; + +@Module({ + providers: [CheckService, PrismaService], + controllers: [CheckController], +}) +export class CheckModule {} diff --git a/backend/src/check/check.service.spec.ts b/backend/src/check/check.service.spec.ts new file mode 100644 index 00000000..8d4b4c8d --- /dev/null +++ b/backend/src/check/check.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { CheckService } from "./check.service"; + +describe("CheckService", () => { + let service: CheckService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [CheckService], + }).compile(); + + service = module.get(CheckService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); +}); diff --git a/backend/src/check/check.service.ts b/backend/src/check/check.service.ts new file mode 100644 index 00000000..daa73e2f --- /dev/null +++ b/backend/src/check/check.service.ts @@ -0,0 +1,27 @@ +import { Injectable } from "@nestjs/common"; +import { PrismaService } from "src/db/prisma.service"; +import { CheckNameConflicReponse } from "./types/check-name-conflict-response.type"; +import slugify from "slugify"; + +@Injectable() +export class CheckService { + constructor(private prismaService: PrismaService) {} + + async checkNameConflict(name: string): Promise { + const slug = slugify(name, { lower: true }); + const conflictUserList = await this.prismaService.user.findMany({ + where: { + OR: [{ nickname: name }, { nickname: slug }], + }, + }); + const conflictWorkspaceList = await this.prismaService.workspace.findMany({ + where: { + OR: [{ title: name }, { title: slug }], + }, + }); + + return { + conflict: Boolean(conflictUserList.length + conflictWorkspaceList.length), + }; + } +} diff --git a/backend/src/check/dto/check-name-conflict.dto.ts b/backend/src/check/dto/check-name-conflict.dto.ts new file mode 100644 index 00000000..b9a5586e --- /dev/null +++ b/backend/src/check/dto/check-name-conflict.dto.ts @@ -0,0 +1,6 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class CheckNameConflictDto { + @ApiProperty({ type: String, description: "Name to check conflict" }) + name: string; +} diff --git a/backend/src/check/types/check-name-conflict-response.type.ts b/backend/src/check/types/check-name-conflict-response.type.ts new file mode 100644 index 00000000..4d8feba4 --- /dev/null +++ b/backend/src/check/types/check-name-conflict-response.type.ts @@ -0,0 +1,6 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class CheckNameConflicReponse { + @ApiProperty({ type: Boolean, description: "Whether the name is conflict" }) + conflict: boolean; +} diff --git a/backend/src/documents/documents.controller.ts b/backend/src/documents/documents.controller.ts index 4498f9ca..5ca1cc55 100644 --- a/backend/src/documents/documents.controller.ts +++ b/backend/src/documents/documents.controller.ts @@ -1,8 +1,7 @@ -import { Controller, Get, Param, Query, Req } from "@nestjs/common"; +import { Controller, Get, Query } from "@nestjs/common"; import { DocumentsService } from "./documents.service"; import { Public } from "src/utils/decorators/auth.decorator"; import { - ApiFoundResponse, ApiNotFoundResponse, ApiOkResponse, ApiOperation, @@ -12,8 +11,6 @@ import { } from "@nestjs/swagger"; import { FindDocumentFromSharingTokenResponse } from "./types/find-document-from-sharing-token-response.type"; import { HttpExceptionResponse } from "src/utils/types/http-exception-response.type"; -import { FindDocumentResponse } from "./types/find-document-response.type"; -import { AuthroizedRequest } from "src/utils/types/req.type"; @ApiTags("Documents") @Controller("documents") @@ -41,22 +38,4 @@ export class DocumentsController { ): Promise { return this.documentsService.findDocumentFromSharingToken(token); } - - @Get(":document_slug") - @ApiOperation({ - summary: "Retrieve a Document in the Workspace", - description: "If the user has the access permissions, return a document.", - }) - @ApiFoundResponse({ type: FindDocumentResponse }) - @ApiNotFoundResponse({ - type: HttpExceptionResponse, - description: - "The workspace or document does not exist, or the user lacks the appropriate permissions.", - }) - async findOne( - @Req() req: AuthroizedRequest, - @Param("document_slug") documentSlug: string - ): Promise { - return this.documentsService.findOneBySlug(req.user.id, documentSlug); - } } diff --git a/backend/src/documents/documents.service.ts b/backend/src/documents/documents.service.ts index d46ff037..4d4fee61 100644 --- a/backend/src/documents/documents.service.ts +++ b/backend/src/documents/documents.service.ts @@ -43,25 +43,4 @@ export class DocumentsService { role, }; } - - async findOneBySlug(userId: string, documentSlug: string) { - try { - const document = await this.prismaService.document.findFirstOrThrow({ - where: { - slug: documentSlug, - }, - }); - - await this.prismaService.userWorkspace.findFirstOrThrow({ - where: { - userId, - workspaceId: document.workspaceId, - }, - }); - - return document; - } catch (e) { - throw new NotFoundException(); - } - } } diff --git a/backend/src/documents/types/document-domain.type.ts b/backend/src/documents/types/document-domain.type.ts index 9758695b..12157ff6 100644 --- a/backend/src/documents/types/document-domain.type.ts +++ b/backend/src/documents/types/document-domain.type.ts @@ -7,8 +7,6 @@ export class DocumentDomain { yorkieDocumentId: string; @ApiProperty({ type: String, description: "Title of the document" }) title: string; - @ApiProperty({ type: String, description: "Slug of the document" }) - slug: string; @ApiProperty({ type: String, description: "Content of the document", required: false }) content?: string; @ApiProperty({ type: Date, description: "Created date of the document" }) diff --git a/backend/src/documents/types/find-document-response.type.ts b/backend/src/documents/types/find-document-response.type.ts deleted file mode 100644 index ddd4291b..00000000 --- a/backend/src/documents/types/find-document-response.type.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { WorkspaceDocumentDomain } from "../../workspace-documents/types/workspace-document-domain.type"; - -export class FindDocumentResponse extends WorkspaceDocumentDomain {} diff --git a/backend/src/users/dto/change-nickname.dto.ts b/backend/src/users/dto/change-nickname.dto.ts new file mode 100644 index 00000000..ee610194 --- /dev/null +++ b/backend/src/users/dto/change-nickname.dto.ts @@ -0,0 +1,6 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class ChangeNicknameDto { + @ApiProperty({ type: String, description: "Nickname of user to update" }) + nickname: string; +} diff --git a/backend/src/users/types/user-domain.type.ts b/backend/src/users/types/user-domain.type.ts index 57f4be2f..321cf272 100644 --- a/backend/src/users/types/user-domain.type.ts +++ b/backend/src/users/types/user-domain.type.ts @@ -3,10 +3,10 @@ import { ApiProperty } from "@nestjs/swagger"; export class UserDomain { @ApiProperty({ type: String, description: "ID of user" }) id: string; - @ApiProperty({ type: String, description: "Nickname of user" }) - nickname: string; - @ApiProperty({ type: String, description: "Last worksace slug of user" }) - lastWorkspaceSlug: string; + @ApiProperty({ type: String, description: "Nickname of user", required: false }) + nickname?: string; + @ApiProperty({ type: String, description: "Last worksace slug of user", required: false }) + lastWorkspaceSlug?: string; @ApiProperty({ type: Date, description: "Created date of user" }) createdAt: Date; @ApiProperty({ type: Date, description: "Updated date of user" }) diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index 9b2c9d72..4397a48e 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -1,8 +1,16 @@ -import { Controller, Get, Req } from "@nestjs/common"; -import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Body, Controller, Get, Put, Req } from "@nestjs/common"; +import { + ApiBearerAuth, + ApiBody, + ApiConflictResponse, + ApiOkResponse, + ApiOperation, + ApiTags, +} from "@nestjs/swagger"; import { UsersService } from "./users.service"; import { AuthroizedRequest } from "src/utils/types/req.type"; import { FindUserResponse } from "./types/find-user-response.type"; +import { ChangeNicknameDto } from "./dto/change-nickname.dto"; @ApiTags("Users") @ApiBearerAuth() @@ -19,4 +27,21 @@ export class UsersController { async findOne(@Req() req: AuthroizedRequest): Promise { return this.usersService.findOne(req.user.id); } + + @Put("") + @ApiOperation({ + summary: "Change the Nickname of the User", + description: "Change the nickname of the user", + }) + @ApiBody({ + type: ChangeNicknameDto, + }) + @ApiOkResponse() + @ApiConflictResponse({ description: "The nickname conflicts" }) + async changeNickname( + @Req() req: AuthroizedRequest, + @Body() changeNicknameDto: ChangeNicknameDto + ): Promise { + return this.usersService.changeNickname(req.user.id, changeNicknameDto.nickname); + } } diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index 83077360..7930a041 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -2,9 +2,10 @@ import { Module } from "@nestjs/common"; import { UsersService } from "./users.service"; import { PrismaService } from "src/db/prisma.service"; import { UsersController } from "./users.controller"; +import { CheckService } from "src/check/check.service"; @Module({ - providers: [UsersService, PrismaService], + providers: [UsersService, PrismaService, CheckService], exports: [UsersService], controllers: [UsersController], }) diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 5049c9d4..04de8452 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -1,13 +1,17 @@ -import { Injectable } from "@nestjs/common"; +import { ConflictException, Injectable } from "@nestjs/common"; import { User } from "@prisma/client"; import { PrismaService } from "src/db/prisma.service"; import { FindUserResponse } from "./types/find-user-response.type"; -import { WorkspaceRoleConstants } from "src/utils/constants/auth-role"; +import { CheckService } from "src/check/check.service"; import slugify from "slugify"; +import { WorkspaceRoleConstants } from "src/utils/constants/auth-role"; @Injectable() export class UsersService { - constructor(private prismaService: PrismaService) {} + constructor( + private prismaService: PrismaService, + private checkService: CheckService + ) {} async findOne(userId: string): Promise { const foundUserWorkspace = await this.prismaService.userWorkspace.findFirst({ @@ -40,15 +44,11 @@ export class UsersService { return { ...foundUser, - lastWorkspaceSlug: foundUserWorkspace.workspace.slug, + lastWorkspaceSlug: foundUserWorkspace?.workspace?.slug, }; } - async findOrCreate( - socialProvider: string, - socialUid: string, - nickname: string - ): Promise { + async findOrCreate(socialProvider: string, socialUid: string): Promise { const foundUser = await this.prismaService.user.findFirst({ where: { socialProvider, @@ -64,40 +64,57 @@ export class UsersService { data: { socialProvider, socialUid, - nickname, }, }); - const title = `${user.nickname}'s Workspace`; - let slug = slugify(title); + return user; + } - const duplicatedWorkspaceList = await this.prismaService.workspace.findMany({ - where: { - slug: { - startsWith: slug, - }, - }, - }); + async changeNickname(userId: string, nickname: string): Promise { + const { conflict } = await this.checkService.checkNameConflict(nickname); - if (duplicatedWorkspaceList.length) { - slug += `-${duplicatedWorkspaceList.length + 1}`; + if (conflict) { + throw new ConflictException(); } - const workspace = await this.prismaService.workspace.create({ + await this.prismaService.user.update({ + where: { + id: userId, + }, data: { - title, - slug, + nickname, }, }); - await this.prismaService.userWorkspace.create({ - data: { - userId: user.id, - workspaceId: workspace.id, - role: WorkspaceRoleConstants.OWNER, + const userWorkspaceList = await this.prismaService.userWorkspace.findMany({ + select: { + id: true, + }, + where: { + userId, }, }); - return user; + const slug = slugify(nickname, { lower: true }); + + if (!userWorkspaceList.length) { + const { id: workspaceId } = await this.prismaService.workspace.create({ + select: { + id: true, + }, + data: { + title: nickname, + slug, + }, + }); + + await this.prismaService.userWorkspace.create({ + data: { + workspaceId, + userId, + role: WorkspaceRoleConstants.OWNER, + }, + }); + } } } diff --git a/backend/src/workspace-documents/types/find-workspace-document-response.type.ts b/backend/src/workspace-documents/types/find-workspace-document-response.type.ts new file mode 100644 index 00000000..4b13d4bf --- /dev/null +++ b/backend/src/workspace-documents/types/find-workspace-document-response.type.ts @@ -0,0 +1,3 @@ +import { WorkspaceDocumentDomain } from "./workspace-document-domain.type"; + +export class FindWorkspaceDocumentResponse extends WorkspaceDocumentDomain {} diff --git a/backend/src/workspace-documents/workspace-documents.controller.ts b/backend/src/workspace-documents/workspace-documents.controller.ts index c33608e0..9ab4fd1f 100644 --- a/backend/src/workspace-documents/workspace-documents.controller.ts +++ b/backend/src/workspace-documents/workspace-documents.controller.ts @@ -28,6 +28,7 @@ import { HttpExceptionResponse } from "src/utils/types/http-exception-response.t import { FindWorkspaceDocumentsResponse } from "./types/find-workspace-documents-response.type"; import { CreateWorkspaceDocumentShareTokenResponse } from "./types/create-workspace-document-share-token-response.type"; import { CreateWorkspaceDocumentShareTokenDto } from "./dto/create-workspace-document-share-token.dto"; +import { FindWorkspaceDocumentResponse } from "./types/find-workspace-document-response.type"; @ApiTags("Workspace.Documents") @ApiBearerAuth() @@ -67,6 +68,25 @@ export class WorkspaceDocumentsController { return this.workspaceDocumentsService.findMany(req.user.id, workspaceId, pageSize, cursor); } + @Get(":document_id") + @ApiOperation({ + summary: "Retrieve a Document in the Workspace", + description: "If the user has the access permissions, return a document.", + }) + @ApiFoundResponse({ type: FindWorkspaceDocumentResponse }) + @ApiNotFoundResponse({ + type: HttpExceptionResponse, + description: + "The workspace or document does not exist, or the user lacks the appropriate permissions.", + }) + async findOne( + @Req() req: AuthroizedRequest, + @Param("workspace_id") workspaceId: string, + @Param("document_id") documentId: string + ): Promise { + return this.workspaceDocumentsService.findOne(req.user.id, workspaceId, documentId); + } + @Post() @ApiOperation({ summary: "Create a Document in a Workspace", diff --git a/backend/src/workspace-documents/workspace-documents.service.ts b/backend/src/workspace-documents/workspace-documents.service.ts index 5e125db6..4e8814c9 100644 --- a/backend/src/workspace-documents/workspace-documents.service.ts +++ b/backend/src/workspace-documents/workspace-documents.service.ts @@ -4,7 +4,6 @@ import { PrismaService } from "src/db/prisma.service"; import { FindWorkspaceDocumentsResponse } from "./types/find-workspace-documents-response.type"; import { CreateWorkspaceDocumentShareTokenResponse } from "./types/create-workspace-document-share-token-response.type"; import { ShareRole } from "src/utils/types/share-role.type"; -import slugify from "slugify"; import { generateRandomKey } from "src/utils/functions/random-string"; @Injectable() @@ -23,24 +22,9 @@ export class WorkspaceDocumentsService { throw new NotFoundException(); } - let slug = slugify(title); - - const duplicatedDocumentList = await this.prismaService.document.findMany({ - where: { - slug: { - startsWith: slug, - }, - }, - }); - - if (duplicatedDocumentList.length) { - slug += `-${duplicatedDocumentList.length + 1}`; - } - return this.prismaService.document.create({ data: { title, - slug, workspaceId, yorkieDocumentId: Math.random().toString(36).substring(7), }, @@ -87,6 +71,25 @@ export class WorkspaceDocumentsService { }; } + async findOne(userId: string, workspaceId: string, documentId: string) { + try { + await this.prismaService.userWorkspace.findFirstOrThrow({ + where: { + userId, + workspaceId, + }, + }); + + return this.prismaService.document.findUniqueOrThrow({ + where: { + id: documentId, + }, + }); + } catch (e) { + throw new NotFoundException(); + } + } + async createSharingToken( userId: string, workspaceId: string, diff --git a/backend/src/workspaces/workspaces.module.ts b/backend/src/workspaces/workspaces.module.ts index 99ef2608..e6548035 100644 --- a/backend/src/workspaces/workspaces.module.ts +++ b/backend/src/workspaces/workspaces.module.ts @@ -2,10 +2,11 @@ import { Module } from "@nestjs/common"; import { WorkspacesController } from "./workspaces.controller"; import { WorkspacesService } from "./workspaces.service"; import { PrismaService } from "src/db/prisma.service"; +import { CheckService } from "src/check/check.service"; @Module({ imports: [], controllers: [WorkspacesController], - providers: [WorkspacesService, PrismaService], + providers: [WorkspacesService, PrismaService, CheckService], }) export class WorkspacesModule {} diff --git a/backend/src/workspaces/workspaces.service.ts b/backend/src/workspaces/workspaces.service.ts index ff379b7a..978f66d0 100644 --- a/backend/src/workspaces/workspaces.service.ts +++ b/backend/src/workspaces/workspaces.service.ts @@ -1,4 +1,9 @@ -import { Injectable, NotFoundException, UnauthorizedException } from "@nestjs/common"; +import { + ConflictException, + Injectable, + NotFoundException, + UnauthorizedException, +} from "@nestjs/common"; import { Prisma, Workspace } from "@prisma/client"; import { PrismaService } from "src/db/prisma.service"; import { FindWorkspacesResponse } from "./types/find-workspaces-response.type"; @@ -7,30 +12,26 @@ import { WorkspaceRoleConstants } from "src/utils/constants/auth-role"; import slugify from "slugify"; import { generateRandomKey } from "src/utils/functions/random-string"; import * as moment from "moment"; +import { CheckService } from "src/check/check.service"; @Injectable() export class WorkspacesService { - constructor(private prismaService: PrismaService) {} + constructor( + private prismaService: PrismaService, + private checkService: CheckService + ) {} async create(userId: string, title: string): Promise { - let slug = slugify(title); + const { conflict } = await this.checkService.checkNameConflict(title); - const duplicatedWorkspaceList = await this.prismaService.workspace.findMany({ - where: { - slug: { - startsWith: slug, - }, - }, - }); - - if (duplicatedWorkspaceList.length) { - slug += `-${duplicatedWorkspaceList.length + 1}`; + if (conflict) { + throw new ConflictException(); } const workspace = await this.prismaService.workspace.create({ data: { title, - slug, + slug: slugify(title, { lower: true }), }, }); diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 95f5641c..8a022302 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -1,21 +1,21 @@ { - "compilerOptions": { - "module": "commonjs", - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "target": "ES2021", - "sourceMap": true, - "outDir": "./dist", - "baseUrl": "./", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": false, - "noImplicitAny": false, - "strictBindCallApply": false, - "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false - } + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": false, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + }, } diff --git a/frontend/src/components/cards/DocumentCard.tsx b/frontend/src/components/cards/DocumentCard.tsx index 4416ebeb..f857b884 100644 --- a/frontend/src/components/cards/DocumentCard.tsx +++ b/frontend/src/components/cards/DocumentCard.tsx @@ -2,7 +2,7 @@ import moment from "moment"; import { Card, CardActionArea, CardContent, Stack, Typography } from "@mui/material"; import AccessTimeIcon from "@mui/icons-material/AccessTime"; import { Document } from "../../hooks/api/types/document.d"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; interface DocumentCardProps { document: Document; @@ -11,9 +11,10 @@ interface DocumentCardProps { function DocumentCard(props: DocumentCardProps) { const { document } = props; const navigate = useNavigate(); + const params = useParams(); const handleToDocument = () => { - navigate(`/document/${document.slug}`); + navigate(`/${params.workspaceSlug}/${document.id}`); }; return ( diff --git a/frontend/src/components/common/GuestRoute.tsx b/frontend/src/components/common/GuestRoute.tsx index c83d70f6..f3504a36 100644 --- a/frontend/src/components/common/GuestRoute.tsx +++ b/frontend/src/components/common/GuestRoute.tsx @@ -17,7 +17,7 @@ const GuestRoute = (props: RejectLoggedInRouteProps) => { if (isLoggedIn) { return ( diff --git a/frontend/src/components/drawers/WorkspaceDrawer.tsx b/frontend/src/components/drawers/WorkspaceDrawer.tsx index 94f259c1..941d7362 100644 --- a/frontend/src/components/drawers/WorkspaceDrawer.tsx +++ b/frontend/src/components/drawers/WorkspaceDrawer.tsx @@ -17,7 +17,7 @@ import { useSelector } from "react-redux"; import { selectUser } from "../../store/userSlice"; import { MouseEventHandler, useState } from "react"; import ProfilePopover from "../popovers/ProfilePopover"; -import { useParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import { useGetWorkspaceQuery } from "../../hooks/api/workspace"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; @@ -32,6 +32,7 @@ import MemberModal from "../modals/MemberModal"; const DRAWER_WIDTH = 240; function WorkspaceDrawer() { + const navigate = useNavigate(); const params = useParams(); const userStore = useSelector(selectUser); const { data: workspace } = useGetWorkspaceQuery(params.workspaceSlug); @@ -60,7 +61,9 @@ function WorkspaceDrawer() { }; const handleCreateWorkspace = async (data: { title: string }) => { - await createDocument(data); + const document = await createDocument(data); + + navigate(document.id); }; const handleCreateWorkspaceModalOpen = () => { @@ -143,7 +146,7 @@ function WorkspaceDrawer() { - {userStore.data?.nickname.charAt(0)} + {userStore.data?.nickname?.charAt(0)} diff --git a/frontend/src/components/editor/DocumentView.tsx b/frontend/src/components/editor/DocumentView.tsx new file mode 100644 index 00000000..8f3c6d01 --- /dev/null +++ b/frontend/src/components/editor/DocumentView.tsx @@ -0,0 +1,71 @@ +import { useSelector } from "react-redux"; +import { selectEditor } from "../../store/editorSlice"; +import Resizable from "react-resizable-layout"; +import { useWindowWidth } from "@react-hook/window-size"; +import Editor from "./Editor"; +import { Backdrop, Box, CircularProgress, Paper } from "@mui/material"; +import Preview from "./Preview"; + +function DocumentView() { + const editorStore = useSelector(selectEditor); + const windowWidth = useWindowWidth(); + + if (!editorStore.doc || !editorStore.client) + return ( + + + + ); + + return ( + <> + {/* For Markdown Preview Theme */} +
+ {editorStore.mode === "both" && ( + + {({ position: width, separatorProps }) => ( +
+
+ +
+ +
+ + + +
+
+ )} +
+ )} + {editorStore.mode === "read" && ( + + + + )} + {editorStore.mode === "edit" && } + + ); +} + +export default DocumentView; diff --git a/frontend/src/components/headers/EditorHeader.tsx b/frontend/src/components/headers/DocumentHeader.tsx similarity index 79% rename from frontend/src/components/headers/EditorHeader.tsx rename to frontend/src/components/headers/DocumentHeader.tsx index 7827ade9..1cd81722 100644 --- a/frontend/src/components/headers/EditorHeader.tsx +++ b/frontend/src/components/headers/DocumentHeader.tsx @@ -2,6 +2,7 @@ import { AppBar, Avatar, AvatarGroup, + IconButton, Paper, Stack, ToggleButton, @@ -20,9 +21,12 @@ import { useEffect } from "react"; import { useList } from "react-use"; import { ActorID } from "yorkie-js-sdk"; import { YorkieCodeMirrorPresenceType } from "../../utils/yorkie/yorkieSync"; +import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew"; +import { useNavigate } from "react-router-dom"; -function EditorHeader() { +function DocumentHeader() { const dispatch = useDispatch(); + const navigate = useNavigate(); const editorState = useSelector(selectEditor); const [ presenceList, @@ -76,11 +80,20 @@ function EditorHeader() { dispatch(setMode(newMode)); }; + const handleToPrevious = () => { + navigate(-1); + }; + return ( - + + + + + + {editorState.shareRole !== "READ" && ( {presenceList?.map((presence) => ( - - {presence.presence.name[0]} - + + + {presence.presence.name[0]} + + ))} {!editorState.shareRole && } @@ -129,4 +143,4 @@ function EditorHeader() { ); } -export default EditorHeader; +export default DocumentHeader; diff --git a/frontend/src/components/headers/MainHeader.tsx b/frontend/src/components/headers/MainHeader.tsx index 091c2e26..3e864568 100644 --- a/frontend/src/components/headers/MainHeader.tsx +++ b/frontend/src/components/headers/MainHeader.tsx @@ -1,8 +1,15 @@ -import { AppBar, Stack, Toolbar } from "@mui/material"; +import { AppBar, Button, Stack, Toolbar } from "@mui/material"; import ThemeButton from "../common/ThemeButton"; import CodePairIcon from "../icons/CodePairIcon"; +import { useNavigate } from "react-router-dom"; function MainHeader() { + const navigate = useNavigate(); + + const handleMoveToLogin = () => { + navigate("/login"); + }; + return ( @@ -13,7 +20,12 @@ function MainHeader() { alignItems="center" > - + + + + diff --git a/frontend/src/components/layouts/EditorLayout.tsx b/frontend/src/components/layouts/DocumentLayout.tsx similarity index 54% rename from frontend/src/components/layouts/EditorLayout.tsx rename to frontend/src/components/layouts/DocumentLayout.tsx index c1346bd8..1eb514b0 100644 --- a/frontend/src/components/layouts/EditorLayout.tsx +++ b/frontend/src/components/layouts/DocumentLayout.tsx @@ -1,14 +1,14 @@ import { Box } from "@mui/material"; import { Outlet } from "react-router-dom"; -import EditorHeader from "../headers/EditorHeader"; +import DocumentHeader from "../headers/DocumentHeader"; -function EditorLayout() { +function DocumentLayout() { return ( - + ); } -export default EditorLayout; +export default DocumentLayout; diff --git a/frontend/src/components/layouts/MainLayout.tsx b/frontend/src/components/layouts/MainLayout.tsx index 41af4695..d147f6b6 100644 --- a/frontend/src/components/layouts/MainLayout.tsx +++ b/frontend/src/components/layouts/MainLayout.tsx @@ -1,9 +1,11 @@ import { Stack } from "@mui/material"; import { Outlet } from "react-router-dom"; +import MainHeader from "../headers/MainHeader"; function MainLayout() { return ( - + + ); diff --git a/frontend/src/components/modals/ChangeNicknameModal.tsx b/frontend/src/components/modals/ChangeNicknameModal.tsx new file mode 100644 index 00000000..11600c9a --- /dev/null +++ b/frontend/src/components/modals/ChangeNicknameModal.tsx @@ -0,0 +1,88 @@ +import { Button, FormControl, Modal, ModalProps, Paper, Stack, Typography } from "@mui/material"; +import { FormContainer, TextFieldElement } from "react-hook-form-mui"; +import { useCheckNameConflictQuery } from "../../hooks/api/check"; +import { useMemo, useState } from "react"; +import { useDebounce } from "react-use"; +import { useUpdateUserNicknmaeMutation } from "../../hooks/api/user"; + +interface ChangeNicknameModalProps extends Omit {} + +function ChangeNicknameModal(props: ChangeNicknameModalProps) { + const [nickname, setNickname] = useState(""); + const [debouncedNickname, setDebouncedNickname] = useState(""); + const { data: conflictResult } = useCheckNameConflictQuery(debouncedNickname); + const { mutateAsync: updateUserNickname } = useUpdateUserNicknmaeMutation(); + const errorMessage = useMemo(() => { + if (conflictResult?.conflict) { + return "Already Exists"; + } + return null; + }, [conflictResult?.conflict]); + + useDebounce( + () => { + setDebouncedNickname(nickname); + }, + 500, + [nickname] + ); + + const handleNicknameChange = (e: React.ChangeEvent) => { + setNickname(e.target.value); + }; + + const handleUpdateUserNickname = async (data: { nickname: string }) => { + await updateUserNickname(data); + }; + + return ( + + + + Create Your Nickname + + + + + + + + + + + + ); +} + +export default ChangeNicknameModal; diff --git a/frontend/src/components/modals/CreateModal.tsx b/frontend/src/components/modals/CreateModal.tsx index a0ab2913..eb37068b 100644 --- a/frontend/src/components/modals/CreateModal.tsx +++ b/frontend/src/components/modals/CreateModal.tsx @@ -10,6 +10,9 @@ import { } from "@mui/material"; import { FormContainer, TextFieldElement } from "react-hook-form-mui"; import CloseIcon from "@mui/icons-material/Close"; +import { useMemo, useState } from "react"; +import { useCheckNameConflictQuery } from "../../hooks/api/check"; +import { useDebounce } from "react-use"; interface CreateRequest { title: string; @@ -18,10 +21,28 @@ interface CreateRequest { interface CreateModalProps extends Omit { title: string; onSuccess: (data: CreateRequest) => Promise; + enableConflictCheck?: boolean; } function CreateModal(props: CreateModalProps) { - const { title, onSuccess, ...modalProps } = props; + const { title, onSuccess, enableConflictCheck, ...modalProps } = props; + const [nickname, setNickname] = useState(""); + const [debouncedNickname, setDebouncedNickname] = useState(""); + const { data: conflictResult } = useCheckNameConflictQuery(debouncedNickname); + const errorMessage = useMemo(() => { + if (conflictResult?.conflict) { + return "Already Exists"; + } + return null; + }, [conflictResult?.conflict]); + + useDebounce( + () => { + setDebouncedNickname(nickname); + }, + 500, + [nickname] + ); const handleCloseModal = () => { modalProps?.onClose?.(new Event("Close Modal"), "escapeKeyDown"); @@ -32,6 +53,11 @@ function CreateModal(props: CreateModalProps) { handleCloseModal(); }; + const handleNicknameChange = (e: React.ChangeEvent) => { + if (!enableConflictCheck) return; + setNickname(e.target.value); + }; + return ( - diff --git a/frontend/src/components/modals/ShareModal.tsx b/frontend/src/components/modals/ShareModal.tsx index 87afda76..5b78ed26 100644 --- a/frontend/src/components/modals/ShareModal.tsx +++ b/frontend/src/components/modals/ShareModal.tsx @@ -14,25 +14,25 @@ import { invitationExpiredStringList } from "../../utils/expire"; import { useState } from "react"; import moment, { unitOfTime } from "moment"; import { useParams } from "react-router"; -import { useGetDocumentQuery } from "../../hooks/api/document"; -import { useCreateWorkspaceSharingTokenMutation } from "../../hooks/api/workspaceDocument"; +import { + useCreateWorkspaceSharingTokenMutation, + useGetDocumentQuery, +} from "../../hooks/api/workspaceDocument"; import { ShareRole } from "../../utils/share"; import clipboard from "clipboardy"; import { useSnackbar } from "notistack"; import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import CloseIcon from "@mui/icons-material/Close"; -import { useSearchParams } from "react-router-dom"; +import { useGetWorkspaceQuery } from "../../hooks/api/workspace"; interface ShareModalProps extends Omit {} function ShareModal(props: ShareModalProps) { const { ...modalProps } = props; const params = useParams(); - const [searchParams] = useSearchParams(); const [shareUrl, setShareUrl] = useState(null); - const { data: document } = useGetDocumentQuery( - searchParams.get("token") ? null : params.documentSlug - ); + const { data: workspace } = useGetWorkspaceQuery(params.workspaceSlug); + const { data: document } = useGetDocumentQuery(workspace?.id, params.documentId); const { mutateAsync: createWorkspaceSharingToken } = useCreateWorkspaceSharingTokenMutation( document?.workspaceId || "", document?.id || "" @@ -57,7 +57,7 @@ function ShareModal(props: ShareModalProps) { }); setShareUrl( - `${window.location.origin}/document/${params.documentSlug}?token=${sharingToken}` + `${window.location.origin}/${params.workspaceSlug}/${params.documentId}/share?token=${sharingToken}` ); }; @@ -135,7 +135,9 @@ function ShareModal(props: ShareModalProps) { {Boolean(shareUrl) && ( - {shareUrl} + + {shareUrl} + diff --git a/frontend/src/components/popovers/WorkspaceListPopover.tsx b/frontend/src/components/popovers/WorkspaceListPopover.tsx index 631ca23b..d6614dc8 100644 --- a/frontend/src/components/popovers/WorkspaceListPopover.tsx +++ b/frontend/src/components/popovers/WorkspaceListPopover.tsx @@ -39,7 +39,7 @@ function WorkspaceListPopover(props: WorkspaceListPopoverProps) { const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false); const moveToWorkspace = (slug: string) => { - navigate(`/workspace/${slug}`); + navigate(`/${slug}`); }; const handleMoveToSelectedWorkspace = (workspaceSlug: string) => { @@ -129,6 +129,7 @@ function WorkspaceListPopover(props: WorkspaceListPopoverProps) { title="Workspace" onClose={handleCreateWorkspaceModalOpen} onSuccess={handleCreateWorkspace} + enableConflictCheck /> ); diff --git a/frontend/src/hooks/api/check.ts b/frontend/src/hooks/api/check.ts new file mode 100644 index 00000000..908bf834 --- /dev/null +++ b/frontend/src/hooks/api/check.ts @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; +import axios from "axios"; +import { CheckNameConflictRequest, CheckNameConflictResponse } from "./types/check"; + +export const generateCheckNameConflictQueryKey = (name: string) => { + return ["check", "name-conflict", name]; +}; + +export const useCheckNameConflictQuery = (name: string | null) => { + const query = useQuery({ + queryKey: generateCheckNameConflictQueryKey(name || ""), + enabled: Boolean(name), + queryFn: async () => { + const res = await axios.post("/check/name-conflict", { + name, + } as CheckNameConflictRequest); + return res.data; + }, + }); + + return query; +}; diff --git a/frontend/src/hooks/api/document.ts b/frontend/src/hooks/api/document.ts index 1a3512d4..a6e88ab7 100644 --- a/frontend/src/hooks/api/document.ts +++ b/frontend/src/hooks/api/document.ts @@ -1,34 +1,14 @@ import { useQuery } from "@tanstack/react-query"; import axios from "axios"; -import { GetDocumentBySharingTokenResponse, GetDocumentResponse } from "./types/document"; - -export const generateGetDocumentQueryKey = (documentSlug: string) => { - return ["documents", documentSlug]; -}; +import { GetDocumentBySharingTokenResponse } from "./types/document"; export const generateGetDocumentBySharingTokenQueryKey = (sharingToken: string) => { return ["documents", "share", sharingToken]; }; -export const useGetDocumentQuery = (documentSlug?: string | null) => { - const query = useQuery({ - queryKey: generateGetDocumentQueryKey(documentSlug || ""), - enabled: Boolean(documentSlug), - queryFn: async () => { - const res = await axios.get(`/documents/${documentSlug}`); - return res.data; - }, - meta: { - errorMessage: "This is a non-existent or unauthorized document.", - }, - }); - - return query; -}; - export const useGetDocumentBySharingTokenQuery = (sharingToken?: string | null) => { const query = useQuery({ - queryKey: generateGetDocumentQueryKey(sharingToken || ""), + queryKey: generateGetDocumentBySharingTokenQueryKey(sharingToken || ""), enabled: Boolean(sharingToken), queryFn: async () => { const res = await axios.get("/documents/share", { diff --git a/frontend/src/hooks/api/types/check.d.ts b/frontend/src/hooks/api/types/check.d.ts new file mode 100644 index 00000000..aedaa54a --- /dev/null +++ b/frontend/src/hooks/api/types/check.d.ts @@ -0,0 +1,7 @@ +export class CheckNameConflictRequest { + name: string; +} + +export class CheckNameConflictResponse { + conflict: boolean; +} diff --git a/frontend/src/hooks/api/types/document.d.ts b/frontend/src/hooks/api/types/document.d.ts index e7a594ba..3e35f683 100644 --- a/frontend/src/hooks/api/types/document.d.ts +++ b/frontend/src/hooks/api/types/document.d.ts @@ -5,14 +5,11 @@ export class Document { workspaceId: string; yorkieDocumentId: string; title: string; - slug: string; content?: string; createdAt: Date; updatedAt: Date; } -export class GetDocumentResponse extends Document {} - export class GetDocumentBySharingTokenResponse extends Document { role: ShareRole; } diff --git a/frontend/src/hooks/api/types/user.d.ts b/frontend/src/hooks/api/types/user.d.ts index 17fe7cb6..cc647353 100644 --- a/frontend/src/hooks/api/types/user.d.ts +++ b/frontend/src/hooks/api/types/user.d.ts @@ -1,9 +1,13 @@ -export interface User { +export class User { id: string; - nickname: string; - lastWorkspaceSlug: string; + nickname?: string | null; + lastWorkspaceSlug?: string | null; createdAt: Date; updatedAt: Date; } export class GetUserResponse extends User {} + +export class UpdateUserRequest { + nickname: string; +} diff --git a/frontend/src/hooks/api/types/workspaceDocument.d.ts b/frontend/src/hooks/api/types/workspaceDocument.d.ts index 05ae6db4..aea1808e 100644 --- a/frontend/src/hooks/api/types/workspaceDocument.d.ts +++ b/frontend/src/hooks/api/types/workspaceDocument.d.ts @@ -5,6 +5,8 @@ export class GetWorkspaceDocumentListResponse { documents: Array; } +export class GetWorkspaceDocumentResponse extends Document {} + export class CreateDocumentRequest { title: string; } diff --git a/frontend/src/hooks/api/user.ts b/frontend/src/hooks/api/user.ts index 902f0f5f..c5011970 100644 --- a/frontend/src/hooks/api/user.ts +++ b/frontend/src/hooks/api/user.ts @@ -1,8 +1,8 @@ import { useDispatch, useSelector } from "react-redux"; import { selectAuth, setAccessToken } from "../../store/authSlice"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import axios from "axios"; -import { GetUserResponse } from "./types/user"; +import { GetUserResponse, UpdateUserRequest } from "./types/user"; import { useEffect } from "react"; import { User, setUserData } from "../../store/userSlice"; @@ -39,3 +39,21 @@ export const useGetUserQuery = () => { return query; }; + +export const useUpdateUserNicknmaeMutation = () => { + const authStore = useSelector(selectAuth); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: UpdateUserRequest) => { + const res = await axios.put("/users", data); + + return res.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: generateGetUserQueryKey(authStore.accessToken || ""), + }); + }, + }); +}; diff --git a/frontend/src/hooks/api/workspace.ts b/frontend/src/hooks/api/workspace.ts index 1279f534..3f77dfe0 100644 --- a/frontend/src/hooks/api/workspace.ts +++ b/frontend/src/hooks/api/workspace.ts @@ -1,4 +1,4 @@ -import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query"; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import axios from "axios"; import { CreateWorkspaceInviteTokenRequest, @@ -55,12 +55,19 @@ export const useGetWorkspaceListQuery = () => { }; export const useCreateWorkspaceMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ mutationFn: async (data: CreateWorkspaceRequest) => { const res = await axios.post("/workspaces", data); return res.data; }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: generateGetWorkspaceListQueryKey(), + }); + }, }); }; diff --git a/frontend/src/hooks/api/workspaceDocument.ts b/frontend/src/hooks/api/workspaceDocument.ts index ad4da6fb..614617af 100644 --- a/frontend/src/hooks/api/workspaceDocument.ts +++ b/frontend/src/hooks/api/workspaceDocument.ts @@ -1,10 +1,11 @@ -import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import axios from "axios"; import { CreateDocumentRequest, CreateDocumentResponse, CreateDocumentShareTokenRequest, CreateDocumentShareTokenResponse, + GetWorkspaceDocumentResponse, GetWorkspaceDocumentListResponse, } from "./types/workspaceDocument"; @@ -12,6 +13,10 @@ export const generateGetWorkspaceDocumentListQueryKey = (workspaceId: string) => return ["workspaces", workspaceId, "documents"]; }; +export const generateGetDocumentQueryKey = (workspaceId: string, documentId: string) => { + return ["workpsaces", workspaceId, "documents", documentId]; +}; + export const useGetWorkspaceDocumentListQuery = (workspaceId?: string) => { const query = useInfiniteQuery({ queryKey: generateGetWorkspaceDocumentListQueryKey(workspaceId || ""), @@ -36,6 +41,24 @@ export const useGetWorkspaceDocumentListQuery = (workspaceId?: string) => { return query; }; +export const useGetDocumentQuery = (workspaceId?: string | null, documentId?: string | null) => { + const query = useQuery({ + queryKey: generateGetDocumentQueryKey(workspaceId || "", documentId || ""), + enabled: Boolean(workspaceId && documentId), + queryFn: async () => { + const res = await axios.get( + `/workspaces/${workspaceId}/documents/${documentId}` + ); + return res.data; + }, + meta: { + errorMessage: "This is a non-existent or unauthorized document.", + }, + }); + + return query; +}; + export const useCreateDocumentMutation = (workspaceId: string) => { const queryClient = useQueryClient(); diff --git a/frontend/src/hooks/useYorkieDocument.ts b/frontend/src/hooks/useYorkieDocument.ts new file mode 100644 index 00000000..a5717b06 --- /dev/null +++ b/frontend/src/hooks/useYorkieDocument.ts @@ -0,0 +1,51 @@ +import { useCallback, useEffect, useState } from "react"; +import * as yorkie from "yorkie-js-sdk"; +import { YorkieCodeMirrorDocType, YorkieCodeMirrorPresenceType } from "../utils/yorkie/yorkieSync"; +import Color from "color"; +import randomColor from "randomcolor"; + +export const useYorkieDocument = ( + yorkieDocuentId?: string | null, + presenceName?: string | null +) => { + const [client, setClient] = useState(null); + const [doc, setDoc] = useState | null>(null); + const cleanUpYorkieDocument = useCallback(async () => { + if (!client || !doc) return; + + await client?.detach(doc); + await client?.deactivate(); + }, [client, doc]); + + useEffect(() => { + if (!yorkieDocuentId || !presenceName || doc || client) return; + + const initializeYorkie = async () => { + const newClient = new yorkie.Client(import.meta.env.VITE_YORKIE_API_ADDR, { + apiKey: import.meta.env.VITE_YORKIE_API_KEY, + }); + await newClient.activate(); + + const newDoc = new yorkie.Document(yorkieDocuentId as string); + + await newClient.attach(newDoc, { + initialPresence: { + name: presenceName, + color: Color(randomColor()).fade(0.15).toString(), + selection: null, + }, + }); + + setClient(newClient); + setDoc( + newDoc as yorkie.Document + ); + }; + initializeYorkie(); + }, [presenceName, yorkieDocuentId, doc, client]); + + return { client, doc, cleanUpYorkieDocument }; +}; diff --git a/frontend/src/pages/Index.tsx b/frontend/src/pages/Index.tsx index d6f257a7..a46cf09b 100644 --- a/frontend/src/pages/Index.tsx +++ b/frontend/src/pages/Index.tsx @@ -1,60 +1,7 @@ -import { Box, Container, Divider, Grid, Paper, Stack, Typography } from "@mui/material"; -import CodePairIcon from "../components/icons/CodePairIcon"; -import { GithubLoginButton } from "react-social-login-buttons"; - -const socialLoginList = [ - { - SocailLoginComponent: GithubLoginButton, - provider: "github", - }, -]; +import { Box } from "@mui/material"; function Index() { - const handleLogin = (provider: string) => { - window.location.href = `${import.meta.env.VITE_API_ADDR}/auth/login/${provider}`; - }; - - return ( - - - - - - - - Login - - - Real-time markdown editor for interviews, meetings and more... - - - - - - - - - - Login with - - - - - - - {socialLoginList.map(({ SocailLoginComponent, provider }) => ( - handleLogin(provider)} - /> - ))} - - - - - - ); + return ; } export default Index; diff --git a/frontend/src/pages/document/Index.tsx b/frontend/src/pages/document/Index.tsx deleted file mode 100644 index 7d7d11bc..00000000 --- a/frontend/src/pages/document/Index.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { useContext, useEffect } from "react"; -import Editor from "../../components/editor/Editor"; -import * as yorkie from "yorkie-js-sdk"; -import { selectEditor, setClient, setDoc, setShareRole } from "../../store/editorSlice"; -import { useDispatch, useSelector } from "react-redux"; -import { - YorkieCodeMirrorDocType, - YorkieCodeMirrorPresenceType, -} from "../../utils/yorkie/yorkieSync"; -import randomColor from "randomcolor"; -import Color from "color"; -import { Box, Paper } from "@mui/material"; -import Resizable from "react-resizable-layout"; -import { useWindowWidth } from "@react-hook/window-size"; -import Preview from "../../components/editor/Preview"; -import { Navigate, useParams, useSearchParams } from "react-router-dom"; -import { useGetDocumentBySharingTokenQuery, useGetDocumentQuery } from "../../hooks/api/document"; -import { AuthContext } from "../../contexts/AuthContext"; -import { selectUser } from "../../store/userSlice"; - -function EditorIndex() { - const dispatch = useDispatch(); - const params = useParams(); - const userStore = useSelector(selectUser); - const { isLoggedIn } = useContext(AuthContext); - const [searchParams] = useSearchParams(); - const windowWidth = useWindowWidth(); - const editorStore = useSelector(selectEditor); - const { data: document, isError: isDocumentError } = useGetDocumentQuery( - isLoggedIn ? params.documentSlug : null - ); - const { data: sharedDocument, isError: isSharedDocumentError } = - useGetDocumentBySharingTokenQuery(searchParams.get("token")); - - useEffect(() => { - let client: yorkie.Client; - let doc: yorkie.Document; - const yorkieDocuentId = document?.yorkieDocumentId || sharedDocument?.yorkieDocumentId; - const name = searchParams.get("token") ? "Anonymous" : userStore.data?.nickname; - - if (!yorkieDocuentId || !name) return; - - const initializeYorkie = async () => { - client = new yorkie.Client(import.meta.env.VITE_YORKIE_API_ADDR, { - apiKey: import.meta.env.VITE_YORKIE_API_KEY, - }); - await client.activate(); - - doc = new yorkie.Document(yorkieDocuentId as string); - - await client.attach(doc, { - initialPresence: { - name, - color: Color(randomColor()).fade(0.15).toString(), - selection: null, - }, - }); - dispatch(setDoc(doc)); - dispatch(setClient(client)); - }; - initializeYorkie(); - - return () => { - const cleanUp = async () => { - await client?.detach(doc); - await client?.deactivate(); - dispatch(setDoc(null)); - dispatch(setClient(null)); - }; - - cleanUp(); - }; - }, [ - dispatch, - document?.yorkieDocumentId, - sharedDocument?.yorkieDocumentId, - userStore.data?.nickname, - searchParams, - ]); - - useEffect(() => { - if (!sharedDocument) return; - - dispatch(setShareRole(sharedDocument.role)); - - return () => { - setShareRole(null); - }; - }, [dispatch, sharedDocument, sharedDocument?.role]); - - if (isDocumentError || isSharedDocumentError) - return ; - - return ( - - {/* For Markdown Preview Theme */} -
- {editorStore.mode === "both" && ( - - {({ position: width, separatorProps }) => ( -
-
- -
- -
- - - -
-
- )} -
- )} - {editorStore.mode === "read" && ( - - - - )} - {editorStore.mode === "edit" && } - - ); -} - -export default EditorIndex; diff --git a/frontend/src/pages/login/Index.tsx b/frontend/src/pages/login/Index.tsx new file mode 100644 index 00000000..b7d4eed9 --- /dev/null +++ b/frontend/src/pages/login/Index.tsx @@ -0,0 +1,60 @@ +import { Box, Container, Divider, Grid, Paper, Stack, Typography } from "@mui/material"; +import CodePairIcon from "../../components/icons/CodePairIcon"; +import { GithubLoginButton } from "react-social-login-buttons"; + +const socialLoginList = [ + { + SocailLoginComponent: GithubLoginButton, + provider: "github", + }, +]; + +function LoginIndex() { + const handleLogin = (provider: string) => { + window.location.href = `${import.meta.env.VITE_API_ADDR}/auth/login/${provider}`; + }; + + return ( + + + + + + + + Login + + + Real-time markdown editor for interviews, meetings and more... + + + + + + + + + + Login with + + + + + + + {socialLoginList.map(({ SocailLoginComponent, provider }) => ( + handleLogin(provider)} + /> + ))} + + + + + + ); +} + +export default LoginIndex; diff --git a/frontend/src/pages/workspace/document/Index.tsx b/frontend/src/pages/workspace/document/Index.tsx new file mode 100644 index 00000000..b4aaa2b0 --- /dev/null +++ b/frontend/src/pages/workspace/document/Index.tsx @@ -0,0 +1,43 @@ +import { useEffect } from "react"; +import { setClient, setDoc } from "../../../store/editorSlice"; +import { useDispatch, useSelector } from "react-redux"; +import { Box } from "@mui/material"; +import { useParams } from "react-router-dom"; +import { selectUser } from "../../../store/userSlice"; +import { useGetDocumentQuery } from "../../../hooks/api/workspaceDocument"; +import { useGetWorkspaceQuery } from "../../../hooks/api/workspace"; +import DocumentView from "../../../components/editor/DocumentView"; +import { useYorkieDocument } from "../../../hooks/useYorkieDocument"; + +function DocumentIndex() { + const dispatch = useDispatch(); + const params = useParams(); + const userStore = useSelector(selectUser); + const { data: workspace } = useGetWorkspaceQuery(params.workspaceSlug); + const { data: document } = useGetDocumentQuery(workspace?.id, params.documentId); + const { doc, client, cleanUpYorkieDocument } = useYorkieDocument( + document?.yorkieDocumentId, + userStore.data?.nickname + ); + + useEffect(() => { + if (!doc || !client) return; + + dispatch(setDoc(doc)); + dispatch(setClient(client)); + + return () => { + cleanUpYorkieDocument(); + dispatch(setDoc(null)); + dispatch(setClient(null)); + }; + }, [cleanUpYorkieDocument, dispatch, client, doc]); + + return ( + + + + ); +} + +export default DocumentIndex; diff --git a/frontend/src/pages/workspace/document/share/Index.tsx b/frontend/src/pages/workspace/document/share/Index.tsx new file mode 100644 index 00000000..3102c587 --- /dev/null +++ b/frontend/src/pages/workspace/document/share/Index.tsx @@ -0,0 +1,53 @@ +import { Box } from "@mui/material"; +import DocumentView from "../../../../components/editor/DocumentView"; +import { useGetDocumentBySharingTokenQuery } from "../../../../hooks/api/document"; +import { Navigate, useLocation, useSearchParams } from "react-router-dom"; +import { useEffect, useMemo } from "react"; +import { useYorkieDocument } from "../../../../hooks/useYorkieDocument"; +import { useDispatch } from "react-redux"; +import { setClient, setDoc, setMode, setShareRole } from "../../../../store/editorSlice"; + +function DocumentShareIndex() { + const dispatch = useDispatch(); + const location = useLocation(); + const [searchParams] = useSearchParams(); + const shareToken = useMemo(() => searchParams.get("token"), [searchParams]); + const { data: sharedDocument } = useGetDocumentBySharingTokenQuery(shareToken); + const { doc, client, cleanUpYorkieDocument } = useYorkieDocument( + sharedDocument?.yorkieDocumentId, + "Anonymous" + ); + + useEffect(() => { + if (!sharedDocument?.role) return; + + dispatch(setShareRole(sharedDocument.role)); + + if (sharedDocument.role === "READ") { + dispatch(setMode("read")); + } + }, [dispatch, sharedDocument?.role]); + + useEffect(() => { + if (!doc || !client) return; + + dispatch(setDoc(doc)); + dispatch(setClient(client)); + + return () => { + cleanUpYorkieDocument(); + dispatch(setDoc(null)); + dispatch(setClient(null)); + }; + }, [cleanUpYorkieDocument, dispatch, client, doc]); + + if (!shareToken) return ; + + return ( + + + + ); +} + +export default DocumentShareIndex; diff --git a/frontend/src/pages/workspace/join/Index.tsx b/frontend/src/pages/workspace/join/Index.tsx index bc43af69..6ced7840 100644 --- a/frontend/src/pages/workspace/join/Index.tsx +++ b/frontend/src/pages/workspace/join/Index.tsx @@ -12,7 +12,7 @@ function JoinIndex() { if (!params.invitationToken) return; joinWorkspace({ invitationToken: params.invitationToken }).then((data) => { - navigate(`/workspace/${data.slug}`); + navigate(`/${data.slug}`); }); }, [joinWorkspace, navigate, params.invitationToken]); diff --git a/frontend/src/providers/AuthProvider.tsx b/frontend/src/providers/AuthProvider.tsx index ef2fd8ab..1a1be25c 100644 --- a/frontend/src/providers/AuthProvider.tsx +++ b/frontend/src/providers/AuthProvider.tsx @@ -1,6 +1,7 @@ -import { ReactNode } from "react"; +import { ReactNode, useMemo } from "react"; import { AuthContext } from "../contexts/AuthContext"; import { useGetUserQuery } from "../hooks/api/user"; +import ChangeNicknameModal from "../components/modals/ChangeNicknameModal"; interface AuthProviderProps { children?: ReactNode; @@ -8,11 +9,15 @@ interface AuthProviderProps { function AuthProvider(props: AuthProviderProps) { const { children } = props; - const { isSuccess, isLoading } = useGetUserQuery(); + const { data: user, isSuccess, isLoading } = useGetUserQuery(); + const shouldChangeNickname = useMemo( + () => isSuccess && !user.nickname, + [isSuccess, user?.nickname] + ); return ( - {children} + {shouldChangeNickname ? : children} ); } diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index 310f5c99..e256ebf7 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -1,7 +1,6 @@ -import EditorLayout from "./components/layouts/EditorLayout"; -import EditorIndex from "./pages/document/Index"; +import DocumentIndex from "./pages/workspace/document/Index"; import MainLayout from "./components/layouts/MainLayout"; -import Index from "./pages/Index"; +import LoginIndex from "./pages/login/Index"; import CallbackIndex from "./pages/auth/callback/Index"; import WorkspaceLayout from "./components/layouts/WorkspaceLayout"; import GuestRoute from "./components/common/GuestRoute"; @@ -9,21 +8,25 @@ import PrivateRoute from "./components/common/PrivateRoute"; import WorkspaceIndex from "./pages/workspace/Index"; import CodePairError from "./components/common/CodePairError"; import JoinIndex from "./pages/workspace/join/Index"; +import Index from "./pages/Index"; +import DocumentLayout from "./components/layouts/DocumentLayout"; +import DocumentShareIndex from "./pages/workspace/document/share/Index"; interface CodePairRoute { path: string; - accessType: AccessType; + accessType?: AccessType; element: JSX.Element; errorElement?: JSX.Element; children?: { path: string; element: JSX.Element; + accessType?: AccessType; }[]; } const enum AccessType { + PUBLIC, // Everyone can access (Default) PRIVATE, // Authroized user can access only - PUBLIC, // Everyone can access GUEST, // Not authorized user can access only } @@ -37,27 +40,36 @@ const codePairRoutes: Array = [ path: "", element: , }, + { + path: "login", + element: , + }, ], }, { - path: "workspace", + path: ":workspaceSlug", accessType: AccessType.PRIVATE, element: , children: [ { - path: ":workspaceSlug", + path: "", element: , }, ], }, { - path: "document", - accessType: AccessType.PUBLIC, - element: , + path: ":workspaceSlug", + element: , children: [ { - path: ":documentSlug", - element: , + path: ":documentId", + accessType: AccessType.PRIVATE, + element: , + }, + { + path: ":documentId/share", + accessType: AccessType.PUBLIC, + element: , }, ], }, @@ -73,14 +85,24 @@ const codePairRoutes: Array = [ }, ]; -const injectProtectedRoute = (routes: typeof codePairRoutes) => { - return routes.map((route) => { +const injectProtectedRoute = (routes: Array) => { + const injectProtectedComp = (route: CodePairRoute) => { if (route.accessType === AccessType.PRIVATE) { route.element = {route.element}; } else if (route.accessType === AccessType.GUEST) { route.element = {route.element}; } + return route; + }; + + return routes.map((route) => { + route = injectProtectedComp(route); + + if (route?.children) { + route.children = route.children.map((route) => injectProtectedComp(route)); + } + route.errorElement = ; return route; diff --git a/frontend/src/store/userSlice.ts b/frontend/src/store/userSlice.ts index 41ee044d..a38540ed 100644 --- a/frontend/src/store/userSlice.ts +++ b/frontend/src/store/userSlice.ts @@ -4,7 +4,7 @@ import { RootState } from "./store"; export interface User { id: string; - nickname: string; + nickname: string | null; lastWorkspaceSlug: string; updatedAt: Date; createdAt: Date;