diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 64e2ea9b81..b85bbfa094 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -15,6 +15,7 @@ "customizations": { "vscode": { "extensions": [ + "apollographql.vscode-apollo", "bradlc.vscode-tailwindcss", "bruno-api-client.bruno", "csstools.postcss", diff --git a/.github/actions/setup-pnpm/action.yml b/.github/actions/setup-pnpm/action.yml index 016db93faa..2c23ed2758 100644 --- a/.github/actions/setup-pnpm/action.yml +++ b/.github/actions/setup-pnpm/action.yml @@ -10,7 +10,7 @@ inputs: runs: using: 'composite' steps: - - uses: pnpm/action-setup@v2 + - uses: pnpm/action-setup@v3 with: version: latest - uses: actions/setup-node@v4 diff --git a/.github/workflows/cd-prod.yml b/.github/workflows/cd-prod.yml index 960954f964..3009020031 100644 --- a/.github/workflows/cd-prod.yml +++ b/.github/workflows/cd-prod.yml @@ -36,7 +36,7 @@ jobs: file: ./backend/Dockerfile push: true build-args: 'target=client' - tags: ${{ steps.login-ecr.outputs.registry }}/codedang-admin-api:latest + tags: ${{ steps.login-ecr.outputs.registry }}/codedang-client-api:latest build-admin-api: name: Build admin-api image @@ -102,6 +102,9 @@ jobs: role-to-assume: ${{ secrets.AWS_ROLE_FOR_DEPLOY }} aws-region: ${{ env.AWS_REGION }} + - name: Trigger Amplify Frontend Build + run: curl -X POST -d {} "${{ secrets.AMPLIFY_WEBHOOK }}" + - uses: hashicorp/setup-terraform@v3 with: terraform_version: 1.5.2 @@ -109,9 +112,9 @@ jobs: - name: Create Terraform variable file working-directory: ./infra/deploy run: | - echo $TFVARS >> terraform.tfvars - echo $OAUTH_GITHUB >> terraform.tfvars - echo $OAUTH_KAKAO >> terraform.tfvars + echo "$TFVARS" >> terraform.tfvars + echo "$OAUTH_GITHUB" >> terraform.tfvars + echo "$OAUTH_KAKAO" >> terraform.tfvars env: TFVARS: ${{ secrets.TFVARS }} OAUTH_GITHUB: ${{ secrets.OAUTH_GITHUB }} diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml deleted file mode 100644 index 451be5aac4..0000000000 --- a/.github/workflows/documentation.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Update Documentation - -on: - push: - branches: [main] - paths: ['docs/**'] - -permissions: - id-token: write - contents: read - -jobs: - deploy: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup-pnpm - - - name: Build documentation - run: pnpm docs:build - - - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - role-to-assume: ${{ secrets.AWS_ROLE_FOR_DOCUMENTATION }} - aws-region: ap-northeast-2 - - - name: Deploy documentation to AWS S3 - run: | - aws s3 sync ./docs/.vitepress/dist s3://docs.codedang.com --region ap-northeast-2 diff --git a/.gitignore b/.gitignore index e46396700c..37a0f06e12 100644 --- a/.gitignore +++ b/.gitignore @@ -72,9 +72,6 @@ dist/ # pnpm store .pnpm-store -# Vitepress -docs/.vitepress/cache/ - # NestJS & GraphQL @generated/ backend/schema.gql diff --git a/.gitpod.yml b/.gitpod.yml index 9a7771856b..8eda1381be 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -37,6 +37,7 @@ github: vscode: extensions: + - apollographql.vscode-apollo - bradlc.vscode-tailwindcss - bruno-api-client.bruno - csstools.postcss diff --git a/.prettierignore b/.prettierignore index f72ea2e35e..3aba1a4e6f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,6 +4,7 @@ node_modules/ pnpm-lock.yaml .pnpm-store/ @generated/ +__generated__/ schema.gql collection/ .next/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 082bc94c02..e93441c845 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,11 @@ { - "recommendations": ["ms-vscode-remote.remote-containers"] + // Recommendations for Frontend team, developing without devcontainer + "recommendations": [ + "apollographql.vscode-apollo", + "dbaeumer.vscode-eslint", + "donjayamanne.githistory", + "eamodio.gitlens", + "EditorConfig.EditorConfig", + "esbenp.prettier-vscode" + ] } diff --git a/README.md b/README.md index fbd464f14d..7fb22a7c5b 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Codedang은 스꾸딩(skkuding) 팀에서 만들고 관리하는 성균관대학 문서 웹페이지를 참고해주세요. https://docs.codedang.com -문서 미리보기는 `pnpm docs:dev` 명령어로 가능합니다. +> 문서 저장소: [skkuding/docs](https://github.com/skkuding/docs) ## Contributing Guide 👏 diff --git a/apollo.config.js b/apollo.config.js new file mode 100644 index 0000000000..6b67e7fb18 --- /dev/null +++ b/apollo.config.js @@ -0,0 +1,10 @@ +module.exports = { + client: { + includes: ['./frontend-client/**/*.ts', './frontend-client/**/*.tsx'], + excludes: ['**/__generated__/**'], + service: { + name: 'codedang-graphql-app', + url: 'https://dev.codedang.com/graphql' + } + } +} diff --git a/backend/apps/admin/src/contest/contest.service.spec.ts b/backend/apps/admin/src/contest/contest.service.spec.ts index e90ec0aa82..23e912275e 100644 --- a/backend/apps/admin/src/contest/contest.service.spec.ts +++ b/backend/apps/admin/src/contest/contest.service.spec.ts @@ -113,11 +113,15 @@ const problem: Problem = { exposeTime: faker.date.past(), createTime: faker.date.past(), updateTime: faker.date.past(), - inputExamples: ['input'], - outputExamples: ['input'], + samples: [], submissionCount: 0, acceptedCount: 0, - acceptedRate: 0 + acceptedRate: 0, + engDescription: null, + engHint: null, + engInputDescription: null, + engOutputDescription: null, + engTitle: null } const contestProblem: ContestProblem = { diff --git a/backend/apps/admin/src/problem/mock/mock.ts b/backend/apps/admin/src/problem/mock/mock.ts index cb7a737879..298edc45a9 100644 --- a/backend/apps/admin/src/problem/mock/mock.ts +++ b/backend/apps/admin/src/problem/mock/mock.ts @@ -41,9 +41,13 @@ export const problems: Problem[] = [ createTime: faker.date.past(), updateTime: faker.date.past(), exposeTime: faker.date.anytime(), - inputExamples: [], - outputExamples: [], - isVisible: true + samples: [], + isVisible: true, + engTitle: null, + engDescription: null, + engHint: null, + engInputDescription: null, + engOutputDescription: null }, { id: 2, @@ -66,9 +70,13 @@ export const problems: Problem[] = [ createTime: faker.date.past(), updateTime: faker.date.past(), exposeTime: faker.date.anytime(), - inputExamples: [], - outputExamples: [], - isVisible: true + samples: [], + isVisible: true, + engTitle: null, + engDescription: null, + engHint: null, + engInputDescription: null, + engOutputDescription: null } ] @@ -120,9 +128,13 @@ export const importedProblems: Problem[] = [ createTime: faker.date.past(), updateTime: faker.date.past(), exposeTime: faker.date.anytime(), - inputExamples: [], - outputExamples: [], - isVisible: true + samples: [], + isVisible: true, + engTitle: null, + engDescription: null, + engHint: null, + engInputDescription: null, + engOutputDescription: null }, { id: 33, @@ -161,8 +173,12 @@ export const importedProblems: Problem[] = [ createTime: faker.date.past(), updateTime: faker.date.past(), exposeTime: faker.date.anytime(), - inputExamples: [], - outputExamples: [], - isVisible: true + samples: [], + isVisible: true, + engTitle: null, + engDescription: null, + engHint: null, + engInputDescription: null, + engOutputDescription: null } ] diff --git a/backend/apps/admin/src/problem/model/problem.input.ts b/backend/apps/admin/src/problem/model/problem.input.ts index a063bdf018..2e4a598da4 100644 --- a/backend/apps/admin/src/problem/model/problem.input.ts +++ b/backend/apps/admin/src/problem/model/problem.input.ts @@ -4,7 +4,7 @@ import { GraphQLUpload } from 'graphql-upload' import { Language, Level } from '@admin/@generated' import type { FileUploadDto } from '../dto/file-upload.dto' import { Template } from './template.input' -import { Testcase } from './testcase.input' +import { Testcase, Sample } from './testcase.input' @InputType() export class CreateProblemInput { @@ -44,11 +44,8 @@ export class CreateProblemInput { @Field(() => String, { nullable: false }) source!: string - @Field(() => [String], { nullable: false }) - inputExamples!: Array - - @Field(() => [String], { nullable: false }) - outputExamples!: Array + @Field(() => [Sample], { nullable: false }) + samples!: Array @Field(() => [Testcase], { nullable: false }) testcases!: Array @@ -76,8 +73,7 @@ export interface UploadProblemInput { memoryLimit: number difficulty: keyof typeof Level source: string - inputExamples: Array - outputExamples: Array + samples: Array } @InputType() @@ -97,6 +93,14 @@ export class UpdateProblemTagInput { @Field(() => [Int], { nullable: false }) delete!: Array } +@InputType() +export class UpdateSamples { + @Field(() => [Sample], { nullable: false }) + create!: Array + + @Field(() => [Int], { nullable: false }) + delete!: Array +} @InputType() export class UpdateProblemInput { @@ -139,11 +143,8 @@ export class UpdateProblemInput { @Field(() => String, { nullable: true }) source?: string - @Field(() => [String], { nullable: true }) - inputExamples?: Array - - @Field(() => [String], { nullable: true }) - outputExamples?: Array + @Field(() => UpdateSamples, { nullable: true }) + samples?: UpdateSamples @Field(() => [Testcase], { nullable: true }) testcases?: Array diff --git a/backend/apps/admin/src/problem/model/testcase.input.ts b/backend/apps/admin/src/problem/model/testcase.input.ts index 13371a2a04..51e1bbe3cd 100644 --- a/backend/apps/admin/src/problem/model/testcase.input.ts +++ b/backend/apps/admin/src/problem/model/testcase.input.ts @@ -1,13 +1,16 @@ import { Field, InputType, Int } from '@nestjs/graphql' @InputType() -export class Testcase { +export class Sample { @Field(() => String, { nullable: false }) input!: string @Field(() => String, { nullable: false }) output!: string +} +@InputType() +export class Testcase extends Sample { @Field(() => Int, { nullable: true }) scoreWeight?: number } diff --git a/backend/apps/admin/src/problem/problem-tag.resolver.ts b/backend/apps/admin/src/problem/problem-tag.resolver.ts new file mode 100644 index 0000000000..0ee3084fe7 --- /dev/null +++ b/backend/apps/admin/src/problem/problem-tag.resolver.ts @@ -0,0 +1,38 @@ +import { InternalServerErrorException, Logger } from '@nestjs/common' +import { Query, Resolver, ResolveField, Parent } from '@nestjs/graphql' +import { ProblemTag, Tag } from '@generated' +import { ProblemService } from './problem.service' + +@Resolver(() => ProblemTag) +export class ProblemTagResolver { + private readonly logger = new Logger(ProblemTagResolver.name) + + constructor(private readonly problemService: ProblemService) {} + + @ResolveField('tag', () => Tag) + async getTag(@Parent() problemTag: ProblemTag) { + try { + return await this.problemService.getTag(problemTag.tagId) + } catch (error) { + this.logger.error(error) + throw new InternalServerErrorException() + } + } +} + +@Resolver(() => Tag) +export class TagResolver { + private readonly logger = new Logger(TagResolver.name) + + constructor(private readonly problemService: ProblemService) {} + + @Query(() => [Tag]) + async getTags() { + try { + return await this.problemService.getTags() + } catch (error) { + this.logger.error(error) + throw new InternalServerErrorException() + } + } +} diff --git a/backend/apps/admin/src/problem/problem.module.ts b/backend/apps/admin/src/problem/problem.module.ts index 9888963f81..bd2c977f4e 100644 --- a/backend/apps/admin/src/problem/problem.module.ts +++ b/backend/apps/admin/src/problem/problem.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common' import { StorageModule } from '@admin/storage/storage.module' +import { ProblemTagResolver, TagResolver } from './problem-tag.resolver' import { ProblemResolver } from './problem.resolver' import { ProblemService } from './problem.service' @Module({ imports: [StorageModule], - providers: [ProblemResolver, ProblemService] + providers: [ProblemResolver, ProblemTagResolver, TagResolver, ProblemService] }) export class ProblemModule {} diff --git a/backend/apps/admin/src/problem/problem.resolver.ts b/backend/apps/admin/src/problem/problem.resolver.ts index c9cc50ee9d..d95fcb05cd 100644 --- a/backend/apps/admin/src/problem/problem.resolver.ts +++ b/backend/apps/admin/src/problem/problem.resolver.ts @@ -7,7 +7,6 @@ import { UsePipes, ValidationPipe } from '@nestjs/common' -import { Args, Context, Query, Int, Mutation, Resolver } from '@nestjs/graphql' import { Prisma } from '@prisma/client' import { AuthenticatedRequest } from '@libs/auth' import { OPEN_SPACE_ID } from '@libs/constants' @@ -308,8 +307,23 @@ export class ProblemResolver { } } - @Query(() => [Tag]) - async getTags() { - return await this.problemService.getTags() + @ResolveField('problemTag', () => [ProblemTag]) + async getProblemTags(@Parent() problem: Problem) { + try { + return await this.problemService.getProblemTags(problem.id) + } catch (error) { + this.logger.error(error) + throw new InternalServerErrorException() + } + } + + @ResolveField('problemTestcase', () => [ProblemTestcase]) + async getProblemTestCases(@Parent() problem: Problem) { + try { + return await this.problemService.getProblemTestcases(problem.id) + } catch (error) { + this.logger.error(error) + throw new InternalServerErrorException() + } } } diff --git a/backend/apps/admin/src/problem/problem.service.spec.ts b/backend/apps/admin/src/problem/problem.service.spec.ts index 7a1eb6acb8..f836cb5a97 100644 --- a/backend/apps/admin/src/problem/problem.service.spec.ts +++ b/backend/apps/admin/src/problem/problem.service.spec.ts @@ -43,6 +43,12 @@ const db = { findMany: stub(), update: stub() }, + problemTag: { + findMany: stub() + }, + tag: { + findUnique: stub() + }, workbook: { findFirstOrThrow: stub() }, @@ -394,6 +400,33 @@ const exampleOrderUpdatedContestProblems: ContestProblem[] = [ } ] +const exampleProblemTestcases: ProblemTestcase[] = [ + { + id: 1, + problemId: 1, + input: '1', + output: '1', + scoreWeight: 1, + createTime: new Date(), + updateTime: new Date() + } +] + +const exampleProblemTags: ProblemTag[] = [ + { + id: 1, + problemId: 1, + tagId: 1 + } +] + +const exampleTag: Tag = { + id: 1, + name: 'brute force', + createTime: new Date(), + updateTime: new Date() +} + describe('ProblemService', () => { let service: ProblemService let storageService: StorageService @@ -431,8 +464,7 @@ describe('ProblemService', () => { memoryLimit: problems[0].memoryLimit, difficulty: Level.Level1, source: problems[0].source, - inputExamples: problems[0].inputExamples, - outputExamples: problems[0].outputExamples, + samples: problems[0].samples ?? [], testcases: [testcaseInput], tagIds: [1] } @@ -836,4 +868,45 @@ describe('ProblemService', () => { ).to.be.rejectedWith(EntityNotExistException) }) }) + + describe('getTag', () => { + afterEach(() => { + db.tag.findUnique.reset() + }) + + it('should return a tag object', async () => { + db.tag.findUnique.resolves(exampleTag) + expect(await service.getTag(1)).to.deep.equal(exampleTag) + }) + + it('should throw an EntityNotExist exception when tagId do not exist', async () => { + await expect(service.getTag(999)).to.be.rejectedWith( + EntityNotExistException + ) + }) + }) + + describe('getProblemTags', () => { + afterEach(() => { + db.problemTestcase.findMany.reset() + }) + + it('should return a problem tag array', async () => { + db.problemTag.findMany.resolves(exampleProblemTags) + expect(await service.getProblemTags(1)).to.deep.equal(exampleProblemTags) + }) + }) + + describe('getProblemTestcases', () => { + afterEach(() => { + db.problemTestcase.findMany.reset() + }) + + it('should return a problem testcase array', async () => { + db.problemTestcase.findMany.resolves(exampleProblemTestcases) + expect(await service.getProblemTestcases(1)).to.deep.equal( + exampleProblemTestcases + ) + }) + }) }) diff --git a/backend/apps/admin/src/problem/problem.service.ts b/backend/apps/admin/src/problem/problem.service.ts index 5546a7e31f..1703335eb2 100644 --- a/backend/apps/admin/src/problem/problem.service.ts +++ b/backend/apps/admin/src/problem/problem.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common' import { Workbook } from 'exceljs' import { DuplicateFoundException, + EntityNotExistException, UnprocessableDataException, UnprocessableFileDataException } from '@libs/exception' @@ -16,7 +17,6 @@ import type { CreateProblemInput, UploadFileInput, FilterProblemsInput, - UploadProblemInput, UpdateProblemInput, UpdateProblemTagInput } from './model/problem.input' @@ -35,7 +35,7 @@ export class ProblemService { userId: number, groupId: number ) { - const { languages, template, tagIds, testcases, ...data } = input + const { languages, template, tagIds, samples, testcases, ...data } = input if (!languages.length) { throw new UnprocessableDataException( 'A problem should support at least one language' @@ -52,6 +52,9 @@ export class ProblemService { const problem = await this.prisma.problem.create({ data: { ...data, + samples: { + create: samples + }, groupId, createdById: userId, languages, @@ -61,6 +64,9 @@ export class ProblemService { return { tagId } }) } + }, + include: { + samples: true } }) await this.createTestcases(problem.id, testcases) @@ -115,8 +121,7 @@ export class ProblemService { ) const header = {} - const problems: { index: number; data: UploadProblemInput }[] = [] - const testcases: { [key: number]: Testcase[] } = {} + const problems: CreateProblemInput[] = [] const workbook = new Workbook() const worksheet = (await workbook.xlsx.read(createReadStream())) @@ -188,22 +193,6 @@ export class ProblemService { } //TODO: specify timeLimit, memoryLimit(default: 2sec, 512mb) - const problemInput = { - title, - description, - inputDescription: '', - outputDescription: '', - hint: '', - template, - languages, - timeLimit: 2000, - memoryLimit: 512, - difficulty: level, - source: '', - inputExamples: [], - outputExamples: [] - } - problems.push({ index: rowNumber, data: problemInput }) const testCnt = parseInt(row.getCell(header['TestCnt']).text) const inputText = row.getCell(header['Input']).text @@ -238,31 +227,29 @@ export class ProblemService { scoreWeight: parseInt(scoreWeights[i]) }) } - testcases[rowNumber] = testcaseInput + + problems.push({ + title, + description, + inputDescription: '', + isVisible: true, + outputDescription: '', + hint: '', + template, + languages, + timeLimit: 2000, + memoryLimit: 512, + difficulty: level, + source: '', + testcases: testcaseInput, + tagIds: [], + samples: [] + }) }) return await Promise.all( - problems.map(async (problemInput) => { - const { index, data } = problemInput - const problem = await this.prisma.problem.create({ - data: { - ...data, - createdBy: { - connect: { - id: userId - } - }, - group: { - connect: { - id: groupId - } - }, - template: [JSON.stringify(data.template)] - } - }) - if (index in testcases) { - await this.createTestcases(problem.id, testcases[index]) - } + problems.map(async (data) => { + const problem = await this.createProblem(data, userId, groupId) return problem }) ) @@ -303,6 +290,7 @@ export class ProblemService { groupId }, include: { + samples: true, problemTestcase: true, problemTag: { include: { @@ -314,7 +302,7 @@ export class ProblemService { } async updateProblem(input: UpdateProblemInput, groupId: number) { - const { id, languages, template, tags, testcases, ...data } = input + const { id, languages, template, tags, testcases, samples, ...data } = input const problem = await this.getProblem(id, groupId) if (languages && !languages.length) { @@ -341,6 +329,14 @@ export class ProblemService { where: { id }, data: { ...data, + samples: { + create: samples?.create, + delete: samples?.delete.map((deleteId) => { + return { + id: deleteId + } + }) + }, ...(languages && { languages }), ...(template && { template: [JSON.stringify(template)] }), problemTag @@ -580,4 +576,32 @@ export class ProblemService { async getTags(): Promise[]> { return await this.prisma.tag.findMany() } + + async getTag(tagId: number) { + const tag = await this.prisma.tag.findUnique({ + where: { + id: tagId + } + }) + if (tag == null) { + throw new EntityNotExistException('problem') + } + return tag + } + + async getProblemTags(problemId: number) { + return await this.prisma.problemTag.findMany({ + where: { + problemId + } + }) + } + + async getProblemTestcases(problemId: number) { + return await this.prisma.problemTestcase.findMany({ + where: { + problemId + } + }) + } } diff --git a/backend/apps/client/src/problem/dto/problem.response.dto.ts b/backend/apps/client/src/problem/dto/problem.response.dto.ts index a71caf604c..7358b6cbd9 100644 --- a/backend/apps/client/src/problem/dto/problem.response.dto.ts +++ b/backend/apps/client/src/problem/dto/problem.response.dto.ts @@ -9,6 +9,11 @@ export class ProblemResponseDto { @Expose() inputDescription: string @Expose() outputDescription: string @Expose() hint: string + @Expose() engTitle: string + @Expose() engDescription: string + @Expose() engInputDescription: string + @Expose() engOutputDescription: string + @Expose() engHint: string @Expose() languages: Language[] @Expose() timeLimit: number @Expose() memoryLimit: number diff --git a/backend/apps/client/src/problem/dto/problems.response.dto.ts b/backend/apps/client/src/problem/dto/problems.response.dto.ts index 1ac8c3a575..4ff6d56408 100644 --- a/backend/apps/client/src/problem/dto/problems.response.dto.ts +++ b/backend/apps/client/src/problem/dto/problems.response.dto.ts @@ -19,6 +19,9 @@ class Problem { @Expose() title: string + @Expose() + engTitle: string + @Expose() difficulty: Level diff --git a/backend/apps/client/src/problem/enum/problem-order.enum.ts b/backend/apps/client/src/problem/enum/problem-order.enum.ts new file mode 100644 index 0000000000..b0a7510c41 --- /dev/null +++ b/backend/apps/client/src/problem/enum/problem-order.enum.ts @@ -0,0 +1,12 @@ +export enum ProblemOrder { + idASC = 'id-asc', + idDESC = 'id-desc', + titleASC = 'title-asc', + titleDESC = 'title-desc', + levelASC = 'level-asc', + levelDESC = 'level-desc', + acrateASC = 'acrate-asc', + acrateDESC = 'acrate-desc', + submitASC = 'submit-asc', + submitDESC = 'submit-desc' +} diff --git a/backend/apps/client/src/problem/mock/problem.mock.ts b/backend/apps/client/src/problem/mock/problem.mock.ts index ec88bd549c..f7c83fbc7d 100644 --- a/backend/apps/client/src/problem/mock/problem.mock.ts +++ b/backend/apps/client/src/problem/mock/problem.mock.ts @@ -29,10 +29,13 @@ export const problems: Problem[] = [ exposeTime: new Date('2000-01-01'), createTime: faker.date.past(), updateTime: faker.date.past(), - inputExamples: [], - outputExamples: [], template: [], - isVisible: true + isVisible: true, + engTitle: null, + engDescription: null, + engHint: null, + engInputDescription: null, + engOutputDescription: null }, { id: 2, @@ -54,10 +57,13 @@ export const problems: Problem[] = [ exposeTime: new Date('2000-01-01'), createTime: faker.date.past(), updateTime: faker.date.past(), - inputExamples: [], - outputExamples: [], template: [], - isVisible: true + isVisible: true, + engTitle: null, + engDescription: null, + engHint: null, + engInputDescription: null, + engOutputDescription: null } ] diff --git a/backend/apps/client/src/problem/problem.controller.ts b/backend/apps/client/src/problem/problem.controller.ts index 20cbf11db6..5d0eb74a50 100644 --- a/backend/apps/client/src/problem/problem.controller.ts +++ b/backend/apps/client/src/problem/problem.controller.ts @@ -16,17 +16,17 @@ import { } from '@libs/exception' import { CursorValidationPipe, - ZodValidationPipe, GroupIDPipe, IDValidationPipe, - RequiredIntPipe + RequiredIntPipe, + ProblemOrderPipe } from '@libs/pipe' +import { ProblemOrder } from './enum/problem-order.enum' import { ContestProblemService, ProblemService, WorkbookProblemService } from './problem.service' -import { ProblemOrder, problemOrderSchema } from './schema/problem-order.schema' @Controller('problem') @AuthNotNeededIfOpenSpace() @@ -47,7 +47,7 @@ export class ProblemController { @Query('cursor', CursorValidationPipe) cursor: number | null, @Query('take', new DefaultValuePipe(10), new RequiredIntPipe('take')) take: number, - @Query('order', new ZodValidationPipe(problemOrderSchema)) + @Query('order', ProblemOrderPipe) order: ProblemOrder, @Query('search') search?: string ) { diff --git a/backend/apps/client/src/problem/problem.repository.ts b/backend/apps/client/src/problem/problem.repository.ts index b16f52f15d..2aebdfe322 100644 --- a/backend/apps/client/src/problem/problem.repository.ts +++ b/backend/apps/client/src/problem/problem.repository.ts @@ -4,7 +4,7 @@ import type { Problem, Tag, CodeDraft, Prisma } from '@prisma/client' import { PrismaService } from '@libs/prisma' import type { CodeDraftUpdateInput } from '@admin/@generated' import type { CreateTemplateDto } from './dto/create-code-draft.dto' -import type { ProblemOrder } from './schema/problem-order.schema' +import type { ProblemOrder } from './enum/problem-order.enum' /** * repository에서는 partial entity를 반환합니다. @@ -19,28 +19,32 @@ import type { ProblemOrder } from './schema/problem-order.schema' export class ProblemRepository { constructor(private readonly prisma: PrismaService) {} - private readonly problemsSelectOption = { + private readonly problemsSelectOption: Prisma.ProblemSelect = { id: true, title: true, + engTitle: true, exposeTime: true, difficulty: true, acceptedRate: true, submissionCount: true } - private readonly problemSelectOption = { + private readonly problemSelectOption: Prisma.ProblemSelect = { ...this.problemsSelectOption, description: true, inputDescription: true, outputDescription: true, hint: true, + engDescription: true, + engInputDescription: true, + engOutputDescription: true, + engHint: true, languages: true, timeLimit: true, memoryLimit: true, source: true, acceptedCount: true, - inputExamples: true, - outputExamples: true + samples: true } private readonly codeDraftSelectOption = { diff --git a/backend/apps/client/src/problem/problem.service.ts b/backend/apps/client/src/problem/problem.service.ts index f64efb67ff..a2bf02210c 100644 --- a/backend/apps/client/src/problem/problem.service.ts +++ b/backend/apps/client/src/problem/problem.service.ts @@ -13,8 +13,8 @@ import { ProblemResponseDto } from './dto/problem.response.dto' import { ProblemsResponseDto } from './dto/problems.response.dto' import { RelatedProblemResponseDto } from './dto/related-problem.response.dto' import { RelatedProblemsResponseDto } from './dto/related-problems.response.dto' +import type { ProblemOrder } from './enum/problem-order.enum' import { ProblemRepository } from './problem.repository' -import type { ProblemOrder } from './schema/problem-order.schema' @Injectable() export class ProblemService { diff --git a/backend/apps/client/src/problem/schema/problem-order.schema.ts b/backend/apps/client/src/problem/schema/problem-order.schema.ts deleted file mode 100644 index f25d55b2e5..0000000000 --- a/backend/apps/client/src/problem/schema/problem-order.schema.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { z } from 'zod' - -export const problemOrderSchema = z - .union([ - z.literal('id-asc'), - z.literal('id-desc'), - z.literal('title-asc'), - z.literal('title-desc'), - z.literal('level-asc'), - z.literal('level-desc'), - z.literal('acrate-asc'), - z.literal('acrate-desc'), - z.literal('submit-asc'), - z.literal('submit-desc') - ]) - .default('id-asc') - -export type ProblemOrder = z.infer diff --git a/backend/apps/client/src/submission/mock/problem.mock.ts b/backend/apps/client/src/submission/mock/problem.mock.ts index 51c776a1a1..16d1ec3592 100644 --- a/backend/apps/client/src/submission/mock/problem.mock.ts +++ b/backend/apps/client/src/submission/mock/problem.mock.ts @@ -31,8 +31,11 @@ export const problems: Problem[] = [ exposeTime: new Date(), createTime: faker.date.past(), updateTime: faker.date.past(), - inputExamples: [], - outputExamples: [], - isVisible: true + isVisible: true, + engTitle: null, + engDescription: null, + engHint: null, + engInputDescription: null, + engOutputDescription: null } ] diff --git a/backend/libs/pipe/src/index.ts b/backend/libs/pipe/src/index.ts index 9edcd69fbc..51df868689 100644 --- a/backend/libs/pipe/src/index.ts +++ b/backend/libs/pipe/src/index.ts @@ -1,5 +1,5 @@ export * from './cursor-validation.pipe' -export * from './zod-validation.pipe' export * from './id-validation.pipe' export * from './group-id.pipe' export * from './required-int.pipe' +export * from './problem-order.pipe' diff --git a/backend/libs/pipe/src/problem-order.pipe.ts b/backend/libs/pipe/src/problem-order.pipe.ts new file mode 100644 index 0000000000..eeb3df45d8 --- /dev/null +++ b/backend/libs/pipe/src/problem-order.pipe.ts @@ -0,0 +1,18 @@ +import { + BadRequestException, + Injectable, + type PipeTransform +} from '@nestjs/common' +import { ProblemOrder } from '@client/problem/enum/problem-order.enum' + +@Injectable() +export class ProblemOrderPipe implements PipeTransform { + transform(value: unknown) { + if (!value) { + return ProblemOrder.idASC + } else if (!Object.values(ProblemOrder).includes(value as ProblemOrder)) { + throw new BadRequestException('Problem-order validation failed') + } + return value + } +} diff --git a/backend/libs/pipe/src/zod-validation.pipe.ts b/backend/libs/pipe/src/zod-validation.pipe.ts deleted file mode 100644 index 7b3111d24b..0000000000 --- a/backend/libs/pipe/src/zod-validation.pipe.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { BadRequestException, type PipeTransform } from '@nestjs/common' -import type { ZodSchema } from 'zod' - -export class ZodValidationPipe implements PipeTransform { - constructor(private schema: ZodSchema) {} - - transform(value: unknown) { - try { - const parsedValue = this.schema.parse(value) - return parsedValue - } catch (error) { - throw new BadRequestException('Validation failed') - } - } -} diff --git a/backend/package.json b/backend/package.json index 0a74862ed9..bf22f62bc3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,9 +18,9 @@ }, "dependencies": { "@apollo/server": "^4.10.0", - "@aws-sdk/client-s3": "^3.514.0", - "@aws-sdk/client-ses": "^3.514.0", - "@aws-sdk/credential-provider-node": "^3.514.0", + "@aws-sdk/client-s3": "^3.515.0", + "@aws-sdk/client-ses": "^3.515.0", + "@aws-sdk/credential-provider-node": "^3.515.0", "@golevelup/nestjs-rabbitmq": "^4.1.0", "@nestjs-modules/mailer": "^1.10.3", "@nestjs/apollo": "^12.1.0", @@ -75,7 +75,7 @@ "@types/express": "^4.17.21", "@types/graphql-upload": "8.0.12", "@types/mocha": "^10.0.6", - "@types/node": "^20.11.17", + "@types/node": "^20.11.19", "@types/nodemailer": "^6.4.14", "@types/passport-jwt": "^4.0.1", "@types/proxyquire": "^1.3.31", diff --git a/backend/prisma/__fixtures__/problem/1-description-eng.html b/backend/prisma/__fixtures__/problem/1-description-eng.html new file mode 100644 index 0000000000..e0c31f47b8 --- /dev/null +++ b/backend/prisma/__fixtures__/problem/1-description-eng.html @@ -0,0 +1,5 @@ +

+ Write a program that takes two integers, A and B, as input and outputs their + sum, A+B. A and B are given in the first line. (0 < A, B < 10) Output the sum + A+B on the first line. +

diff --git a/backend/prisma/__fixtures__/problem/1-input-eng.html b/backend/prisma/__fixtures__/problem/1-input-eng.html new file mode 100644 index 0000000000..426fec4fab --- /dev/null +++ b/backend/prisma/__fixtures__/problem/1-input-eng.html @@ -0,0 +1 @@ +

A and B are given in the first line. (0 < A, B < 10)

diff --git a/backend/prisma/__fixtures__/problem/1-output-eng.html b/backend/prisma/__fixtures__/problem/1-output-eng.html new file mode 100644 index 0000000000..9850a74624 --- /dev/null +++ b/backend/prisma/__fixtures__/problem/1-output-eng.html @@ -0,0 +1 @@ +

Output the sum A+B on the first line.

diff --git a/backend/prisma/migrations/20240213065504_add_exaple_io_model/migration.sql b/backend/prisma/migrations/20240213065504_add_exaple_io_model/migration.sql new file mode 100644 index 0000000000..19f69295c8 --- /dev/null +++ b/backend/prisma/migrations/20240213065504_add_exaple_io_model/migration.sql @@ -0,0 +1,25 @@ +/* + Warnings: + + - You are about to drop the column `input_examples` on the `problem` table. All the data in the column will be lost. + - You are about to drop the column `output_examples` on the `problem` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "problem" DROP COLUMN "input_examples", +DROP COLUMN "output_examples"; + +-- CreateTable +CREATE TABLE "example_io" ( + "id" SERIAL NOT NULL, + "problem_id" INTEGER NOT NULL, + "input" TEXT NOT NULL, + "output" TEXT NOT NULL, + "create_time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "update_time" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "example_io_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "example_io" ADD CONSTRAINT "example_io_problem_id_fkey" FOREIGN KEY ("problem_id") REFERENCES "problem"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20240215062711_add_english_information_to_problem_model/migration.sql b/backend/prisma/migrations/20240215062711_add_english_information_to_problem_model/migration.sql new file mode 100644 index 0000000000..657787d109 --- /dev/null +++ b/backend/prisma/migrations/20240215062711_add_english_information_to_problem_model/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "problem" ADD COLUMN "eng_description" TEXT, +ADD COLUMN "eng_hint" TEXT, +ADD COLUMN "eng_input_description" TEXT, +ADD COLUMN "eng_output_description" TEXT, +ADD COLUMN "eng_title" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 0bbcd72ae6..5aa488859d 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -138,17 +138,25 @@ model Notice { } model Problem { - id Int @id @default(autoincrement()) - createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull) - createdById Int? @map("created_by_id") - group Group @relation(fields: [groupId], references: [id]) - groupId Int @map("group_id") + id Int @id @default(autoincrement()) + createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull) + createdById Int? @map("created_by_id") + group Group @relation(fields: [groupId], references: [id]) + groupId Int @map("group_id") + title String description String - inputDescription String @map("input_description") - outputDescription String @map("output_description") + inputDescription String @map("input_description") + outputDescription String @map("output_description") hint String - isVisible Boolean @default(true) @map("is_visible") + + // 문제 정보의 영어 버전 제공은 선택사항임 + engTitle String? @map("eng_title") + engDescription String? @map("eng_description") + engInputDescription String? @map("eng_input_description") + engOutputDescription String? @map("eng_output_description") + engHint String? @map("eng_hint") + /// template code item structure /// { /// "lanaguage": Language, @@ -158,21 +166,21 @@ model Problem { /// "locked": boolean /// }[] /// } - template Json[] - languages Language[] - timeLimit Int @map("time_limit") // unit: MilliSeconds - memoryLimit Int @map("memory_limit") // unit: MegaBytes - difficulty Level - source String - submissionCount Int @default(0) @map("submission_count") - acceptedCount Int @default(0) @map("accepted_count") - acceptedRate Float @default(0) @map("accepted_rate") - exposeTime DateTime @default(now()) @map("expose_time") - createTime DateTime @default(now()) @map("create_time") - updateTime DateTime @updatedAt @map("update_time") - inputExamples String[] @map("input_examples") - outputExamples String[] @map("output_examples") - + isVisible Boolean @default(true) @map("is_visible") + template Json[] + languages Language[] + timeLimit Int @map("time_limit") // unit: MilliSeconds + memoryLimit Int @map("memory_limit") // unit: MegaBytes + difficulty Level + source String + submissionCount Int @default(0) @map("submission_count") + acceptedCount Int @default(0) @map("accepted_count") + acceptedRate Float @default(0) @map("accepted_rate") + exposeTime DateTime @default(now()) @map("expose_time") + createTime DateTime @default(now()) @map("create_time") + updateTime DateTime @updatedAt @map("update_time") + + samples ExampleIO[] problemTestcase ProblemTestcase[] problemTag ProblemTag[] contestProblem ContestProblem[] @@ -216,6 +224,18 @@ model ProblemTestcase { @@map("problem_testcase") } +model ExampleIO { + id Int @id @default(autoincrement()) + problem Problem @relation(fields: [problemId], references: [id], onDelete: Cascade, onUpdate: Cascade) + problemId Int @map("problem_id") + input String + output String + createTime DateTime @default(now()) @map("create_time") + updateTime DateTime @updatedAt @map("update_time") + + @@map("example_io") +} + model ProblemTag { id Int @id @default(autoincrement()) problem Problem @relation(fields: [problemId], references: [id], onDelete: Cascade, onUpdate: Cascade) diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 5a704b3e5e..f237ca4423 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -598,28 +598,51 @@ const createProblems = async () => { await prisma.problem.create({ data: { title: '정수 더하기', + engTitle: 'Integer Addition', createdById: superAdminUser.id, groupId: publicGroup.id, description: await readFile( join(fixturePath, 'problem/1-description.html'), 'utf-8' ), + engDescription: await readFile( + join(fixturePath, 'problem/1-description-eng.html'), + 'utf-8' + ), difficulty: Level.Level1, inputDescription: await readFile( join(fixturePath, 'problem/1-input.html'), 'utf-8' ), + engInputDescription: await readFile( + join(fixturePath, 'problem/1-input-eng.html'), + 'utf-8' + ), outputDescription: await readFile( join(fixturePath, 'problem/1-output.html'), 'utf-8' ), + engOutputDescription: await readFile( + join(fixturePath, 'problem/1-output-eng.html'), + 'utf-8' + ), languages: [Language.C, Language.Cpp, Language.Java, Language.Python3], hint: '', timeLimit: 2000, memoryLimit: 512, source: '', - inputExamples: ['1 2', '11 12'], - outputExamples: ['3', '23'] + samples: { + create: [ + { + input: '1 2', + output: '3' + }, + { + input: '11 12', + output: '23' + } + ] + } } }) ) @@ -648,8 +671,9 @@ const createProblems = async () => { timeLimit: 2000, memoryLimit: 512, source: 'Canadian Computing Competition(CCC) 2012 Junior 2번', - inputExamples: ['1\n10\n12\n13'], - outputExamples: ['Uphill'] + samples: { + create: [{ input: '1\n10\n12\n13', output: 'Uphill' }] + } } }) ) @@ -678,8 +702,13 @@ const createProblems = async () => { timeLimit: 1000, memoryLimit: 128, source: 'Canadian Computing Competition(CCC) 2013 Junior 2번', - inputExamples: ['SHINS', 'NO', 'SHOW'], - outputExamples: ['YES', 'YES', 'NO'] + samples: { + create: [ + { input: 'SHINS', output: 'YES' }, + { input: 'NO', output: 'YES' }, + { input: 'SHOW', output: 'NO' } + ] + } } }) ) @@ -708,8 +737,9 @@ const createProblems = async () => { timeLimit: 1000, memoryLimit: 128, source: 'USACO 2012 US Open Bronze 1번', - inputExamples: ['9\n2\n7\n3\n7\n7\n3\n7\n5\n7\n'], - outputExamples: ['4'] + samples: { + create: [{ input: '9\n2\n7\n3\n7\n7\n3\n7\n5\n7\n', output: '4' }] + } } }) ) @@ -738,8 +768,14 @@ const createProblems = async () => { timeLimit: 1000, memoryLimit: 128, source: 'ICPC Regionals NCPC 2009 B번', - inputExamples: ['5 3\n100\n-75\n-25\n-42\n42\n0 1\n1 2\n3 4'], - outputExamples: ['POSSIBLE'] + samples: { + create: [ + { + input: '5 3\n100\n-75\n-25\n-42\n42\n0 1\n1 2\n3 4', + output: 'POSSIBLE' + } + ] + } } }) ) @@ -768,8 +804,7 @@ const createProblems = async () => { timeLimit: 1000, memoryLimit: 128, source: 'USACO November 2011 Silver 3번', - inputExamples: ['3 6', '3', '3', '1'], - outputExamples: ['5'] + samples: { create: [{ input: '3 6', output: '5' }] } } }) ) @@ -798,12 +833,19 @@ const createProblems = async () => { timeLimit: 2000, memoryLimit: 512, source: 'COCI 2019/2020 Contest #3 2번', - inputExamples: [ - 'aaaaa\n2\n1 2\n4 5\n2 4 1 5 3', - 'abbabaab\n3\n1 3\n4 7\n3 5\n6 3 5 1 4 2 7 8', - 'abcd\n1\n1 4\n1 2 3 4' - ], - outputExamples: ['2', '5', '0'] + samples: { + create: [ + { input: 'aaaaa\n2\n1 2\n4 5\n2 4 1 5 3', output: '2' }, + { + input: 'abbabaab\n3\n1 3\n4 7\n3 5\n6 3 5 1 4 2 7 8', + output: '5' + }, + { + input: 'abcd\n1\n1 4\n1 2 3 4', + output: '0' + } + ] + } } }) ) @@ -832,11 +874,53 @@ const createProblems = async () => { timeLimit: 2000, memoryLimit: 256, source: 'ICPC Regionals SEERC 2019 J번', - inputExamples: [ - '3\n1 2 1\n2 3 1\n3 1 1', - '5\n4 5 4\n1 3 4\n1 2 4\n3 2 3\n3 5 2\n1 4 3\n4 2 2\n1 5 4\n5 2 4\n3 4 2' - ], - outputExamples: ['3', '35'] + samples: { + create: [ + { + input: '3\n1 2 1\n2 3 1\n3 1 1', + output: '3' + }, + { + input: + '5\n4 5 4\n1 3 4\n1 2 4\n3 2 3\n3 5 2\n1 4 3\n4 2 2\n1 5 4\n5 2 4\n3 4 2', + output: '35' + } + ] + }, + isVisible: false + } + }) + ) + + problems.push( + await prisma.problem.create({ + data: { + title: '수정중인 문제', + createdById: superAdminUser.id, + groupId: publicGroup.id, + description: `

수정 작업 중

`, + difficulty: Level.Level3, + inputDescription: `

비공개

`, + outputDescription: `

비공개

`, + languages: [Language.C, Language.Cpp, Language.Java, Language.Python3], + hint: `

작성중

`, + timeLimit: 2000, + memoryLimit: 256, + source: '2024 육군훈련소 입소 코딩 테스트', + samples: { + create: [ + { + input: '3\n1 2 1\n2 3 1\n3 1 1', + output: '3' + }, + { + input: + '5\n4 5 4\n1 3 4\n1 2 4\n3 2 3\n3 5 2\n1 4 3\n4 2 2\n1 5 4\n5 2 4\n3 4 2', + output: '35' + } + ] + }, + isVisible: false } }) ) diff --git a/collection/admin/Problem/Create Problem/Succeed.bru b/collection/admin/Problem/Create Problem/Succeed.bru index 2b7f069b6b..f24425af84 100644 --- a/collection/admin/Problem/Create Problem/Succeed.bru +++ b/collection/admin/Problem/Create Problem/Succeed.bru @@ -23,6 +23,10 @@ body:graphql { hint isVisible template + samples { + input + output + } createTime updateTime } @@ -54,8 +58,12 @@ body:graphql:vars { "memoryLimit": 0, "difficulty": "Level2", "source": "source", - "inputExamples": [], - "outputExamples": [], + "samples": [ + { + "input": "1 2", + "output": "3" + } + ], "testcases": [ { "input": "input", diff --git a/collection/admin/Problem/Get Problems/Succeed.bru b/collection/admin/Problem/Get Problems/Succeed.bru index 13d74aa306..1975cd5c4e 100644 --- a/collection/admin/Problem/Get Problems/Succeed.bru +++ b/collection/admin/Problem/Get Problems/Succeed.bru @@ -21,6 +21,17 @@ body:graphql { inputDescription outputDescription hint + problemTag { + id + tag { + id + name + } + } + problemTestcase { + input + output + } } } } diff --git a/collection/admin/Problem/Upload Problem/Succeed.bru b/collection/admin/Problem/Upload Problem/Succeed.bru new file mode 100644 index 0000000000..37e19ec0cb --- /dev/null +++ b/collection/admin/Problem/Upload Problem/Succeed.bru @@ -0,0 +1,30 @@ +meta { + name: Succeed + type: http + seq: 1 +} + +post { + url: {{gqlUrl}} + body: multipartForm + auth: none +} + +headers { + Apollo-Require-Preflight: true +} + +body:multipart-form { + operations: { "query": "mutation($groupId: Int!, $input: UploadFileInput!) { uploadProblems(groupId: $groupId, input: $input){id createdById groupId title description template languages difficulty}}", "variables" : {"groupId": 2, "input": {"file": null}}} + map: { "nfile" : ["variables.input.file"]} + nfile: @file() +} + +docs { + ## Upload Problem + Problem을 업로드합니다. + + ### Error Cases + #### UNPROCESSABLE + 파일의 확장자가 엑셀이 아니거나, 파일 내 특정 데이터가 형식에 맞지 않을 경우 줄 번호와 함께 에러를 반환합니다. +} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts deleted file mode 100644 index d086d944c0..0000000000 --- a/docs/.vitepress/config.mts +++ /dev/null @@ -1,128 +0,0 @@ -import { defineConfig } from 'vitepress' - -export default defineConfig({ - title: 'Codedang 코드당', - description: 'Codedang Document for Developers', - titleTemplate: false, - lastUpdated: true, - - head: [ - ['link', { rel: 'icon', href: '/logo.png' }], - ['link', { rel: 'preconnect', href: 'https://fonts.googleapis.com' }], - [ - 'link', - { - rel: 'preconnect', - href: 'https://fonts.gstatic.com', - crossorigin: '' - } - ], - [ - 'link', - { - rel: 'stylesheet', - href: 'https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700&display=swap' - } - ] - ], - - themeConfig: { - logo: '/logo.png', - nav: [ - { - text: '📌 Guide', - link: '/intro/' - }, - { - text: '💡 Demo', - link: 'https://codedang.com' - } - ], - socialLinks: [{ icon: 'github', link: 'https://github.com/skkuding/next' }], - sidebar: [ - { - text: 'Introduction', - items: [ - { - text: 'What is Coding Platform?', - link: '/intro/' - }, - { - text: 'Getting Started', - link: '/intro/getting-started' - }, - { - text: 'Contributing Guide', - link: 'https://github.com/skkuding/next/blob/main/CONTRIBUTING.md' - }, - { - text: 'API Documentation', - link: '/intro/bruno' - } - ] - }, - { - text: 'Project', - items: [ - { - text: 'Tech Stack', - link: '/project/tech-stack' - }, - { - text: 'Hierarchy', - link: '/project/hierarchy' - }, - { - text: 'How Deployments Work', - link: '/project/deploy' - }, - { - text: 'Stage Server', - link: '/project/stage-server' - }, - { - text: 'Project Roadmap', - link: '/project/roadmap' - } - ] - }, - { - text: '학생 매뉴얼', - items: [ - { - text: 'Main', - link: '/user/main' - }, - { - text: 'Notice', - link: '/user/notice' - }, - { text: 'Contest', link: '/user/contest' }, - { text: 'Group', link: '/user/group' } - ] - }, - { - text: '관리자 매뉴얼', - items: [ - { - text: '가입 및 로그인', - link: '/group-admin/login' - }, - { text: '그룹 및 멤버 관리', link: '/group-admin/group' }, - { text: '문제 생성 및 관리', link: '/group-admin/problem' }, - { - text: 'Notice', - link: '/group-admin/notice' - }, - { text: 'Contest', link: '/group-admin/contest' }, - { text: 'Workbook', link: '/group-admin/workbook' } - ] - } - ] - }, - vite: { - server: { - host: true - } - } -}) diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts deleted file mode 100644 index 9c182f0176..0000000000 --- a/docs/.vitepress/theme/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import DefaultTheme from 'vitepress/theme' -import './vars.css' - -export default { - // root component to wrap each page - ...DefaultTheme, - - enhanceApp({ app, router, siteData }) { - // app is the Vue 3 app instance from `createApp()`. - // router is VitePress' custom router. `siteData` is - // a `ref` of current site-level metadata. - } -} diff --git a/docs/.vitepress/theme/vars.css b/docs/.vitepress/theme/vars.css deleted file mode 100644 index 2ba954d10e..0000000000 --- a/docs/.vitepress/theme/vars.css +++ /dev/null @@ -1,25 +0,0 @@ -:root { - --c-blue: #3581fa; - --c-blue-dark: #2e6de9; - --c-blue-light: #4d9dfc; - - --vp-font-family-base: 'Inter var experimental', 'Inter var', 'Noto Sans KR', - -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, - Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; - - --vp-home-hero-name-color: transparent; - --vp-home-hero-name-background: var(--c-blue); - --vp-c-brand: var(--c-blue); - --vp-c-brand-text: #fff; - - /* Button */ - --vp-button-brand-border: var(--c-blue-dark); - --vp-button-brand-text: var(--vp-c-brand-text); - --vp-button-brand-bg: var(--c-blue); - --vp-button-brand-hover-border: var(--c-blue-light); - --vp-button-brand-hover-text: var(--vp-c-brand-text); - --vp-button-brand-hover-bg: var(--c-blue-light); - --vp-button-brand-active-border: var(--c-blue-dark); - --vp-button-brand-active-text: var(--vp-c-brand-text); - --vp-button-brand-active-bg: var(--vp-button-brand-bg); -} diff --git a/docs/group-admin/contest.md b/docs/group-admin/contest.md deleted file mode 100644 index 5b9d7e0223..0000000000 --- a/docs/group-admin/contest.md +++ /dev/null @@ -1,5 +0,0 @@ -# Group Admin - Contest - -::: warning Work in Progress 🚧 -아직 작성 중인 페이지입니다. 조금만 기다려주세요! 🙏 -::: diff --git a/docs/group-admin/group.md b/docs/group-admin/group.md deleted file mode 100644 index 04a1c66ef3..0000000000 --- a/docs/group-admin/group.md +++ /dev/null @@ -1,89 +0,0 @@ -# 그룹 및 멤버 관리 - -본 Section에서는 그룹 및 멤버 관리 기능에 대해서 소개합니다. - -::: warning Group 내의 계층 관련 ⛔️ -Group 내에서는 두 가지의 계층이 존재합니다. -- __GroupLeader__ : 그룹의 관리자 역할으로, Group 및 Member 관리에 관한 권한들을 갖고 있습니다. -- __GroupMember__ : 그룹 내의 일반적인 Member 역할입니다. - -본 Section의 독자는 GroupLeader로 가정합니다. -::: -## 그룹 관리 - -### 새로운 그룹 생성 - -Manager 이상 계층의 관리자는 직접 그룹을 생성할 수 있습니다. - -Group 페이지에서 +Create Group 버튼을 클릭하면 그룹 생성창으로 넘어갑니다. -![My Groups](pic/myGroup.png) - -원하는 Group Name, Group Configuration, Group Description을 작성하고 Save를 클릭하면 그룹이 생성됩니다. 이때, 그룹을 생성한 관리자는 자동으로 해당 그룹의 GroupLeader로 설정됩니다. -![Create Group](pic/group-create-detail.png) -Group Configuration의 자세한 설명은 아래와 같습니다. - - __Show On List__ : - 자신의 그룹의 전체 공개(All Groups에 표시) 여부를 결정합니다. - - __Allow Join From Search__ : 그룹 목록에서 해당 그룹을 선택한 사용자들에게 그룹 가입 버튼의 공개 여부를 결정합니다. - - __Allow Join With URL__ : 그룹 가입용 URL 발급 가능 여부를 결정합니다. (⚠️ Allow Join From Search와 Allow Join With URL이 모두 False라면 경고창을 반환합니다.) - - __Require Approval Before Join__ : 가입 승인 절차의 유무를 결정합니다. False라면 승인 절차없이 즉시 가입이 완료됩니다. - -### 기존 그룹 관리 -관리자들은 상단 Header 중 Group을 눌러 자신이 속한 그룹들을 확인할 수 있습니다. - -이때, 자신이 GroupLeader로 속한 그룹은 그룹명 우측 상단에 톱니바퀴(⚙️) 모양의 아이콘이 뜨게 됩니다. -![My Groups](pic/myGroup.png) - -톱니바퀴 아이콘(⚙️)을 클릭시 관리자용 그룹 창으로 이동하게 됩니다. - -관리자는 그룹 창에서 (1) 그룹 세부설정 수정 (2) 초대 URL 생성 (3) 그룹 삭제를 할 수 있습니다. -![Group Config](pic/group-detail.png) - -- __그룹 세부설정 수정__ - - 관리자는 그룹명 옆의 연필 아이콘(📝)을 클릭해 그룹 세부 설정을 수정할 수 있습니다. - ![Group Config](pic/group-config-detail.png) - Group Name, Group Configuration, Description을 수정할 수 있습니다. - -- __초대 URL 생성__ - - Create URL 버튼을 클릭하여 학생들이 클릭시 해당 그룹에 가입할 수 있는 일회용 초대 URL을 생성할 수 있습니다. - -- __그룹 삭제__ - - Admin, SuperAdmin 계층의 관리자는 바로 그룹을 삭제할 수 있으며, 이외의 관리자는 자신이 생성한 그룹에 한해서 삭제할 수 있습니다. - -## 멤버 관리 - -![Group Config](pic/group-detail.png) -관리자는 관리자용 그룹창에서 Member 탭을 통해 관리자용 멤버창으로 접속할 수 있습니다. - -![Group Member](pic/group-member.png) - -관리자용 멤버창에서 사용할 수 있는 기능들은 아래와 같습니다. - -### 그룹 내의 계층 변경 - -관리자는 멤버들의 그룹 내 계층을 변경할 수 있습니다. - -계층 변경은 두 가지의 경우가 존재합니다. - -- __GroupLeader에서 GroupMember로 downgrade__ - - Member 탭의 GroupLeaders 목록에서 ⬇ 버튼을 클릭하면 해당 멤버의 계층을 GroupMember로 downgrade할 수 있습니다. - - > 이때, 모든 GroupLeader를 GroupMember로 downgrade할 수 없습니다.(최소 한 명의 GroupLeader가 존재해야 합니다.) - > 또한 Admin 및 SuperAdmin 계층의 사용자는 downgrade할 수 없습니다. - -- __GroupMember에서 GroupLeader로 upgrade__ - - Member 탭의 GroupMembers 목록에서 ⬆ 버튼을 클릭하면 해당 멤버의 계층을 GroupLeader로 upgrade할 수 있습니다. - -### 그룹 멤버 강제 탈퇴 - -관리자는 GroupLeaders 및 GroupMembers 목록에서 🗑️ 버튼을 눌러 멤버를 강제 탈퇴시킬 수 있습니다. - -### 그룹 가입 승인 및 거절 - -![Group Approval](pic/group-approval.png) -관리자는 Group Member Approval 목록에서 대기중인 그룹 가입신청 내역들을 확인할 수 있으며, 승인(✅) 혹은 거절(❎)을 선택할 수 있습니다. - diff --git a/docs/group-admin/login.md b/docs/group-admin/login.md deleted file mode 100644 index 1dd00fc046..0000000000 --- a/docs/group-admin/login.md +++ /dev/null @@ -1,29 +0,0 @@ -# 가입 및 로그인 - -본 Section에서는 현재 코드당의 계층을 소개하고, 가입 및 로그인 관련 기능들을 소개합니다. - -## 계층 소개 - -코드당의 사용자들은 총 네 가지의 계층 중 하나로 속해 있습니다. - -계층의 세부적인 정보는 아래와 같습니다. - -- __SuperAdmin__ : 하위 계층에 부여되는 모든 권한 및 시스템 관리(서버 로그 확인 등)와 관련된 권한을 가집니다. - -- __Admin__ : Admin Dashboard 접근을 통해 모든 Group들의 목록을 접근할 수 있으며, Open Space를 관리할 수 있습니다. 사용자 계층을 변경가능합니다. - -- __Manager__ : Group을 생성할 수 있습니다. - -- __User__ : 일반적인 사용자 계층입니다. - -표로 나타내면 아래와 같습니다. - -| 계층 | SuperAdmin | Admin | Manager | User | -|-|-|-|-|-| -| 그룹 생성 |✔️|✔️|✔️|❌| -| Admin Dashboard 접근 |✔️|✔️|❌|❌| -| OpenSpace 관리 |✔️|✔️|❌|❌| -| 사용자 계층 변경 |✔️|✔️|❌|❌| -| 시스템 관리 |✔️|❌|❌|❌| - -본 __관리자 매뉴얼__ 은 Manager 이상 계층의 사용자를 기준으로 작성되었습니다. diff --git a/docs/group-admin/notice.md b/docs/group-admin/notice.md deleted file mode 100644 index f1242863e4..0000000000 --- a/docs/group-admin/notice.md +++ /dev/null @@ -1,4 +0,0 @@ -# Group Admin - Notice -::: warning Work in Progress 🚧 -아직 작성 중인 페이지입니다. 조금만 기다려주세요! 🙏 -::: diff --git a/docs/group-admin/pic/group-approval.png b/docs/group-admin/pic/group-approval.png deleted file mode 100644 index 28dd379f63..0000000000 Binary files a/docs/group-admin/pic/group-approval.png and /dev/null differ diff --git a/docs/group-admin/pic/group-config-detail.png b/docs/group-admin/pic/group-config-detail.png deleted file mode 100644 index 0faabf2496..0000000000 Binary files a/docs/group-admin/pic/group-config-detail.png and /dev/null differ diff --git a/docs/group-admin/pic/group-create-detail.png b/docs/group-admin/pic/group-create-detail.png deleted file mode 100644 index 849977afdb..0000000000 Binary files a/docs/group-admin/pic/group-create-detail.png and /dev/null differ diff --git a/docs/group-admin/pic/group-detail.png b/docs/group-admin/pic/group-detail.png deleted file mode 100644 index 04cbce183e..0000000000 Binary files a/docs/group-admin/pic/group-detail.png and /dev/null differ diff --git a/docs/group-admin/pic/group-member.png b/docs/group-admin/pic/group-member.png deleted file mode 100644 index f19ddbd83f..0000000000 Binary files a/docs/group-admin/pic/group-member.png and /dev/null differ diff --git a/docs/group-admin/pic/management.png b/docs/group-admin/pic/management.png deleted file mode 100644 index 7b98e0f9f6..0000000000 Binary files a/docs/group-admin/pic/management.png and /dev/null differ diff --git a/docs/group-admin/pic/myGroup.png b/docs/group-admin/pic/myGroup.png deleted file mode 100644 index b943ec7a7a..0000000000 Binary files a/docs/group-admin/pic/myGroup.png and /dev/null differ diff --git a/docs/group-admin/pic/problem-create-detail.png b/docs/group-admin/pic/problem-create-detail.png deleted file mode 100644 index bbc43f8526..0000000000 Binary files a/docs/group-admin/pic/problem-create-detail.png and /dev/null differ diff --git a/docs/group-admin/pic/problem-create.png b/docs/group-admin/pic/problem-create.png deleted file mode 100644 index 31ad97f864..0000000000 Binary files a/docs/group-admin/pic/problem-create.png and /dev/null differ diff --git a/docs/group-admin/problem.md b/docs/group-admin/problem.md deleted file mode 100644 index 317ba8cb13..0000000000 --- a/docs/group-admin/problem.md +++ /dev/null @@ -1,51 +0,0 @@ -# 문제 생성 및 관리하기 -본 Section에서는 문제를 생성하고 관리하는 기능들을 소개합니다. - -## 문제 생성 및 삭제하기 -관리자들은 상단 Header 중 Group을 눌러 자신이 속한 그룹들을 확인할 수 있습니다. - -이때, 자신이 GroupLeader로 속한 그룹은 그룹명 우측 상단에 톱니바퀴(⚙️) 모양의 아이콘이 뜨게 됩니다. -![My Groups](pic/myGroup.png) - -톱니바퀴 아이콘(⚙️)을 클릭시 관리자용 그룹 창으로 이동하게 됩니다. - -![Group Config](pic/group-detail.png) - -관리자는 옆 Problem 탭을 통해 관리자용 Problem 창으로 이동할 수 있습니다. - -![Problem Create](pic/problem-create.png) - -### 문제 생성하기 - -그룹 내에서 문제를 생성할 수 있는 방법은 세 경우가 있습니다. - -- __문제를 직접 생성하는 경우__ (Create버튼) - - Create버튼을 누르면 직접 문제를 생성할 수 있습니다. - ![Problem Create](pic/problem-create-detail.png) - 문제의 세부 구성사항은 아래와 같습니다. - - __Title__ : 문제의 제목을 설정합니다. - - __Difficulty__ : 문제의 난이도를 설정합니다 Level 1~5까지 존재합니다. - - __Language__ : 문제의 지원언어를 설정합니다. 종류는 `C`, `C++`,`Java`, `Python3`로 이루어져 있습니다. - - __Description__ : 문제의 설명을 설정합니다. - - __Input Description__ : 입력과 관련한 설명을 설정합니다. - - __Output Description__ : 출력과 관련한 설명을 설정합니다. - - __Time Limit(ms)__ : 시간 제한을 설정합니다. 단위는 ms(밀리초)입니다. - - __Memory Limit(MB)__ : 메모리 제한을 설정합니다. 단위는 MB(메가바이트)입니다. - - __Hint__ : 표시될 힌트를 설정합니다. - - __Input Sample, Output Sample__ : 사용자에게 보여질 입력, 출력 예시를 설정합니다. - - __Testcase__ : 테스트케이스를 설정합니다. - -- __문제를 Import하는 경우__ (Import버튼) - - 추후 추가할 예정입니다. - -- __문제를 파일로 업로드 하는 경우__ (File Upload버튼) - - Excel 파일을 업로드하여 여러 개의 문제 및 테스트 케이스들을 업로드할 수 있습니다. - -### 문제 삭제하기 - -문제 옆 🗑️ 버튼을 클릭하면 문제를 삭제할 수 있습니다. - - diff --git a/docs/group-admin/workbook.md b/docs/group-admin/workbook.md deleted file mode 100644 index 6b48898b80..0000000000 --- a/docs/group-admin/workbook.md +++ /dev/null @@ -1,5 +0,0 @@ -# Group Admin - Workbook - -::: warning Work in Progress 🚧 -아직 작성 중인 페이지입니다. 조금만 기다려주세요! 🙏 -::: diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 4bb63e881f..0000000000 --- a/docs/index.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -layout: home - -hero: - name: Codedang - text: with SKKUDING 😎 - tagline: Online Judge for SKKU - image: - src: /logo.svg - alt: Codedang Logo - actions: - - theme: brand - text: Get Started - link: /intro/ - - theme: alt - text: View on GitHub - link: https://github.com/skkuding/next - -features: - - title: 👍 Easy to use - details: With imbedded code editor, you can code and submit easily. - - title: 🌈 Open Source - details: Codedang is open source project. You can contribute to this project freely! - - title: 🚦 Scalable - details: Codedang cares about stability and scalability. It can handle many users at the same time. ---- diff --git a/docs/intro/backend-preview.png b/docs/intro/backend-preview.png deleted file mode 100644 index ff72940c30..0000000000 Binary files a/docs/intro/backend-preview.png and /dev/null differ diff --git a/docs/intro/bruno-env.png b/docs/intro/bruno-env.png deleted file mode 100644 index 0a1313635d..0000000000 Binary files a/docs/intro/bruno-env.png and /dev/null differ diff --git a/docs/intro/bruno-select.png b/docs/intro/bruno-select.png deleted file mode 100644 index 79e4e9caf3..0000000000 Binary files a/docs/intro/bruno-select.png and /dev/null differ diff --git a/docs/intro/bruno-start.png b/docs/intro/bruno-start.png deleted file mode 100644 index 2ea6f52e99..0000000000 Binary files a/docs/intro/bruno-start.png and /dev/null differ diff --git a/docs/intro/bruno.md b/docs/intro/bruno.md deleted file mode 100644 index ca07c7cf54..0000000000 --- a/docs/intro/bruno.md +++ /dev/null @@ -1,128 +0,0 @@ -# API Documentation - -이 프로젝트에서는 API 문서화 도구로 Bruno를 사용하고 있습니다. Bruno는 Postman, Insomnia와 같이 API를 테스트하고 문서화하는 오픈 소스 도구입니다. - -자세한 내용은 공식 홈페이지에서 확인할 수 있습니다. -https://www.usebruno.com/ - -![Bruno](https://www.usebruno.com/images/landing-2.png) - -## How to use - -Bruno로 Codedang API collection을 보려면 아래 단계를 따르세요. - -### 1. Bruno 설치 - -Bruno 홈페이지에서 설치 파일을 다운로드 받아 설치하세요. -https://www.usebruno.com/downloads - -### 2. Collection 불러오기 - -Bruno를 실행하고 'Open Collection'을 클릭합니다. -Codedang 폴더에서 collection 폴더의 client 또는 admin을 선택합니다. - -![Open Collection](bruno-start.png) - -### 3. 환경 변수 설정 - -오른쪽 맨 위의 'No Environment'를 클릭하고, 환경을 변경합니다. - -- Development: 개발 서버 (https://dev.codedang.com) -- Local: 로컬 서버 (http://localhost) - -![Environment](bruno-env.png) - -### 4. 확인 - -왼쪽 탭에서 request를 선택하여 실행할 수 있습니다. `baseUrl`이 초록색으로 표시되는지 확인해주세요. - -![Select](bruno-select.png) - -## Convention 🤙 - -새로운 API request를 작성할 때 아래 사항을 지켜주세요. - -- 모든 예외 경우마다 request를 작성해주세요. (200, 403, 404 등) -- 설명문은 평어체로 작성해주세요. (~ 한다 등) - -### Structure - -APP 구분 / 모듈 이름 / 기능 / request 이름 -(예: Codedang Admin / Notice / Create Notice / Succeed) - -#### request 이름 - -각 request는 다음과 같이 이름 지어주세요. - -- 성공하는 경우 - - **"Succeed"** - - **"Succeed: \"**: 성공하는 경우 + 추가 설명 (예: "Succeed: Admin Login") -- 실패하는 경우 - - REST API - - **"40x: \"**: 400, 401, 403, 404 등의 오류가 발생하는 경우 + 추가 설명 (예: "40x: Invalid email") - - GraphQL - - **""**: GraphQL Error Code - - **"(1)"**: 동일한 Error Code가 발생하는 case가 여러 개인 경우 - -### Docs - -Endpoint마다 'Succeed' request의 'Docs' 탭에 아래 Format을 준수하여 설명을 남겨주세요. - -#### Format - -```markdown -## API 제목 -API가 수행하는 역할을 기재합니다. - -### Args / Query / Params / Body -필요한 경우, Args/Query/Params/Body에 대한 설명을 표로 정리합니다. -이름, 타입, 의미, 기본값, 제약사항을 포함해야 합니다. -(예) -| 이름 | 타입 | 설명 | -|--|--|--| -|take|Int|한번에 가져올 데이터의 수, 기본값은 10.| -|groupId|Int|포함하지 않으면 Open Space에 대한 요청이 된다. 기본값은 1.| -|problemId|Int|problemId와 contestId중 하나는 반드시 포함한다.| -|contestId|Int|problemId와 contestId중 하나는 반드시 포함한다.| - - -### Error Case -API를 호출했을 때 발생 가능한 Error Case의 이름과 설명을 기재합니다. -Error case의 이름은 실패하는 파일의 이름과 일치시켜주세요. -이름은 '#' 4개, 설명은 줄글로 작성합니다. -(예) -#### BAD_USER_INPUT(1) -password가 조건에 맞지 않는 경우. -``` - -### Scripts - -로그인 및 권한이 필요한 request의 경우, Pre Request에서 사전 인증 작업을 수행해주세요. GraphQL의 경우, Admin 계정으로 로그인하는 작업이 전역으로 적용되어 있습니다. - -~~Request를 보낼 때 상황별로 결과가 달라지지 않게 해주세요. 다시 말해 **언제나 동일한 결과**가 오게 해주세요.~~ - -### Assert - -모든 request마다 test를 충분히 작성해주세요(상태 코드 검사, body 검사 등). PR이 merge될 때마다 자동으로 E2E 검사가 이뤄집니다. - -다음 항목을 포함해야 합니다. -#### REST API -- 성공하는 경우 - - res.status - - res.body의 모든 property -- 실패하는 경우 - - res.status - - res.message -#### GraphQL -- 성공하는 경우 - - res.body.data[0] 존재 유무 -- 실패하는 경우 - - res.body.errors[0].extensions.code - - res.body.errors[0].message - -### GraphQL - -![GraphQL Docs](graphql-docs.png) -GraphQL 서버 개발 시, field 타입을 정확하게 지정하여 Docs panel만으로 필드 및 반환 객체의 이름과 타입 정보를 알 수 있게 해 주세요. - -Query나 Mutation에 인자가 들어가는 경우, `Variables` 항목에 분리하여 작성해주세요. diff --git a/docs/intro/frontend-preview.png b/docs/intro/frontend-preview.png deleted file mode 100644 index ddbbe3e7b8..0000000000 Binary files a/docs/intro/frontend-preview.png and /dev/null differ diff --git a/docs/intro/getting-started.md b/docs/intro/getting-started.md deleted file mode 100644 index 1243a1efb6..0000000000 --- a/docs/intro/getting-started.md +++ /dev/null @@ -1,162 +0,0 @@ -# Getting Started! - -스꾸딩 팀과 함께 SKKU Coding Platform 개발을 시작하려면 아래 가이드를 따라주세요. -가능한 개발 환경은 크게 세 가지가 있습니다. - -1. Visual Studio Code + Container -2. GitPod -3. Manual - -가장 권장하는 개발 환경은 Container 내에서 개발하는 Visual Studio Code이지만, 어려울 경우에는 GitPod을 이용하거나 직접 세팅할 수도 있습니다. - -## Visual Studio Code (이하 VSCode) - -### 1. 기본 도구 설치 (Git, WSL2(Windows), Docker) - -- **Git**: Windows는 [https://git-scm.com/download/win](https://git-scm.com/download/win)에서 다운로드하고, Mac은 [https://git-scm.com/download/mac](https://git-scm.com/download/mac)에서 다운로드합니다. -Linux는 패키지 관리도구로 쉽게 설치할 수 있습니다. (예: Debian 계열인 경우 `sudo apt install git-all`) - -- **WSL2(Windows)**: 자세한 설치 방법은 [WSL 설치 공식 가이드](https://docs.microsoft.com/ko-kr/windows/wsl/install)를 참고해주세요. - -- **Docker**: Windows는 WSL2를 먼저 설치하고, [Docker Desktop for Windows](https://docs.docker.com/desktop/install/windows-install/)를 설치하면 됩니다. -Mac은 [Docker Desktop for Mac](https://docs.docker.com/desktop/install/mac-install/)을 설치하면 됩니다. -Linux는 자신의 배포판에 맞는 버전을 [공식 홈페이지](https://docs.docker.com/engine/install/)에서 찾아 설치하면 됩니다. - -### 2. VSCode 설치 - -[VSCode 홈페이지](https://code.visualstudio.com/)에서 VSCode 설치 파일을 다운로드 받고, 파일을 실행하여 설치합니다. - -![VSCode Download](vscode-download.png) - -### 3. Remote - Containers 확장 설치 - -왼쪽의 'Extensions' icon을 눌러(단축키 Ctrl+Shift+X, ⇧⌘X) "remote containers"를 검색창에 입력합니다. -'Remote - Containers'를 선택하고 'Install' 버튼을 눌러 설치합니다. - -![Remote - Containers](remote-containers.png) - -### 4. Clone Repository - -GitHub에서 [skkuding/next](https://github.com/skkuding/next) repository를 clone 받습니다. -왼쪽의 'Source Control' icon을 눌러(단축키 Ctrl+Shift+G, ⌃⇧G) 'Clone Repository' 버튼을 누르고, skkuding/next를 검색하여 원하는 위치에 받습니다. - -::: warning Windows 유저라면... -Windows file system에 clone 받는 것보다 WSL file system에 clone 받는 것을 권장합니다. -[파일 저장 시 인식하지 못하는 문제](https://github.com/microsoft/WSL/issues/4739)를 비롯한 여러 문제가 있습니다. -WSL에 clone 받는 방법은 아래 설명을 참고해주세요. -::: - -![Git Clone in VSCode](git-clone.png) - -#### 4-1. WSL에 Clone 받기 (Windows만) - -3번처럼 'Extensions' 탭을 열어 "remote wsl"을 검색창에 입력합니다. -'Remote - WSL'을 선택하고 'Install' 버튼을 눌러 설치합니다. - -![Remote - WSL](remote-wsl.png) - -왼쪽 아래의 `><` 모양 아이콘을 누르고 'New WSL Window' 옵션을 선택하여 WSL 환경에서 VSCode를 시작합니다. - -![New WSL Window](new-wsl-window.png) - -이후 4번과 같은 방식으로 WSL 내에 clone하면 됩니다. - -### 5. VSCode로 repository 열기 - -왼쪽의 'Explorer' icon을 눌러(단축키 Ctrl+Shift+E, ⇧⌘E) 'Open Folder' 버튼을 누르고, clone 받았던 repository 폴더를 엽니다. - -Repository가 열리면 좌측 하단의 `><` 모양 아이콘을 누르고, "Reopen in Container" 옵션을 선택합니다. -이후 자동으로 Docker container가 생성되며 도구와 라이브러리, VSCode 확장들이 설치됩니다. -초기 구성에는 5~10분 정도 소요되지만, 다시 실행할 때에는 오래 걸리지 않습니다. - -![Reopen in Container](reopen-in-container.png) - -### 6. Preview server 열기 - -Container 세팅이 완료되면, 터미널을 열어(단축키 Ctrl+\`, ⌃\`) 명령어를 입력해 개발용 preview server를 시작합니다. - -#### 6-1. Frontend - -```sh -cd frontend -pnpm dev -``` - -Story(component 문서)를 보고 싶으면, `pnpm story` 명령어를 입력해주세요. - -![Frontend Preview](frontend-preview.png) - -#### 6-2. Backend - -```sh -cd backend -pnpm start:dev -``` - -![Backend Preview](backend-preview.png) - -## GitPod - -컴퓨터의 사양이 부족하거나 로컬 개발 환경이 제한적인 경우 브라우저로 원격 개발을 하는 GitPod이 좋습니다. -한 달에 50시간까지 무료고, 학생 인증을 하면 한 달에 9달러로 제한 없이 사용할 수 있습니다. -아래 버튼을 눌러 바로 시작하거나 아래 설명을 따르면 됩니다. - -[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/skkuding/next) - -### 1. GitHub Repository 열기 - -브라우저에서 [skkuding/next](https://github.com/skkuding/next) repository를 엽니다. - -> TODO: img - -### 2. URL 입력 - -GitHub 전체 URL의 앞에 `gitpod.io/#`을 입력하고 해당 주소로 접속합니다. -예: `gitpod.io/#https://github.com/skkuding/next` - -::: tip -GitPod은 branch, pull request, commit 등의 context 별로 workspace를 생성하는 것도 가능합니다! - -- Branch `123-feat-name`의 코드로 workspace를 생성하려면: `gitpod.io/#https://github.com/skkuding/next/tree/123-feat-name` -- PR #123의 코드로 workspace를 생성하려면: `gitpod.io/#https://github.com/skkuding/next/pull/123` - -더 자세한 내용은 [공식 문서](https://www.gitpod.io/docs/introduction/learn-gitpod/context-url)에서 확인해주세요. -::: - -> TODO: img - -### 3. GitHub 계정 연결 - -화면에 나오는 대로 GitHub 계정을 GitPod에 연결합니다. - -> TODO: img - -### 4. Preview server 열기 - -GitPod이 자동으로 세팅을 마치면 [위의 Visual Studio Code와 같은 방법](#_6-preview-server-열기)으로 preview server를 열 수 있습니다. - -## Manually - -::: warning Not Recommended 🤔 -직접 모든 환경을 세팅하는 것은 특별한 경우가 아니라면 권장하지 않습니다. -꼭 필요한 경우에만 사용해주세요! -::: - -### 1. 기본 도구 설치 (Git, WSL2(Windows), Docker) - -[위의 Visual Studio Code와 같은 방법](#_1-기본-도구-설치-git-wsl2-windows-docker)으로 기본 도구들을 설치해주세요. - -### 2. Node.js 설치 - -```sh -curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash -``` - -```sh -nvm install node -nvm use node -``` - -### 3. setup script 실행 - -### 4. Visual Studio Code 확장 diff --git a/docs/intro/git-clone.png b/docs/intro/git-clone.png deleted file mode 100644 index 09e82d36c2..0000000000 Binary files a/docs/intro/git-clone.png and /dev/null differ diff --git a/docs/intro/graphql-docs.png b/docs/intro/graphql-docs.png deleted file mode 100644 index 976abde45b..0000000000 Binary files a/docs/intro/graphql-docs.png and /dev/null differ diff --git a/docs/intro/index.md b/docs/intro/index.md deleted file mode 100644 index b51f617a9d..0000000000 --- a/docs/intro/index.md +++ /dev/null @@ -1,21 +0,0 @@ -# What is Coding Platform? - -Coding Platform은 성균관대학교 Online Judge 시스템입니다. -소프트웨어융합대학 알고리즘 동아리인 NPC에서 2020년 처음 시작하여, 2022년에 SKKUDING(스꾸딩) 팀으로 개발 팀이 분리되어 지금까지 개발을 맡고 있습니다. - -플랫폼의 목표는 성균관대 학우들의 코딩 실력을 증진시키는 것입니다. -프로그래밍 대회에 참가하여 자신의 실력을 확인하고 선의의 경쟁을 펼칠 수 있으며, 다양한 연습 문제로 실력을 키울 수 있습니다. -기존의 OJ에서 부족했거나 우리 학교에 필요했던 기능을 직접 만들고 커스터마이징할 수 있습니다. - -이 플랫폼의 큰 특징은 시스템을 구성하는 대부분을 오픈 소스로 공개한다는 점입니다([GitHub](https://github.com/skkuding)). -개발에 SKKUDING 팀원만 참여하는 것이 아닌, 누구나 수정하고 싶은 기능을 건의하고, 심지어 직접 구현할 수 있습니다. (PR은 언제나 환영이에요!) - -## About SKKUDING - -SKKUDING은 Coding Platform을 주도적으로 개발하고 있는 동아리입니다. -2022년 2월에 공식적으로 NPC 동아리에서 분리되었고, 비전공생들도 함께하며 성장을 주된 목표로 활동하고 있습니다. - -SKKUDING은 실력과 성과에 치중하기보다는 팀원 개개인의 성장을 지향합니다. -모두 실무 경험이 없는 학생들이고 비전공생들도 다수 있기에, 접하기 어려운 큰 규모의 프로젝트에 참가한다는 경험을 의의로 합니다. - -혹시 SKKUDING 팀에 함께하고 싶다면, [카카오톡 채널](https://pf.kakao.com/_UKraK/chat)을 통해서 운영진들에게 연락해보세요. diff --git a/docs/intro/new-wsl-window.png b/docs/intro/new-wsl-window.png deleted file mode 100644 index f2057cb499..0000000000 Binary files a/docs/intro/new-wsl-window.png and /dev/null differ diff --git a/docs/intro/remote-containers.png b/docs/intro/remote-containers.png deleted file mode 100644 index 8cc9766a6a..0000000000 Binary files a/docs/intro/remote-containers.png and /dev/null differ diff --git a/docs/intro/remote-wsl.png b/docs/intro/remote-wsl.png deleted file mode 100644 index 1a4f33bd7a..0000000000 Binary files a/docs/intro/remote-wsl.png and /dev/null differ diff --git a/docs/intro/reopen-in-container.png b/docs/intro/reopen-in-container.png deleted file mode 100644 index 335a624bab..0000000000 Binary files a/docs/intro/reopen-in-container.png and /dev/null differ diff --git a/docs/intro/vscode-download.png b/docs/intro/vscode-download.png deleted file mode 100644 index 74ce4c8ef8..0000000000 Binary files a/docs/intro/vscode-download.png and /dev/null differ diff --git a/docs/project/deploy-pipeline.png b/docs/project/deploy-pipeline.png deleted file mode 100644 index 1d793c7754..0000000000 Binary files a/docs/project/deploy-pipeline.png and /dev/null differ diff --git a/docs/project/deploy.md b/docs/project/deploy.md deleted file mode 100644 index 02ed4cc5f6..0000000000 --- a/docs/project/deploy.md +++ /dev/null @@ -1,5 +0,0 @@ -# How to deploy? - -::: warning Work in Progress 🚧 -아직 작성 중인 페이지입니다. 조금만 기다려주세요! 🙏 -::: diff --git a/docs/project/github-actions-runner.png b/docs/project/github-actions-runner.png deleted file mode 100644 index 6e2633b2f7..0000000000 Binary files a/docs/project/github-actions-runner.png and /dev/null differ diff --git a/docs/project/github-projects.png b/docs/project/github-projects.png deleted file mode 100644 index f98ce12bfb..0000000000 Binary files a/docs/project/github-projects.png and /dev/null differ diff --git a/docs/project/hierarchy.md b/docs/project/hierarchy.md deleted file mode 100644 index 659342b60b..0000000000 --- a/docs/project/hierarchy.md +++ /dev/null @@ -1,5 +0,0 @@ -# Hierarchy - -::: warning Work in Progress 🚧 -아직 작성 중인 페이지입니다. 조금만 기다려주세요! 🙏 -::: diff --git a/docs/project/roadmap.md b/docs/project/roadmap.md deleted file mode 100644 index 562a169b35..0000000000 --- a/docs/project/roadmap.md +++ /dev/null @@ -1,5 +0,0 @@ -# Project Planning - -::: warning Work in Progress 🚧 -아직 작성 중인 페이지입니다. 조금만 기다려주세요! 🙏 -::: diff --git a/docs/project/stage-server.md b/docs/project/stage-server.md deleted file mode 100644 index 61d68e4870..0000000000 --- a/docs/project/stage-server.md +++ /dev/null @@ -1,66 +0,0 @@ -# Stage Server - -스테이지 서버는 배포 전 테스트를 위한 서버로, [dev.codedang.com](https://dev.codedang.com/)에서 확인할 수 있습니다. - -![배포 과정](./deploy-pipeline.png) - -`main` 브랜치에 올라간 코드는 `deployment` environment에서 자동으로 스테이지 서버에 배포되며, -수동으로 production 배포를 trigger하면 `production` environment에서 진행합니다. - -## Stage Server 세팅 - -### Prerequisite - -- Docker -- PNPM -- 2 Core 4GB RAM 이상의 서버 -- x86_64 CPU Architecture (ARM은 지원하지 않습니다.) - -### 1. Docker 컨테이너 생성 - -우선 배포할 서버에 SSH로 접속 후, repository를 clone합니다. - -```bash -git clone https://github.com/skkuding/codedang -``` - -그 후, `codedang` 폴더로 이동하여 Docker Compose를 `deploy` profile로 실행합니다. - -```bash -docker compose --profile deploy up -d -``` - -### 2. Frontend Build (선택) - -::: tip 어떤 경우에 build해야 하나요? -컨테이너 생성만 하면 frontend build는 진행되지 않습니다. -그래서 바로 서버에 접속하면 404 에러가 발생해요. -대신 GitHub Actions로 배포가 진행되면 frontend build가 자동으로 이뤄지고 접속이 가능해집니다. -하지만 GitHub Actions까지 세팅하기 전 바로 접속하고 싶다면 이 단계를 진행해주세요. -::: - -`codedang` 폴더에서 frontend를 빌드합니다. - -```bash -pnpm install -pnpm --filter frontend build -``` - -이후 Caddy 컨테이너에 빌드된 파일을 업로드하고 재시작합니다. - -```bash -docker cp frontend/dist caddy:/var/www/html -docker exec -w /etc/caddy caddy caddy reload -``` - -이후 `https://<서버 주소>`로 접속하면, 화면이 나타납니다. - -### 3. GitHub Action Runner 세팅 - -자동 배포를 위해 GitHub Action의 Self-hosted Runner로 등록합니다. - -GitHub의 codedang repository에서 `Settings` > 좌측 사이드 바 `Actions` > `Runners` > `New self-hosted runner`를 클릭합니다. - -이후 설명된 내용을 따라 Runner를 등록합니다. - -![GitHub Action Runner 등록](./github-actions-runner.png) diff --git a/docs/project/tech-stack.md b/docs/project/tech-stack.md deleted file mode 100644 index 36281ebfc8..0000000000 --- a/docs/project/tech-stack.md +++ /dev/null @@ -1,157 +0,0 @@ -# Tech Stack - -Coding Platform은 Frontend, Backend, DevOps 세 팀으로 나누어 프로젝트를 진행합니다. - -| 분류 | Stacks | -|:--------:|-------------| -| 공통 | Visual Studio Code, GitHub, pnpm, Typescript, ESLint, Prettier, Lefthook | -| Frontend | Vue.js, Vite, Tailwind CSS, Pinia, Histoire | -| Backend | Node.js, Nest.js, Express, Mocha, Prisma, PostgreSQL, Redis | -| DevOps | Docker, AWS | - -## 공통 - -### GitHub - -프로젝트 코드 저장부터 이슈 관리, CI/CD 등 다양한 용도로 활용하고 있습니다. -GitHub Issues에 모든 task를 저장하여 스꾸딩 팀의 업무 단위로 활용하고 있고, pull request로 코드 리뷰를 거치며 의견을 교환합니다. -또한 GitHub Actions로 CI/CD 시스템을 구축하여 테스팅과 배포를 자동화하였습니다. - -![GitHub Projects](github-projects.png) - -### pnpm - -[pnpm](https://pnpm.io/)은 Node.js package manager로, npm과 yarn에 비해 월등히 속도가 빠르고 디스크 공간을 절약할 수 있습니다. -또한 monorepo 기능이 내장되어있어 frontend와 backend의 의존성을 별도로 관리하는 이 프로젝트에 적합합니다. -Frontend와 backend의 중복되는 의존성은 pnpm에 의해 모두 자동으로 하나로 관리됩니다. - -::: tip -보통 라이브러리를 설치할 때 `npm i ` 또는 `yarn add `처럼 NPM과 Yarn을 활용한 예시가 많습니다. -이를 그대로 따라하면 NPM 또는 Yarn의 lockfile이 새로 생성되고 pnpm의 것은 무시됩니다. -따라서 이 프로젝트에서는 `pnpm add ` 명령어를 이용하여 설치해야 합니다. (`devDependency`의 경우 `-D` 옵션을 추가해주세요!) -::: - -::: warning -pnpm은 symbolic link를 적극적으로 사용하기 때문에 일부 라이브러리에서 관련 issue가 발생할 수 있습니다. -혹시 프로젝트에서 알 수 없는 문제가 발생했다면 pnpm을 키워드에 넣어 검색해보세요. -::: - -### Typescript - -[Typescript](https://www.typescriptlang.org/)는 Javascript에 정적 타입을 추가한 언어로, 엄격한 문법을 통해 생산성을 높입니다. -VSCode에 Typescript가 내장되어있어 자동 완성이나 문법 분석 등 여러 편의 기능을 제공하고, 다양한 이슈를 runtime이 아닌 build time에 잡을 수 있다는 강점이 있습니다. -Frontend는 Vue 3에서 Typescript를 적극적으로 지원하며, backend는 Nest.js가 Typescript를 기반으로 제작된 framework입니다. - -### ESLint, Prettier - -[ESLint](https://eslint.org/)는 정적 코드 분석 도구로, 문법적 오류나 컨벤션 위반 등을 잡아줍니다. -[Prettier](https://prettier.io/)은 자동 코드 formatter로, 일관된 코드 형식을 자동으로 잡아줍니다. -가이드에 맞춰 VSCode로 개발 환경을 제대로 구성했다면 ESLint와 Prettier은 자동으로 설정됩니다. -또한 commit hook과 CI 단계에도 적용되어있어 main branch에 반영되기 전에 오류를 검출합니다. - -### Lefthook - -[Lefthook](https://github.com/evilmartians/lefthook)은 Git hook을 관리해주는 도구로, 앞서 언급한 linting이나 formatting 등을 commit하는 시점에 실행해줍니다. -보통 Git hook 관리 도구로 Husky를 많이 사용하지만, Lefthook이 훨씬 속도가 빠르고 여러 기능들을 제공합니다. - -## Frontend - -### Vue 3 - -Frontend framework로 [Vue 3](https://vuejs.org/)을 사용합니다. -지금은 구현 편의 상 SPA를 사용하고 있지만, 추후 SSR 또는 SSG 적용을 위해 [Nuxt 3](https://v3.nuxtjs.org/)을 사용할 예정입니다. -아직 Nuxt 3을 도입하기에는 Nuxt 문서가 충분히 완성되지 않았고 stable release 단계에 이르지 않았기 때문에 Nuxt가 어느 정도 성숙해지면 도입할 예정입니다. -React와 Svelte 등의 대안을 두고 Vue를 사용한 이유는 다음과 같습니다. - -- **React**: React는 frontend library 중 가장 큰 커뮤니티를 가지고 있어 자료 검색이나 라이브러리 도입이 쉽다는 장점이 있습니다. -또한 Next.js라는 훌륭한 SSR/SSG 용 framework도 있고, 채용 시장에서 수요도 제일 많습니다. -하지만 난이도가 비교적 높아 진입장벽이 높고 Vue에 비해 문법이 직관적이지 못합니다. -Vue 2에 대해서는 React가 Typescript 지원에서 크게 앞서있었지만, Vue 3에서는 Composition API로 여러 문제가 해결되며 React의 메리트가 줄어들었습니다. - -- **Svelte**: Svelte는 신생 frontend library로 매우 직관적인 문법이 특징입니다. -하지만 신생 라이브러리인 만큼 학습 자료나 라이브러리가 부족하여 개발 경험이 적은 팀원들에게 적합하지 않습니다. -또한 [자체적으로 설계 문제](https://gist.github.com/rabelais88/19bfe8dfd29d901554389f0a8cc8947a)가 있어 추후 scalable한 운영이 어려울 수 있습니다. - -기존에 스꾸딩 팀이 QingdaoU OJ 기반의 Online Judge를 구축할 때부터 Vue를 써온 것도 Vue를 택한 이유 중 하나입니다. -더 상세한 배경은 [issue #8](https://github.com/skkuding/next/issues/8#issuecomment-1065856244)에서 확인할 수 있습니다. - -### Vite - -[Vite](https://vitejs.dev/)는 Vue의 제작자인 Evan You가 만든 module bundler로, Vue 팀에서 공식적으로 권장하는 tool입니다. -ES module을 적극적으로 활용하고 dependecies를 esbuild로 bundle하기 때문에 매우 빠른 속도가 특징입니다. -사용하기 쉽고, 활용할 수 있는 plugin들도 많습니다. -이 프로젝트에서 사용하고 있는 plugin은 다음과 같습니다. - -- [`vite-plugin-pages`](https://github.com/hannoeru/vite-plugin-pages): File system 기반의 routing을 자동으로 만들어줍니다. -- [`unplugin-icons`](https://github.com/antfu/unplugin-icons): Iconset들을 쉽게 불러올 수 있는 plugin입니다. -[Icônes](https://icones.js.org/)에서 아이콘들을 확인할 수 있습니다. - -### Tailwind CSS - -[Tailwind CSS](https://tailwindcss.com/)는 CSS framework로, utility-first라는 특징을 가지고 있습니다. -각각의 CSS 속성들이 CSS class로 정의되어있기 때문에 inline style처럼 style을 정의할 수 있습니다. -이 프로젝트에서 Tailwind CSS를 사용하는 이유는 (1) 진입장벽이 낮고 사용법이 쉬우며, (2) 일관된 look & feel을 구현하기에 용이하기 때문입니다. - -### Pinia - -[Pinia](https://pinia.vuejs.org/)는 기존의 Vuex를 대체하는 상태 관리 라이브러리로, Vue 팀에서 공식적으로 권장하고 있습니다. -직관적인 문법과 뛰어난 Typescript 지원 등이 특징입니다. - -### Histoire - -[Histoire](https://histoire.dev/)은 UI component를 위한 문서화 라이브러리로, 잘 알려진 [Storybook](https://storybook.js.org/)과 동일한 용도입니다. -Storybook 대신 Histoire을 사용하는 이유는 (1) Histoire은 Vue 문법을 사용하기 때문에 쉽고 간결하게 작성할 수 있고, (2) Vite가 내장되어있어 빠릅니다. -Histoire가 Storybook에 비하면 기능도 훨씬 적고 자료도 부족하지만, 간단한 문서화 용도로는 충분하다고 판단하였고, Vue가 익숙한 팀원들이 쉽게 작성할 수 있을 것이라 생각해 Histoire을 택하였습니다. - -### 이 외 - -- [VueUse](https://vueuse.org/): 상태 관리, 시간, 반응형 등 다양한 유틸리티 함수들의 모음입니다. - -- [NProgress](https://ricostacruz.com/nprogress/): 페이지 상단에 progress bar를 표시해줍니다. - -## Backend - -### Node.js - -[Node.js](https://nodejs.org/ko/)는 서버에서 Javascript를 실행하는 runtime 환경입니다. -원래 Javascript는 웹 브라우저에서만 실행 가능하지만, Node.js는 Chrome의 V8엔진에 파일 시스템과 네트워킹 등의 API를 추가해 서버에서 활용할 수 있게끔 만들었습니다. -성능도 뛰어나고 frontend와 동일한 언어를 사용하기 때문에 의존성 관리가 용이합니다. - -### NestJS - -[NestJS](https://nestjs.com/)는 scalabe한 app을 만들기 위한 구조를 제공하는 framework입니다. -Python의 Django와 달리 Node.js에는 그동안 구조가 정해진 backend framework가 없었는데, 그 공백을 채운 것이 NestJS입니다. -그 구조가 Angular나 Java Spring과 유사하며 객체 지향(특히 Dependency Injection)을 핵심으로 합니다. - -### Express - -[Express](http://expressjs.com/ko/)는 Node.js 환경에서 가장 유명한 backend library로, 간단하게 web app을 만들 수 있습니다. -우리 프로젝트에서는 직접 Express를 쓰는 대신 NestJS가 Express를 감싸고 있습니다. -지금은 관련 자료가 풍부하다는 이유로 Express를 사용하고 있지만, 추후 성능 개선이 필요한 시점이 오면 [Fastify](https://www.fastify.io/)로 대체할 수 있습니다. - -### Mocha - -일반적으로 Node.js 생태계에서 testing framework로 [Jest](https://jestjs.io/)가 가장 많이 쓰이지만, Jest의 성능 문제로 우리 프로젝트에서는 [Mocha](https://mochajs.org)를 대신 사용합니다. (관련 issue: [#299](https://github.com/skkuding/next/issues/299)) -Mocha와 함께 assertion 기능을 제공하는 [Chai](https://www.chaijs.com), mocking과 fake 함수 기능을 제공하는 [Sinon](https://sinonjs.org)을 사용합니다. - -### Prisma - -[Prisma](https://www.prisma.io/)는 ORM으로, 직관적으로 사용할 수 있고 Typescript를 적극적으로 지원합니다. -ORM이란 database에 SQL로 직접 query문을 전달하는 대신, Typescript 등의 언어로 mapping 시켜주는 도구를 말합니다. -Prisma는 각 model들을 type으로 생성해주기 때문에 Typescript로 쉽게 query문을 작성할 수 있습니다. - -### PostgreSQL - -[PostgreSQL](https://www.postgresql.org/)은 널리 쓰이는 관계형 database입니다. -많이 비교되는 MySQL보다 다양한 기능을 제공합니다. - -### Redis - -[Redis](https://redis.io/)는 in-memory 저장소로, 이 프로젝트에서는 주로 caching과 message queue를 위해 쓰입니다. (추후 message queue는 Amazon SQS으로 대체할 수 있습니다) - - -## DevOps - -::: warning Work in Progress 🚧 -배포 완료 후 작성 예정입니다! -::: diff --git a/docs/public/logo.png b/docs/public/logo.png deleted file mode 100644 index 1b92bce156..0000000000 Binary files a/docs/public/logo.png and /dev/null differ diff --git a/docs/public/logo.svg b/docs/public/logo.svg deleted file mode 100644 index a2339a1e07..0000000000 --- a/docs/public/logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/user/contest.md b/docs/user/contest.md deleted file mode 100644 index 229f354db3..0000000000 --- a/docs/user/contest.md +++ /dev/null @@ -1,7 +0,0 @@ -# User - Contest - -::: warning Work in Progress 🚧 -아직 작성 중인 페이지입니다. 조금만 기다려주세요! 🙏 -::: - - diff --git a/docs/user/group.md b/docs/user/group.md deleted file mode 100644 index 3a24c42933..0000000000 --- a/docs/user/group.md +++ /dev/null @@ -1,5 +0,0 @@ -# User - Group - -::: warning Work in Progress 🚧 -아직 작성 중인 페이지입니다. 조금만 기다려주세요! 🙏 -::: diff --git a/docs/user/main.md b/docs/user/main.md deleted file mode 100644 index a1955c719c..0000000000 --- a/docs/user/main.md +++ /dev/null @@ -1,5 +0,0 @@ -# 코드당 시작하기 - -::: warning Work in Progress 🚧 -아직 작성 중인 페이지입니다. 조금만 기다려주세요! 🙏 -::: diff --git a/docs/user/notice.md b/docs/user/notice.md deleted file mode 100644 index 77515e5777..0000000000 --- a/docs/user/notice.md +++ /dev/null @@ -1,5 +0,0 @@ -# User - Notice - -::: warning Work in Progress 🚧 -아직 작성 중인 페이지입니다. 조금만 기다려주세요! 🙏 -::: diff --git a/docs/user/problem.md b/docs/user/problem.md deleted file mode 100644 index c97193f0c5..0000000000 --- a/docs/user/problem.md +++ /dev/null @@ -1,5 +0,0 @@ -# User - Problem - -::: warning Work in Progress 🚧 -아직 작성 중인 페이지입니다. 조금만 기다려주세요! 🙏 -::: diff --git a/frontend-client/.eslintrc.js b/frontend-client/.eslintrc.js index 3ec55e95bc..ca3136496b 100644 --- a/frontend-client/.eslintrc.js +++ b/frontend-client/.eslintrc.js @@ -22,7 +22,23 @@ module.exports = { namedComponents: 'function-declaration' } ], - 'func-style': ['off'] + 'func-style': ['off'], + 'no-restricted-imports': [ + 'error', + { + name: '@apollo/client', + importNames: ['gql'], + message: 'Please use @generated instead.' + }, + { + name: '@/__generated__', + message: 'Please use @generated instead.' + }, + { + name: '@/__generated__/graphql', + message: 'Please use @generated/graphql instead.' + } + ] } } ] diff --git a/frontend-client/.gitignore b/frontend-client/.gitignore index fd3dbb571a..16e26f00cf 100644 --- a/frontend-client/.gitignore +++ b/frontend-client/.gitignore @@ -34,3 +34,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# GraphQL Codegen +__generated__/ diff --git a/frontend-client/Dockerfile b/frontend-client/Dockerfile deleted file mode 100644 index 5733b0c351..0000000000 --- a/frontend-client/Dockerfile +++ /dev/null @@ -1,62 +0,0 @@ -# [NOTE] Build image from the root directory of this repository. -# ex) `docker build -f frontend-client/Dockerfile .` - -FROM node:20-alpine AS base - -# Install dependencies only when needed -FROM base AS deps -# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. -RUN apk add --no-cache libc6-compat -WORKDIR /app - -# Install dependencies -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY frontend-client ./frontend-client -RUN corepack enable && pnpm --filter=frontend-client deploy out - - -# Rebuild the source code only when needed -FROM base AS builder - -WORKDIR /app -COPY --from=deps /app/out . - -# Next.js collects completely anonymous telemetry data about general usage. -# Learn more here: https://nextjs.org/telemetry -# Uncomment the following line in case you want to disable telemetry during the build. -# ENV NEXT_TELEMETRY_DISABLED 1 - -RUN npm run build - - -# Production image, copy all the files and run next -FROM base AS runner -WORKDIR /app - -ENV NODE_ENV production -# Uncomment the following line in case you want to disable telemetry during runtime. -# ENV NEXT_TELEMETRY_DISABLED 1 - -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 nextjs - -COPY --from=builder /app/public ./public - -# Set the correct permission for prerender cache -RUN mkdir .next -RUN chown nextjs:nodejs .next - -# Automatically leverage output traces to reduce image size -# https://nextjs.org/docs/advanced-features/output-file-tracing -COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static - -USER nextjs - -EXPOSE 5525 - -ENV PORT 5525 -# set hostname to localhost -ENV HOSTNAME "0.0.0.0" - -CMD ["node", "server.js"] diff --git a/frontend-client/Dockerfile.dockerignore b/frontend-client/Dockerfile.dockerignore deleted file mode 100644 index c18d4657c7..0000000000 --- a/frontend-client/Dockerfile.dockerignore +++ /dev/null @@ -1,7 +0,0 @@ -Dockerfile -Dockerfile.dockerignore -node_modules -npm-debug.log -README.md -.next -.git diff --git a/frontend-client/app/(main)/_components/SignIn.tsx b/frontend-client/app/(main)/_components/SignIn.tsx index c02e90ba47..a03862abfd 100644 --- a/frontend-client/app/(main)/_components/SignIn.tsx +++ b/frontend-client/app/(main)/_components/SignIn.tsx @@ -37,7 +37,7 @@ export default function SignIn() { if (!res?.error) { router.refresh() - toast.success('Successfully logged in') + toast.success('Login Successful') } else { toast.error('Failed to log in') } diff --git a/frontend-client/app/(main)/contest/[id]/@tabs/_components/ParticipateButton.tsx b/frontend-client/app/(main)/contest/[contestId]/@tabs/_components/ParticipateButton.tsx similarity index 100% rename from frontend-client/app/(main)/contest/[id]/@tabs/_components/ParticipateButton.tsx rename to frontend-client/app/(main)/contest/[contestId]/@tabs/_components/ParticipateButton.tsx diff --git a/frontend-client/app/(main)/contest/[id]/@tabs/announcement/_components/Columns.tsx b/frontend-client/app/(main)/contest/[contestId]/@tabs/announcement/_components/Columns.tsx similarity index 100% rename from frontend-client/app/(main)/contest/[id]/@tabs/announcement/_components/Columns.tsx rename to frontend-client/app/(main)/contest/[contestId]/@tabs/announcement/_components/Columns.tsx diff --git a/frontend-client/app/(main)/contest/[id]/@tabs/announcement/_components/styles.css b/frontend-client/app/(main)/contest/[contestId]/@tabs/announcement/_components/styles.css similarity index 100% rename from frontend-client/app/(main)/contest/[id]/@tabs/announcement/_components/styles.css rename to frontend-client/app/(main)/contest/[contestId]/@tabs/announcement/_components/styles.css diff --git a/frontend-client/app/(main)/contest/[id]/@tabs/announcement/page.tsx b/frontend-client/app/(main)/contest/[contestId]/@tabs/announcement/page.tsx similarity index 88% rename from frontend-client/app/(main)/contest/[id]/@tabs/announcement/page.tsx rename to frontend-client/app/(main)/contest/[contestId]/@tabs/announcement/page.tsx index 8c6098f408..3f1ff99e9d 100644 --- a/frontend-client/app/(main)/contest/[id]/@tabs/announcement/page.tsx +++ b/frontend-client/app/(main)/contest/[contestId]/@tabs/announcement/page.tsx @@ -4,17 +4,17 @@ import type { ContestAnnouncement } from '@/types/type' import { columns } from './_components/Columns' interface ContestAnnouncementProps { - params: { id: string } + params: { contestId: string } } export default async function ContestAnnouncement({ params }: ContestAnnouncementProps) { - const { id } = params + const { contestId } = params const contestAnnouncements: ContestAnnouncement[] = await fetcher .get('announcement', { searchParams: { - contestId: id + contestId } }) .json() @@ -28,7 +28,6 @@ export default async function ContestAnnouncement({ content: 'w-[70%]', updateTime: 'w-[18%]' }} - name="" /> ) } diff --git a/frontend-client/app/(main)/contest/[id]/@tabs/page.tsx b/frontend-client/app/(main)/contest/[contestId]/@tabs/page.tsx similarity index 82% rename from frontend-client/app/(main)/contest/[id]/@tabs/page.tsx rename to frontend-client/app/(main)/contest/[contestId]/@tabs/page.tsx index 1fc912a8f2..8d02b8eec6 100644 --- a/frontend-client/app/(main)/contest/[id]/@tabs/page.tsx +++ b/frontend-client/app/(main)/contest/[contestId]/@tabs/page.tsx @@ -10,14 +10,14 @@ interface ContestTop { interface ContestTopProps { params: { - id: string + contestId: string } } export default async function ContestTop({ params }: ContestTopProps) { const session = await auth() - const { id } = params - const data: ContestTop = await fetcher.get(`contest/${id}`).json() + const { contestId } = params + const data: ContestTop = await fetcher.get(`contest/${contestId}`).json() const startTime = new Date(data.startTime) const currentTime = new Date() @@ -29,7 +29,7 @@ export default async function ContestTop({ params }: ContestTopProps) { /> {session && currentTime < startTime && (
- +
)} diff --git a/frontend-client/app/(main)/contest/[id]/@tabs/problem/_components/Columns.tsx b/frontend-client/app/(main)/contest/[contestId]/@tabs/problem/_components/Columns.tsx similarity index 100% rename from frontend-client/app/(main)/contest/[id]/@tabs/problem/_components/Columns.tsx rename to frontend-client/app/(main)/contest/[contestId]/@tabs/problem/_components/Columns.tsx diff --git a/frontend-client/app/(main)/contest/[id]/@tabs/problem/page.tsx b/frontend-client/app/(main)/contest/[contestId]/@tabs/problem/page.tsx similarity index 81% rename from frontend-client/app/(main)/contest/[id]/@tabs/problem/page.tsx rename to frontend-client/app/(main)/contest/[contestId]/@tabs/problem/page.tsx index 7113551a1f..4be813c3a8 100644 --- a/frontend-client/app/(main)/contest/[id]/@tabs/problem/page.tsx +++ b/frontend-client/app/(main)/contest/[contestId]/@tabs/problem/page.tsx @@ -4,24 +4,20 @@ import type { ContestProblem } from '@/types/type' import { columns } from './_components/Columns' interface ContestProblemProps { - params: { id: string } + params: { contestId: string } } export default async function ContestProblem({ params }: ContestProblemProps) { - const { id } = params + const { contestId } = params const { problems }: { problems: ContestProblem[] } = await fetcher .get('problem', { searchParams: { take: 10, - contestId: id + contestId } }) .json() - problems.forEach((problem) => { - problem.id = problem.problemId - }) - return ( ) } diff --git a/frontend-client/app/(main)/contest/[id]/@tabs/standings/page.tsx b/frontend-client/app/(main)/contest/[contestId]/@tabs/standings/page.tsx similarity index 100% rename from frontend-client/app/(main)/contest/[id]/@tabs/standings/page.tsx rename to frontend-client/app/(main)/contest/[contestId]/@tabs/standings/page.tsx diff --git a/frontend-client/app/(main)/contest/[id]/layout.tsx b/frontend-client/app/(main)/contest/[contestId]/layout.tsx similarity index 93% rename from frontend-client/app/(main)/contest/[id]/layout.tsx rename to frontend-client/app/(main)/contest/[contestId]/layout.tsx index 954247db48..7fc2c22f5f 100644 --- a/frontend-client/app/(main)/contest/[id]/layout.tsx +++ b/frontend-client/app/(main)/contest/[contestId]/layout.tsx @@ -8,7 +8,7 @@ import ContestTabs from '../_components/ContestTabs' interface ContestDetailProps { params: { - id: string + contestId: string } } @@ -24,8 +24,8 @@ export default async function Layout({ params: ContestDetailProps['params'] tabs: React.ReactNode }) { - const { id } = params - const res = await fetcher.get(`contest/${id}`) + const { contestId } = params + const res = await fetcher.get(`contest/${contestId}`) if (res.ok) { const data: Contest = await res.json() const currentTime = new Date() @@ -74,7 +74,7 @@ export default async function Layout({ )} - + {tabs} ) diff --git a/frontend-client/app/(main)/contest/[id]/page.tsx b/frontend-client/app/(main)/contest/[contestId]/page.tsx similarity index 91% rename from frontend-client/app/(main)/contest/[id]/page.tsx rename to frontend-client/app/(main)/contest/[contestId]/page.tsx index 02cf30e041..61d89b5f93 100644 --- a/frontend-client/app/(main)/contest/[id]/page.tsx +++ b/frontend-client/app/(main)/contest/[contestId]/page.tsx @@ -4,14 +4,14 @@ import { sanitize } from 'isomorphic-dompurify' interface ContestDetailProps { params: { - id: string + contestId: string } } export default async function ContestDetail({ params }: ContestDetailProps) { - const { id } = params + const { contestId } = params const { title, startTime, endTime, description } = await fetch( - baseUrl + `/contest/${id}` + baseUrl + `/contest/${contestId}` ).then((res) => res.json()) return (
diff --git a/frontend-client/app/(main)/contest/_components/FinishedContestTable.tsx b/frontend-client/app/(main)/contest/_components/FinishedContestTable.tsx index e7fffddaa2..8ec483c636 100644 --- a/frontend-client/app/(main)/contest/_components/FinishedContestTable.tsx +++ b/frontend-client/app/(main)/contest/_components/FinishedContestTable.tsx @@ -26,7 +26,7 @@ export default async function FinishedContestTable() { participants: 'w-1/5 md:w-1/6', status: 'w-1/4 md:w-1/6' }} - name="contest" + linked /> ) diff --git a/frontend-client/app/(main)/notice/_components/NoticeTable.tsx b/frontend-client/app/(main)/notice/_components/NoticeTable.tsx index b6126d07d9..7807c317b3 100644 --- a/frontend-client/app/(main)/notice/_components/NoticeTable.tsx +++ b/frontend-client/app/(main)/notice/_components/NoticeTable.tsx @@ -42,7 +42,7 @@ export default async function NoticeTable({ search }: Props) { createdBy: 'w-1/4 md:w-1/6', createTime: 'w-1/4 md:w-1/6' }} - name="notice" + linked /> ) } diff --git a/frontend-client/app/(main)/problem/_components/ProblemTable.tsx b/frontend-client/app/(main)/problem/_components/ProblemTable.tsx index a95f2a18d0..2942a75fad 100644 --- a/frontend-client/app/(main)/problem/_components/ProblemTable.tsx +++ b/frontend-client/app/(main)/problem/_components/ProblemTable.tsx @@ -30,7 +30,7 @@ export default async function ProblemTable({ search, order }: Props) { acceptedRate: 'w-2/12', info: 'w-1/12' }} - name="problem" + linked /> ) } diff --git a/frontend-client/app/admin/_components/ApolloProvider.tsx b/frontend-client/app/admin/_components/ApolloProvider.tsx new file mode 100644 index 0000000000..86122a32d2 --- /dev/null +++ b/frontend-client/app/admin/_components/ApolloProvider.tsx @@ -0,0 +1,44 @@ +'use client' + +import { auth } from '@/lib/auth' +import { adminBaseUrl } from '@/lib/vars' +import { + ApolloClient, + ApolloLink, + ApolloProvider, + InMemoryCache, + createHttpLink +} from '@apollo/client' +import { setContext } from '@apollo/client/link/context' + +interface Props { + children: React.ReactNode +} + +export default function ClientApolloProvider({ children }: Props) { + const httpLink = createHttpLink({ + uri: adminBaseUrl + }) + const authLink = setContext(async (_, { headers }) => { + const session = await auth() + return { + headers: { + ...headers, + authorization: session?.token.accessToken + } + } + }) + const link = ApolloLink.from([authLink.concat(httpLink)]) + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link, + defaultContext: { + fetchOptions: { + next: { revalidate: 0 } + } + } + }) + + return {children} +} diff --git a/frontend-client/app/admin/layout.tsx b/frontend-client/app/admin/layout.tsx index 380a6bc47b..d399e33753 100644 --- a/frontend-client/app/admin/layout.tsx +++ b/frontend-client/app/admin/layout.tsx @@ -3,33 +3,37 @@ import { Separator } from '@/components/ui/separator' import type { Route } from 'next' import Link from 'next/link' import { FaArrowRightFromBracket } from 'react-icons/fa6' +import ClientApolloProvider from './_components/ApolloProvider' // import GroupSelect from './_components/GroupSelect' import SideBar from './_components/SideBar' export default function Layout({ children }: { children: React.ReactNode }) { return ( -
- - - {children} -
+ +
+ + + +
{children}
+
+
) } diff --git a/frontend-client/app/admin/problem/[id]/page.tsx b/frontend-client/app/admin/problem/[id]/page.tsx new file mode 100644 index 0000000000..f0c061ed1b --- /dev/null +++ b/frontend-client/app/admin/problem/[id]/page.tsx @@ -0,0 +1,843 @@ +'use client' + +import { gql } from '@generated' +import CheckboxSelect from '@/components/CheckboxSelect' +import OptionSelect from '@/components/OptionSelect' +import TagsSelect from '@/components/TagsSelect' +import TextEditor from '@/components/TextEditor' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Popover, + PopoverContent, + PopoverTrigger +} from '@/components/ui/popover' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' +import { cn } from '@/lib/utils' +import type { Language, Sample, Testcase } from '@/types/type' +import { useMutation, useQuery } from '@apollo/client' +import type { UpdateProblemInput } from '@generated/graphql' +import { zodResolver } from '@hookform/resolvers/zod' +import Link from 'next/link' +import { useEffect, useState } from 'react' +import { useForm, Controller } from 'react-hook-form' +import { FaEye, FaEyeSlash } from 'react-icons/fa' +import { FaAngleLeft } from 'react-icons/fa6' +import { HiLockClosed, HiLockOpen } from 'react-icons/hi' +import { IoMdCheckmarkCircleOutline } from 'react-icons/io' +import { MdHelpOutline } from 'react-icons/md' +import { PiWarningBold } from 'react-icons/pi' +import { toast } from 'sonner' +import { z } from 'zod' +import ExampleTextarea from '../_components/ExampleTextarea' +import Label from '../_components/Lable' +import type { TemplateLanguage } from '../utils' +import { + GET_TAGS, + inputStyle, + languageMapper, + languageOptions, + levels +} from '../utils' + +const GET_PROBLEM = gql(` + query GetProblem($groupId: Int!, $id: Int!) { + getProblem(groupId: $groupId, id: $id) { + title + isVisible + difficulty + languages + problemTag { + tag { + id + name + } + } + description + inputDescription + outputDescription + samples { + id + input + output + } + problemTestcase { + input + output + } + timeLimit + memoryLimit + hint + source + template + } + } +`) + +const UPDATE_PROBLEM = gql(` + mutation UpdateProblem($groupId: Int!, $input: UpdateProblemInput!) { + updateProblem(groupId: $groupId, input: $input) { + id + createdById + groupId + title + isVisible + difficulty + languages + problemTag { + tag { + id + name + } + } + description + inputDescription + outputDescription + samples { + input + output + } + problemTestcase { + input + output + } + timeLimit + memoryLimit + hint + source + template + } + } +`) + +const schema = z.object({ + id: z.number(), + title: z.string().min(1).max(25), + isVisible: z.boolean(), + difficulty: z.enum(['Level1', 'Level2', 'Level3', 'Level4', 'Level5']), + languages: z.array( + z.enum(['C', 'Cpp', 'Golang', 'Java', 'Python2', 'Python3']) + ), + tags: z + .object({ create: z.array(z.number()), delete: z.array(z.number()) }) + .optional(), + description: z.string().min(1), + inputDescription: z.string().min(1), + outputDescription: z.string().min(1), + samples: z.object({ + create: z.array( + z + .object({ input: z.string().min(1), output: z.string().min(1) }) + .optional() + ), + delete: z.array(z.number().optional()) + }), + testcases: z + .array( + z.object({ + input: z.string().min(1), + output: z.string().min(1), + scoreWeight: z.number().optional() + }) + ) + .min(1), + timeLimit: z.number().min(0), + memoryLimit: z.number().min(0), + hint: z.string().optional(), + source: z.string().optional(), + template: z + .array( + z + .object({ + language: z.enum([ + 'C', + 'Cpp', + 'Golang', + 'Java', + 'Python2', + 'Python3' + ]), + code: z.array( + z.object({ + id: z.number(), + text: z.string(), + locked: z.boolean() + }) + ) + }) + .optional() + ) + .optional() +}) + +export default function Page({ params }: { params: { id: string } }) { + const { id } = params + const [showHint, setShowHint] = useState(true) + const [showSource, setShowSource] = useState(true) + const [samples, setSamples] = useState([{ input: '', output: '' }]) + const [testcases, setTestcases] = useState([ + { input: '', output: '' } + ]) + const [languages, setLanguages] = useState([]) + + const { data: tagsData } = useQuery(GET_TAGS) + const tags = + tagsData?.getTags.map(({ id, name }) => ({ id: +id, name })) ?? [] + + const { data: problemData } = useQuery(GET_PROBLEM, { + variables: { + groupId: 1, + id: +id + } + }) + + const fetchedDescription = problemData?.getProblem.description + const fetchedDifficulty = problemData?.getProblem.difficulty + const fetchedLangauges = problemData?.getProblem.languages ?? [] + const fetchedTags = + problemData?.getProblem.problemTag.map(({ tag }) => +tag.id) ?? [] + + const fetchedTemplateLanguage = + problemData?.getProblem.template?.map( + (template: string) => JSON.parse(template)[0]?.language + ) ?? [] + + useEffect(() => { + setLanguages( + problemData?.getProblem.languages?.map((language: Language) => ({ + language, + isVisible: fetchedTemplateLanguage.includes(language) ? true : false + })) ?? [] + ) + setSamples(problemData?.getProblem.samples ?? []) + setTestcases(problemData?.getProblem.problemTestcase ?? []) + }, [problemData]) + + const { + handleSubmit, + control, + register, + getValues, + setValue, + formState: { errors } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + samples: { create: [], delete: [] }, + template: [] + } + }) + + if (problemData) { + const data = problemData.getProblem + setValue('id', +id) + setValue('title', data.title) + setValue('isVisible', data.isVisible) + setValue('difficulty', data.difficulty) + setValue('languages', data.languages ?? []) + setValue( + 'tags.create', + data.problemTag.map((problemTag) => Number(problemTag.tag.id)) + ) + setValue( + 'tags.delete', + data.problemTag.map((problemTag) => Number(problemTag.tag.id)) + ) + setValue('description', data.description) + setValue('inputDescription', data.inputDescription) + setValue('outputDescription', data.outputDescription) + setValue('samples.create', data?.samples || []) + setValue('testcases', data.problemTestcase) + setValue('timeLimit', data.timeLimit) + setValue('memoryLimit', data.memoryLimit) + setValue('hint', data.hint) + setValue('source', data.source) + setValue( + 'template', + data.template?.map((template: string) => { + const parsedTemplate = JSON.parse(template)[0] + return { + language: parsedTemplate?.language, + code: [ + { + id: parsedTemplate?.code[0].id, + text: parsedTemplate?.code[0].text, + locked: parsedTemplate?.code[0].locked + } + ] + } + }) + ) + } + + const [updateProblem, { error }] = useMutation(UPDATE_PROBLEM) + const onSubmit = async (input: UpdateProblemInput) => { + console.log(input) + const tagsToDelete = getValues('tags.delete') + const tagsToCreate = getValues('tags.create') + input.tags!.create = tagsToCreate.filter( + (tag) => !tagsToDelete.includes(tag) + ) + input.tags!.delete = tagsToDelete.filter( + (tag) => !tagsToCreate.includes(tag) + ) + + await updateProblem({ + variables: { + groupId: 1, + input + } + }) + if (error) { + toast.error('Failed to update problem') + return + } + toast.success('Problem updated successfully') + } + + const addSample = () => { + const values = getValues('samples.create') + const newSample = { input: '', output: '' } + setValue('samples.create', [...values, newSample]) + setSamples((prev) => [...prev, newSample]) + } + + const addTestcase = () => { + const values = getValues('testcases') ?? [] + const newTestcase = { input: '', output: '' } + setValue('testcases', [...values, newTestcase]) + setTestcases([...testcases, newTestcase]) + } + + const removeSample = (index: number) => { + if (samples.length <= 1) { + toast.warning('At least one sample is required') + return + } + const samplesToDelete = getValues('samples.delete') + setValue('samples.delete', [...samplesToDelete, index]) + setSamples(samples.filter((_, i) => i !== index)) + } + + const removeTestcase = (index: number) => { + const values = getValues('testcases') ?? [] + if (values.length <= 1) { + toast.warning('At least one testcase is required') + return + } + const updatedValues = values.filter((_, i) => i !== index) + setValue('testcases', updatedValues) + } + + return ( + +
+
+ + + + Edit Problem +
+ +
+
+
+ + + {errors.title && ( +
+ + {getValues('title')?.length === 0 + ? 'required' + : errors.title.message?.toString()} +
+ )} +
+
+
+ + + + + + +
    +
  • For contest, 'hidden' is recommended.
  • +
  • You can edit these settings later.
  • +
+
+
+
+ +
+ ( +
+ + +
+ )} + /> +
+ {errors.isVisible && ( +
+ + required +
+ )} +
+
+ +
+ +
+
+ ( + + )} + name="difficulty" + control={control} + /> + {errors.difficulty && ( +
+ + required +
+ )} +
+
+ ( + { + field.onChange(selectedLanguages) + setLanguages( + selectedLanguages.map((language) => ({ + language, + isVisible: + languages.filter( + (prev) => prev.language === language + ).length > 0 + ? languages.filter( + (prev) => prev.language === language + )[0].isVisible + : false + })) as TemplateLanguage[] + ) + }} + defaultValue={fetchedLangauges} + /> + )} + name="languages" + control={control} + /> + {errors.languages && ( +
+ + required +
+ )} +
+
+ ( + + )} + name="tags.create" + control={control} + /> + {errors.tags && ( +
+ + required +
+ )} +
+
+
+ +
+ + {fetchedDescription && ( + ( + + )} + name="description" + control={control} + /> + )} + {errors.description && ( +
+ + required +
+ )} +
+ +
+
+
+ +