diff --git a/.env.development b/.env.development index 320240aa..b0aedc46 100644 --- a/.env.development +++ b/.env.development @@ -6,7 +6,16 @@ VITE_AVATAR_UPLOAD_URI=/upload/avatar VITE_AVATAR_DELETE_URI=/upload/avatar VITE_TRAINING_UPLOAD_URI=/upload/training/:id VITE_TRAINING_DELETE_URI=/upload/training/:id -VITE_HOMEWORK_FILE_UPLOAD_URI=/homework/:homeWorkId/file -VITE_HOMEWORK_FILE_GET_URI=/homework/:homeWorkId/file/:fileId -VITE_HOMEWORK_FILE_DELETE_URI=/homework/:homeWorkId/file -VITE_APP_ENDPOINT="http://app-stage.qa.guru:8080" +VITE_HOMEWORK_FILE_UPLOAD_URI=/student/homework/:homeWorkId/file +VITE_HOMEWORK_FILE_GET_URI=/student/homework/:homeWorkId/file/:fileId +VITE_HOMEWORK_FILE_DELETE_URI=/student/homework/:homeWorkId/file/:fileId +VITE_LECTURE_FILE_UPLOAD_URI=/lecture/:lectureId/file +VITE_LECTURE_FILE_GET_URI=/lecture/:lectureId/file/:fileId +VITE_LECTURE_FILE_DELETE_URI=/lecture/:lectureId/file/:fileId +VITE_LECTURE_HOMEWORK_FILE_UPLOAD_URI=/lecture/:lectureId/homework/file +VITE_LECTURE_HOMEWORK_FILE_GET_URI=/lecture/:lectureId/homework/file/:fileId +VITE_LECTURE_HOMEWORK_FILE_DELETE_URI=/lecture/:lectureId/homework/file/:fileId +VITE_HOMEWORK_COMMENT_FILE_UPLOAD_URI=/homework/comment/:commentId/file +VITE_HOMEWORK_COMMENT_FILE_GET_URI=/homework/comment/:commentId/file/:fileId +VITE_HOMEWORK_COMMENT_FILE_DELETE_URI=/homework/comment/:commentId/file/:fileId +VITE_APP_ENDPOINT="http://app-stage.qa.guru:8080" \ No newline at end of file diff --git a/.env.production b/.env.production index 24b4831a..f3954bc0 100644 --- a/.env.production +++ b/.env.production @@ -6,6 +6,15 @@ VITE_AVATAR_UPLOAD_URI=/api/upload/avatar VITE_AVATAR_DELETE_URI=/api/upload/avatar VITE_TRAINING_UPLOAD_URI=/api/upload/training/:id VITE_TRAINING_DELETE_URI=/api/upload/training/:id -VITE_HOMEWORK_FILE_UPLOAD_URI=/api/homework/:homeWorkId/file -VITE_HOMEWORK_FILE_GET_URI=/api/homework/:homeWorkId/file/:fileId -VITE_HOMEWORK_FILE_DELETE_URI=/api/homework/:homeWorkId/file \ No newline at end of file +VITE_HOMEWORK_FILE_UPLOAD_URI=/api/student/homework/:homeWorkId/file +VITE_HOMEWORK_FILE_GET_URI=/api/student/homework/:homeWorkId/file/:fileId +VITE_HOMEWORK_FILE_DELETE_URI=/api/student/homework/:homeWorkId/file/:fileId +VITE_LECTURE_FILE_UPLOAD_URI=/api/lecture/:lectureId/file +VITE_LECTURE_FILE_GET_URI=/api/lecture/:lectureId/file/:fileId +VITE_LECTURE_FILE_DELETE_URI=/api/lecture/:lectureId/file/:fileId +VITE_LECTURE_HOMEWORK_FILE_UPLOAD_URI=/api/lecture/:lectureId/homework/file +VITE_LECTURE_HOMEWORK_FILE_GET_URI=/api/lecture/:lectureId/homework/file/:fileId +VITE_LECTURE_HOMEWORK_FILE_DELETE_URI=/api/lecture/:lectureId/homework/file/:fileId +VITE_HOMEWORK_COMMENT_FILE_UPLOAD_URI=/api/homework/comment/:commentId/file +VITE_HOMEWORK_COMMENT_FILE_GET_URI=/api/homework/comment/:commentId/file/:fileId +VITE_HOMEWORK_COMMENT_FILE_DELETE_URI=/api/homework/comment/:commentId/file/:fileId \ No newline at end of file diff --git a/.github/workflows/frontend_deploy_test.yml b/.github/workflows/frontend_deploy_test.yml index c8e1ab65..6b05593e 100644 --- a/.github/workflows/frontend_deploy_test.yml +++ b/.github/workflows/frontend_deploy_test.yml @@ -33,3 +33,12 @@ jobs: run: # rm -rf /var/www/app.qa.guru/html/* cp -r dist/* /var/www/app-stage.qa.guru/html/ + + runtest_jenkins: + runs-on: stage-runner + needs: yarn-and-node + steps: + - name: jenkins + run: curl -X POST "https://jenkins.autotests.cloud/buildByToken/buildWithParameters?job=qa-guru-app-tests&token=$TOKEN&TAG=UI" + env: + TOKEN: ${{ secrets.JENKINS_TOKEN }} diff --git a/codegen.ts b/codegen.ts index cd79220c..59c5f940 100644 --- a/codegen.ts +++ b/codegen.ts @@ -2,7 +2,10 @@ import type { CodegenConfig } from "@graphql-codegen/cli"; import "dotenv/config"; const config: CodegenConfig = { - schema: `${process.env.APP_ENDPOINT}${process.env.GRAPHQL_URI}`, + schema: [ + `${process.env.APP_ENDPOINT}${process.env.GRAPHQL_URI}`, + "src/api/schema.graphql", + ], documents: ["src/**/*.graphql"], generates: { "src/api/graphql/generated/graphql.tsx": { diff --git a/src/api/graphql/homework/create-homework-to-check.graphql b/src/api/graphql/homework/create-homework-to-check.graphql index f15ffd66..2a318ced 100644 --- a/src/api/graphql/homework/create-homework-to-check.graphql +++ b/src/api/graphql/homework/create-homework-to-check.graphql @@ -17,6 +17,7 @@ mutation createHomeWorkToCheck( answer status training { + id techStack } student { diff --git a/src/api/graphql/homework/homework-by-lecture-and-training.graphql b/src/api/graphql/homework/homework-by-lecture-and-training.graphql index 7b6d0062..ef02412c 100644 --- a/src/api/graphql/homework/homework-by-lecture-and-training.graphql +++ b/src/api/graphql/homework/homework-by-lecture-and-training.graphql @@ -9,6 +9,7 @@ query homeWorkByLectureAndTraining($lectureId: ID!, $trainingId: ID!) { answer status training { + id techStack } student { diff --git a/src/api/graphql/homework/send-homework-to-check.graphql b/src/api/graphql/homework/send-hom-work-to-check.graphql similarity index 79% rename from src/api/graphql/homework/send-homework-to-check.graphql rename to src/api/graphql/homework/send-hom-work-to-check.graphql index b6174b10..35c147c9 100644 --- a/src/api/graphql/homework/send-homework-to-check.graphql +++ b/src/api/graphql/homework/send-hom-work-to-check.graphql @@ -4,13 +4,13 @@ mutation sendHomeWorkToCheck($homeWorkId: ID!) { lecture { id subject + description contentHomeWork } - answer - status training { techStack } + answer student { id firstName @@ -29,7 +29,15 @@ mutation sendHomeWorkToCheck($homeWorkId: ID!) { rating } } + filesHomeWork { + id + creationDate + fileName + contentType + size + } creationDate + updateDate startCheckingDate endCheckingDate } diff --git a/src/api/graphql/homework/update-homework.graphql b/src/api/graphql/homework/update-homework.graphql index 78b315c5..7b0f6189 100644 --- a/src/api/graphql/homework/update-homework.graphql +++ b/src/api/graphql/homework/update-homework.graphql @@ -9,6 +9,7 @@ mutation updateHomework($id: ID!, $content: String!) { answer status training { + id techStack } student { @@ -29,6 +30,13 @@ mutation updateHomework($id: ID!, $content: String!) { rating } } + filesHomeWork { + id + creationDate + fileName + contentType + size + } creationDate startCheckingDate endCheckingDate diff --git a/src/api/graphql/lecture/lecture.graphql b/src/api/graphql/lecture/lecture.graphql index f792ca23..7f49c08f 100644 --- a/src/api/graphql/lecture/lecture.graphql +++ b/src/api/graphql/lecture/lecture.graphql @@ -14,5 +14,13 @@ query lecture($id: ID!) { subject description content + files { + id + homeWork + creationDate + fileName + contentType + size + } } } diff --git a/src/api/graphql/user/user.graphql b/src/api/graphql/user/user.graphql index 94106d40..452dffbe 100644 --- a/src/api/graphql/user/user.graphql +++ b/src/api/graphql/user/user.graphql @@ -11,6 +11,7 @@ query user { linkedin avatar creationDate + phoneNumber rating { rating } diff --git a/src/api/rest/auth-service.ts b/src/api/rest/auth-service.ts index 9f06fc82..d4e5e678 100644 --- a/src/api/rest/auth-service.ts +++ b/src/api/rest/auth-service.ts @@ -1,8 +1,7 @@ import axios, { type AxiosResponse } from "axios"; +import { LOGIN_URI, LOGOUT_URI, REFRESH_TOKEN_URI } from "config"; import qs from "qs"; -import { LOGIN_URI, LOGOUT_URI, REFRESH_TOKEN_URI } from "../../config"; - export interface LoginResponse { username: string; password: string; diff --git a/src/api/rest/avatar-upload-service.ts b/src/api/rest/avatar-upload-service.ts index 006f5d4e..279714cd 100644 --- a/src/api/rest/avatar-upload-service.ts +++ b/src/api/rest/avatar-upload-service.ts @@ -1,6 +1,5 @@ import axios, { type AxiosResponse } from "axios"; - -import { AVATAR_UPLOAD_URI, AVATAR_DELETE_URI } from "../../config"; +import { AVATAR_DELETE_URI, AVATAR_UPLOAD_URI } from "config"; export interface AvatarUploadResponse { file: string | File; diff --git a/src/api/rest/homework-comment-serivce.ts b/src/api/rest/homework-comment-serivce.ts new file mode 100644 index 00000000..0b29dbdd --- /dev/null +++ b/src/api/rest/homework-comment-serivce.ts @@ -0,0 +1,71 @@ +import axios, { type AxiosResponse } from "axios"; +import { + HOMEWORK_COMMENT_FILE_UPLOAD_URI, + HOMEWORK_COMMENT_FILE_GET_URI, + HOMEWORK_COMMENT_FILE_DELETE_URI, +} from "config"; + +import { createUrlWithParams } from "shared/utils"; + +export interface HomeworkFileResponse { + id: string; + fileName: string; + contentType: string; + size: number; + creationDate: string; +} + +export default class HomeworkCommentFileService { + static uploadFile( + commentId: string, + file: File + ): Promise> { + const formData = new FormData(); + formData.append("file", file); + + const uploadFileUrl = createUrlWithParams( + HOMEWORK_COMMENT_FILE_UPLOAD_URI, + { + commentId, + } + ); + + return axios({ + method: "POST", + url: uploadFileUrl, + headers: { "Content-Type": "multipart/form-data" }, + data: formData, + }); + } + + static getFile( + commentId: string, + fileId: string + ): Promise> { + const getFileUrl = createUrlWithParams(HOMEWORK_COMMENT_FILE_GET_URI, { + commentId, + fileId, + }); + + return axios({ + method: "GET", + url: getFileUrl, + responseType: "blob", + }); + } + + static deleteFile( + commentId: string, + fileId: string + ): Promise> { + const deleteUrl = createUrlWithParams(HOMEWORK_COMMENT_FILE_DELETE_URI, { + commentId, + fileId, + }); + + return axios({ + method: "DELETE", + url: deleteUrl, + }); + } +} diff --git a/src/api/rest/homework-file-service.ts b/src/api/rest/homework-file-service.ts index b01016fe..e5d52778 100644 --- a/src/api/rest/homework-file-service.ts +++ b/src/api/rest/homework-file-service.ts @@ -5,6 +5,8 @@ import { HOMEWORK_FILE_UPLOAD_URI, } from "config"; +import { createUrlWithParams } from "shared/utils"; + export interface HomeworkFileResponse { id: string; fileName: string; @@ -21,11 +23,9 @@ export default class HomeworkFileService { const formData = new FormData(); formData.append("file", file); - const uploadFileUrl = HOMEWORK_FILE_UPLOAD_URI.replace( - // eslint-disable-next-line sonarjs/no-duplicate-string - ":homeWorkId", - homeWorkId - ); + const uploadFileUrl = createUrlWithParams(HOMEWORK_FILE_UPLOAD_URI, { + homeWorkId, + }); return axios({ method: "POST", @@ -39,10 +39,10 @@ export default class HomeworkFileService { homeWorkId: string, fileId: string ): Promise> { - const getFileUrl = HOMEWORK_FILE_GET_URI.replace( - ":homeWorkId", - homeWorkId - ).replace(":fileId", fileId); + const getFileUrl = createUrlWithParams(HOMEWORK_FILE_GET_URI, { + homeWorkId, + fileId, + }); return axios({ method: "GET", @@ -51,11 +51,14 @@ export default class HomeworkFileService { }); } - static deleteFile(homeWorkId: string): Promise> { - const deleteUrl = HOMEWORK_FILE_DELETE_URI.replace( - ":homeWorkId", - homeWorkId - ); + static deleteFile( + homeWorkId: string, + fileId: string + ): Promise> { + const deleteUrl = createUrlWithParams(HOMEWORK_FILE_DELETE_URI, { + homeWorkId, + fileId, + }); return axios({ method: "DELETE", diff --git a/src/api/rest/lecture-file-service.ts b/src/api/rest/lecture-file-service.ts new file mode 100644 index 00000000..a1776bc9 --- /dev/null +++ b/src/api/rest/lecture-file-service.ts @@ -0,0 +1,68 @@ +import axios, { type AxiosResponse } from "axios"; +import { + LECTURE_FILE_UPLOAD_URI, + LECTURE_FILE_GET_URI, + LECTURE_FILE_DELETE_URI, +} from "config"; + +import { createUrlWithParams } from "shared/utils"; + +export interface LectureFileResponse { + id: string; + fileName: string; + contentType: string; + size: number; + creationDate: string; +} + +export default class LectureFileService { + static uploadFile( + lectureId: string, + file: File + ): Promise> { + const formData = new FormData(); + formData.append("file", file); + + const uploadFileUrl = createUrlWithParams(LECTURE_FILE_UPLOAD_URI, { + lectureId, + }); + + return axios({ + method: "POST", + url: uploadFileUrl, + headers: { "Content-Type": "multipart/form-data" }, + data: formData, + }); + } + + static getFile( + lectureId: string, + fileId: string + ): Promise> { + const getFileUrl = createUrlWithParams(LECTURE_FILE_GET_URI, { + lectureId, + fileId, + }); + + return axios({ + method: "GET", + url: getFileUrl, + responseType: "blob", + }); + } + + static deleteFile( + lectureId: string, + fileId: string + ): Promise> { + const deleteUrl = createUrlWithParams(LECTURE_FILE_DELETE_URI, { + lectureId, + fileId, + }); + + return axios({ + method: "DELETE", + url: deleteUrl, + }); + } +} diff --git a/src/api/rest/lecture-homework-file-service.ts b/src/api/rest/lecture-homework-file-service.ts new file mode 100644 index 00000000..7cdf71df --- /dev/null +++ b/src/api/rest/lecture-homework-file-service.ts @@ -0,0 +1,71 @@ +import axios, { type AxiosResponse } from "axios"; +import { + LECTURE_HOMEWORK_FILE_UPLOAD_URI, + LECTURE_HOMEWORK_FILE_GET_URI, + LECTURE_HOMEWORK_FILE_DELETE_URI, +} from "config"; + +import { createUrlWithParams } from "shared/utils"; + +export interface LectureHomeworkFileResponse { + id: string; + fileName: string; + contentType: string; + size: number; + creationDate: string; +} + +export default class LectureHomeworkFileService { + static uploadFile( + lectureId: string, + file: File + ): Promise> { + const formData = new FormData(); + formData.append("file", file); + + const uploadFileUrl = createUrlWithParams( + LECTURE_HOMEWORK_FILE_UPLOAD_URI, + { + lectureId, + } + ); + + return axios({ + method: "POST", + url: uploadFileUrl, + headers: { "Content-Type": "multipart/form-data" }, + data: formData, + }); + } + + static getFile( + lectureId: string, + fileId: string + ): Promise> { + const getFileUrl = createUrlWithParams(LECTURE_HOMEWORK_FILE_GET_URI, { + lectureId, + fileId, + }); + + return axios({ + method: "GET", + url: getFileUrl, + responseType: "blob", + }); + } + + static deleteFile( + lectureId: string, + fileId: string + ): Promise> { + const deleteUrl = createUrlWithParams(LECTURE_HOMEWORK_FILE_DELETE_URI, { + lectureId, + fileId, + }); + + return axios({ + method: "DELETE", + url: deleteUrl, + }); + } +} diff --git a/src/api/rest/training-upload-service.ts b/src/api/rest/training-upload-service.ts index 622bff67..36c002cf 100644 --- a/src/api/rest/training-upload-service.ts +++ b/src/api/rest/training-upload-service.ts @@ -1,6 +1,7 @@ import axios, { type AxiosResponse } from "axios"; +import { TRAINING_DELETE_URI, TRAINING_UPLOAD_URI } from "config"; -import { TRAINING_DELETE_URI, TRAINING_UPLOAD_URI } from "../../config"; +import { createUrlWithParams } from "shared/utils"; export interface TrainingUploadResponse { file: string | File; @@ -15,7 +16,9 @@ export default class TrainingUploadService { formData.append("file", file); - const uploadUrl = TRAINING_UPLOAD_URI.replace(":id", trainingId); + const uploadUrl = createUrlWithParams(TRAINING_UPLOAD_URI, { + id: trainingId, + }); return axios({ method: "POST", @@ -26,7 +29,10 @@ export default class TrainingUploadService { } static delete(trainingId: string): Promise> { - const deleteUrl = TRAINING_DELETE_URI.replace(":id", trainingId); + const deleteUrl = createUrlWithParams(TRAINING_DELETE_URI, { + id: trainingId, + }); + return axios({ method: "DELETE", url: deleteUrl, diff --git a/src/api/schema.graphql b/src/api/schema.graphql new file mode 100644 index 00000000..0b39548a --- /dev/null +++ b/src/api/schema.graphql @@ -0,0 +1,819 @@ +schema { + query: Query + mutation: Mutation +} + +directive @javaType(name: String!) on SCALAR + +"Mutation root" +type Mutation { + """ + user section + """ + createUser(input: UserCreateInput!): UserDto + updateUser(input: UserUpdateInput!): UserDto + updateRole(id: ID!, roles: [UserRole]): UserDto + lockUser(id: ID!): Void + unlockUser(id: ID!): Void + changePassword(newPassword: String!, oldPassword: String!): Void + changePasswordByUserId(userId: ID!, password: String!): Void + resetPassword(email: String!): Void + setPassword(token: String!, newPassword: String!): Void + createSkill(name: String!): SkillDto + updateSkill(id: ID!, name: String!): SkillDto + deleteSkill(id: ID!): Void + + """ + lecture section + """ + updateLecture(input: LectureInput!): LectureInfoDto + deleteLecture(id: ID!): Void + """ + lecture home work level section + """ + updateLectureHomeWorkLevel( + input: LectureHomeWorkLevelInput! + ): LectureHomeWorkLevelDto + deleteLectureHomeWorkLevel(id: ID!): Void + """ + training purchase section + """ + updateTrainingPurchase(input: TrainingPurchaseInput!): TrainingPurchaseDto + updateUserTrainingPurchase( + userEmail: String! + tariffCodes: [String]! + ): [TrainingPurchaseDto] + """ + training tariff + """ + updateTrainingTariff(input: TrainingTariffInput!): TrainingTariffDto + deleteTrainingTariff(id: ID!): Void + """ + training section + """ + updateTraining(input: TrainingInput!): TrainingDto + deleteTraining(id: ID!): Void + """ + training lecture + """ + updateTrainingLecture(id: ID!, lectureIds: [ID!]): [TrainingLectureDto] + """ + studentHomeWork section + """ + createHomeWorkToCheck( + lectureId: ID! + trainingId: ID! + content: String! + ): StudentHomeWorkDto + sendHomeWorkToCheck(homeWorkId: ID): StudentHomeWorkDto + updateHomeWork(id: ID!, content: String!): StudentHomeWorkDto + deleteHomeWork(id: ID!): Void + takeForReview(homeWorkId: ID!): StudentHomeWorkDto + approved(homeWorkId: ID!): StudentHomeWorkDto + notApproved(homeWorkId: ID!): StudentHomeWorkDto + resetState(homeWorkId: ID!): StudentHomeWorkDto + """ + student TestAttempt section + """ + startTest(lectureId: ID!, trainingId: ID!): TestAttemptShortDto + sendTestAnswer( + questionId: ID! + attemptId: ID! + testAnswerIds: [ID]! + ): TestAttemptDto + sendTestAnswerToReview(attemptId: ID!): StudentHomeWorkDto + """ + commentHomeWork section + """ + sendComment(homeWorkId: ID!, content: String!): CommentHomeWorkDto + answerComment(parentID: ID!, content: String!): CommentHomeWorkDto + updateComment(id: ID!, content: String!): CommentHomeWorkDto + deleteComment(id: ID!): Void + likeComment(id: ID!): CommentHomeWorkDto + """ + test lecture section + """ + updateTestQuestion(input: TestQuestionInput!): TestQuestionDto + deleteTestQuestion(id: ID!): Void + updateTestGroup(input: TestGroupInput!): TestGroupDto + deleteTestGroup(id: ID!): Void + updateTestAnswer(input: TestAnswerInput!): TestAnswerDto + deleteTestAnswer(id: ID!): Void +} + +"Query root" +type Query { + """ + user section + """ + user: UserDto + userById(id: ID): UserDto + userRoles: [UserRoleDto] + users( + offset: Int! + limit: Int! + sort: UserSort + filter: UsersFilter + ): UsersDto + usersRating(offset: Int!, limit: Int!, sort: UserSort): UsersRatingDto + mentors(offset: Int!, limit: Int!, sort: UserSort): UsersDto + checkResetPasswordToken(token: String!): Void + skills( + offset: Int! + limit: Int! + sort: SkillSort + filter: SkillFilter + ): SkillsDto + """ + lecture section + """ + lecture(id: ID): LectureInfoShortDto + lectureHomeWork(lectureId: ID): String + lectureTest(lectureId: ID): TestGroupNameDto + lectures(offset: Int!, limit: Int!, sort: LectureSort): LecturesDto + """ + lecture home work level section + """ + lectureHomeWorkLevel(id: ID!): LectureHomeWorkLevelDto + lectureHomeWorkLevels: [LectureHomeWorkLevelDto] + """ + purchase section + """ + trainingPurchases: [TrainingPurchaseDto] + trainingPurchasesByUserId(userId: ID!): [TrainingPurchaseDto] + """ + training tariff + """ + trainingTariffs( + offset: Int! + limit: Int! + sort: TrainingTariffSort + ): TrainingTariffsDto + """ + training section + """ + training(id: ID!): TrainingDto + trainingsByMentor(offset: Int!, limit: Int!, sort: TrainingSort): TrainingsDto + trainings(offset: Int!, limit: Int!, sort: TrainingSort): TrainingsDto + """ + training lecture + """ + trainingLectures(id: ID!): [TrainingLectureDto] + """ + studentHomeWork section + """ + homeWork(id: ID!): StudentHomeWorkDto + homeWorkByStudentAndLectureAndTraining( + studentId: ID! + lectureId: ID! + trainingId: ID! + ): StudentHomeWorkDto + homeWorkByLectureAndTraining( + lectureId: ID! + trainingId: ID! + ): StudentHomeWorkDto + homeWorks( + offset: Int! + limit: Int! + sort: StudentHomeWorkSort + filter: StudentHomeWorkFilter + ): StudentHomeWorksDto + homeWorksByLectureId( + offset: Int! + limit: Int! + sort: StudentHomeWorkSort + filter: HomeWorksFilter + lectureId: ID! + ): StudentHomeWorksDto + homeWorksByStatus( + offset: Int! + limit: Int! + sort: StudentHomeWorkSort + status: StudentHomeWorkStatus! + ): StudentHomeWorksDto + """ + student TestAttempt section + """ + testAttempts(lectureId: ID!, trainingId: ID!): [TestAttemptShortDto] + testAttempt(id: ID!): TestAttemptDto + testAttemptQuestions(attemptId: ID!): [TestAttemptQuestionResultDto] + """ + commentHomeWork section + """ + commentHomeWorkById(id: ID!): CommentHomeWorksDto + commentsHomeWorkByHomeWork( + offset: Int! + limit: Int! + sort: CommentHomeWorkSort + homeWorkId: ID! + ): CommentHomeWorksDto + """ + statistics section + """ + trainingsHomeWorksStatistic(id: ID): [TrainingHomeWorksStatisticDto] + homeWorksStatistic(id: ID): [HomeWorksStatisticDto] + """ + rating + """ + rating: RatingDto + ratingByUser(id: ID): RatingDto + """ + test lecture section + """ + testTestGroups: [TestGroupShortDto] + testTestGroupsById(id: ID): TestGroupDto + testQuestions: [TestQuestionDto] + testAnswerByQuestion(questionId: ID): [TestAnswerDto] +} + +type TrainingPurchaseDto { + id: ID + user: UserDto! + trainingTariff: TrainingTariffDto! +} + +input TrainingPurchaseInput { + id: ID + userEmail: String + trainingTariffCode: String +} + +input TrainingTariffInput { + id: ID + name: String! + code: String! + price: Float + homeWork: Boolean + trainingName: String! + description: String +} + +type TrainingTariffDto { + id: ID + name: String + code: String + price: Float + homeWork: Boolean + training: TrainingDto + description: String +} + +type TrainingDto { + id: ID! + name: String! + description: String + content: String + techStack: TechStack! + picture: String + mentors: [UserDto] + tariffs: [TrainingTariffDto] +} + +input TrainingInput { + id: ID + name: String + description: String + content: String + techStack: TechStack! + mentors: [String] +} + +input TrainingLectureInput { + lecture: ID! + lastLecture: ID + locking: Boolean +} + +type TrainingLectureDto { + id: ID + number: Int + lecture: LectureDto + lastLecture: LectureDto + locking: Boolean +} + +input LectureInput { + id: ID + homeWorkLevelCode: String + subject: String + description: [String] + testGroupId: ID + content: String + contentHomeWork: String + speakers: [String] +} + +type LectureInfoDto { + id: ID + homeWorkLevel: LectureHomeWorkLevelDto + subject: String + description: [String] + content: String + contentHomeWork: String + testGroup: TestGroupDto + files: [LectureFilesDto] + speakers: [UserDto] + creationDate: LocalDateTime + modificationDate: LocalDateTime +} + +type LectureInfoShortDto { + id: ID + homeWorkLevel: LectureHomeWorkLevelDto + subject: String + description: [String] + content: String + testGroup: TestGroupDto + files: [LectureFilesDto] + speakers: [UserDto] + creationDate: LocalDateTime + modificationDate: LocalDateTime +} + +type LectureDto { + id: ID + homeWorkLevel: LectureHomeWorkLevelDto + subject: String + testGroup: TestGroupNameDto + description: [String] + speakers: [UserDto] + creationDate: LocalDateTime + modificationDate: LocalDateTime +} + +type LectureHomeWorkLevelDto { + id: ID + code: String + description: String + estimate: Int +} + +input LectureHomeWorkLevelInput { + id: ID + code: String + description: String + estimate: Int +} + +type LecturesDto { + items: [LectureDto] + offset: Int + limit: Int + totalElements: Long +} + +type StudentHomeWorkDto { + id: ID + lecture: LectureInfoDto + training: TrainingDto + status: StudentHomeWorkStatus + answer: String + testAttempts: [TestAttemptDto] + student: UserDto + mentor: UserDto + filesHomeWork: [StudentHomeWorkFilesDto] + creationDate: LocalDateTime + updateDate: LocalDateTime + startCheckingDate: LocalDateTime + endCheckingDate: LocalDateTime +} + +type StudentHomeWorkFilesDto { + id: ID + creationDate: LocalDateTime + fileName: String + contentType: String + size: Long +} + +type LectureFilesDto { + id: ID + homeWork: Boolean + creationDate: LocalDateTime + fileName: String + contentType: String + size: Long +} + +type StudentHomeWorksDto { + items: [StudentHomeWorkDto] + offset: Int + limit: Int + totalElements: Long +} + +type CommentHomeWorksDto { + items: [CommentHomeWorkDto] + offset: Int + limit: Int + totalElements: Long +} + +type CommentHomeWorkDto { + id: ID + homeWork: StudentHomeWorkDto + creator: UserDto + content: String + filesComment: [CommentHomeWorkFilesDto] + likes: Long + userLike: Boolean + children: [CommentHomeWorkDto] + creationDate: LocalDateTime +} + +type CommentHomeWorkFilesDto { + id: ID + creationDate: LocalDateTime + fileName: String + contentType: String + size: Long +} + +type TrainingsDto { + items: [TrainingDto] + offset: Int + limit: Int + totalElements: Long +} + +type TrainingTariffsDto { + items: [TrainingTariffDto] + offset: Int + limit: Int + totalElements: Long +} + +type ContentFileDto { + id: ID + size: Long + type: String + name: String + fileLocation: String +} + +input UserCreateInput { + id: ID + email: String! + password: String! + firstName: String! + lastName: String! + middleName: String + phoneNumber: String +} + +input UserUpdateInput { + id: ID + email: String! + firstName: String! + lastName: String! + middleName: String + phoneNumber: String + vkId: String + git: String + telegram: String + stackOverflow: String + linkedin: String + website: String + skills: [ID] +} + +type UserDto { + id: ID + email: String + firstName: String + lastName: String + middleName: String + phoneNumber: String + rating: RatingUserDto + vkId: String + git: String + telegram: String + stackOverflow: String + linkedin: String + website: String + avatar: String + skills: [SkillDto] + roles: [UserRole] + locked: Boolean + creationDate: LocalDateTime + confirmationDate: LocalDateTime + updateDate: LocalDateTime +} + +type SkillDto { + id: ID + name: String +} + +type UserRatingDto { + id: ID + firstName: String + lastName: String + middleName: String + avatar: String + creationDate: LocalDateTime + rating: RatingUserDto +} + +type UserRoleDto { + name: String + description: String +} + +type UsersDto { + items: [UserDto] + offset: Int + limit: Int + totalElements: Long +} + +type SkillsDto { + items: [SkillDto] + offset: Int + limit: Int + totalElements: Long +} + +input UsersFilter { + email: String + name: String + phoneNumber: String + role: UserRole +} + +input SkillFilter { + name: String +} + +type UsersRatingDto { + items: [UserRatingDto] + offset: Int + limit: Int + totalElements: Long +} + +type TrainingHomeWorksStatisticDto { + training: TrainingDto + homeworks: [HomeWorksStatisticDto] +} + +type HomeWorksStatisticDto { + status: StudentHomeWorkStatus + count: Long +} + +type RatingUserDto { + rating: Long +} + +type RatingDto { + rating: Long + products: [RatingProductsDto] +} + +type RatingProductsDto { + training: TrainingDto + roles: [RatingProductsByUserRoleDto] +} + +type RatingProductsByUserRoleDto { + role: UserRoleDto + rating: Long + types: [RatingProductsByRatingTypeDto] +} + +type RatingProductsByRatingTypeDto { + type: RatingTypeDto + rating: Long +} + +type TestGroupNameDto { + id: ID + testName: String +} + +type TestGroupDto { + id: ID + testName: String + successThreshold: Int + testQuestions: [TestQuestionDto] +} + +type TestGroupShortDto { + id: ID + testName: String + successThreshold: Int +} + +input TestGroupInput { + id: ID + testName: String + successThreshold: Int + testQuestions: [TestQuestionInput] +} + +type TestQuestionDto { + id: ID + text: String + testAnswers: [TestAnswerShortDto] +} +input TestQuestionInput { + id: ID + text: String +} + +type TestAnswerDto { + id: ID + text: String + correct: Boolean +} + +type TestAnswerShortDto { + id: ID + text: String +} + +input TestAnswerInput { + id: ID + text: String + correct: Boolean + testQuestion: TestQuestionInput +} + +type TestAttemptShortDto { + id: ID + startTime: LocalDateTime + endTime: LocalDateTime + successfulCount: Int + errorsCount: Int + result: Boolean +} + +type TestAttemptDto { + id: ID + startTime: LocalDateTime + endTime: LocalDateTime + successfulCount: Int + errorsCount: Int + result: Boolean + testAttemptQuestionResults: [TestAttemptQuestionResultDto] +} + +type TestAttemptQuestionResultDto { + testQuestion: TestQuestionDto + result: Boolean + testAnswerResults: [TestAnswerResultsDto] +} + +type TestAnswerResultsDto { + testAnswer: TestAnswerShortDto + result: Boolean + answer: Boolean +} + +type TestQuestionInfoDto { + testQuestion: TestQuestionDto + result: Boolean +} + +type RatingTypeDto { + name: String + description: String +} + +input UserSort { + field: UserSortField + order: Order +} + +input SkillSort { + field: SkillSortField + order: Order +} + +enum TechStack { + JAVA + PYTHON +} + +enum UserRole { + ADMIN + STUDENT + MENTOR + LECTOR +} + +enum Order { + ASC + DESC +} + +enum UserSortField { + LAST_NAME + PHONE + EMAIL + RATING +} + +enum SkillSortField { + NAME +} + +enum StudentHomeWorkStatus { + NEW + REVIEW + IN_REVIEW + APPROVED + NOT_APPROVED +} + +input LectureSort { + field: LectureSortField + order: Order +} + +input StudentHomeWorkFilter { + status: StudentHomeWorkStatus + trainingId: ID + lectureId: ID + mentorId: ID + studentId: ID + creationDateFrom: LocalDateTime + creationDateTo: LocalDateTime +} + +input HomeWorksFilter { + status: StudentHomeWorkStatus +} + +input StudentHomeWorkSort { + field: StudentHomeWorkSortField + order: Order +} + +input CommentHomeWorkSort { + field: CommentHomeWorkSortField + order: Order +} + +input TrainingSort { + field: TrainingSortField + order: Order +} + +input TrainingTariffSort { + field: TrainingTariffField + order: Order +} + +enum LectureSortField { + SUBJECT + CREATION_DATE +} + +enum StudentHomeWorkSortField { + LECTOR + STUDENT + STATE + CREATION_DATE + START_CHECKING_DATE + END_CHECKING_DATE +} + +enum CommentHomeWorkSortField { + CREATOR + CREATION_DATE +} + +enum TrainingSortField { + NAME + CREATION_DATE +} + +enum TrainingTariffField { + NAME + CODE + CREATION_DATE +} + +scalar BigDecimal @javaType(name: "java.math.BigDecimal") + +scalar BigInteger @javaType(name: "java.math.BigInteger") + +scalar Date @javaType(name: "java.time.LocalDate") + +scalar DateTime @javaType(name: "java.time.OffsetDateTime") + +scalar LocalDateTime @javaType(name: "java.time.LocalDateTime") + +scalar LocalTime @javaType(name: "java.time.LocalTime") + +scalar Long @javaType(name: "java.lang.Long") + +scalar Time @javaType(name: "java.time.OffsetTime") + +scalar Timestamp @javaType(name: "java.util.Date") + +scalar Url @javaType(name: "java.net.URL") + +scalar Void @javaType(name: "java.lang.Void") diff --git a/src/config.ts b/src/config.ts index d1a868ef..5771aff5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,3 +11,20 @@ export const HOMEWORK_FILE_UPLOAD_URI = import.meta.env export const HOMEWORK_FILE_GET_URI = import.meta.env.VITE_HOMEWORK_FILE_GET_URI; export const HOMEWORK_FILE_DELETE_URI = import.meta.env .VITE_HOMEWORK_FILE_DELETE_URI; +export const LECTURE_FILE_UPLOAD_URI = import.meta.env + .VITE_LECTURE_FILE_UPLOAD_URI; +export const LECTURE_FILE_GET_URI = import.meta.env.VITE_LECTURE_FILE_GET_URI; +export const LECTURE_FILE_DELETE_URI = import.meta.env + .VITE_LECTURE_FILE_DELETE_URI; +export const LECTURE_HOMEWORK_FILE_UPLOAD_URI = import.meta.env + .VITE_LECTURE_HOMEWORK_FILE_UPLOAD_URI; +export const LECTURE_HOMEWORK_FILE_GET_URI = import.meta.env + .VITE_LECTURE_HOMEWORK_FILE_GET_URI; +export const LECTURE_HOMEWORK_FILE_DELETE_URI = import.meta.env + .VITE_LECTURE_HOMEWORK_FILE_DELETE_URI; +export const HOMEWORK_COMMENT_FILE_UPLOAD_URI = import.meta.env + .VITE_HOMEWORK_COMMENT_FILE_UPLOAD_URI; +export const HOMEWORK_COMMENT_FILE_GET_URI = import.meta.env + .VITE_HOMEWORK_COMMENT_FILE_GET_URI; +export const HOMEWORK_COMMENT_FILE_DELETE_URI = import.meta.env + .VITE_HOMEWORK_COMMENT_FILE_DELETE_URI; diff --git a/src/features/authorization/context/auth-context.tsx b/src/features/authorization/context/auth-context.tsx index 6d346528..77a015d8 100644 --- a/src/features/authorization/context/auth-context.tsx +++ b/src/features/authorization/context/auth-context.tsx @@ -1,6 +1,7 @@ import { FC, ReactNode, createContext, useContext, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { useSnackbar } from "notistack"; +import { client } from "api"; import { userRolesVar } from "cache"; import AuthService from "api/rest/auth-service"; @@ -96,9 +97,10 @@ export const AuthProvider: FC = ({ children }) => { const logout = async () => { setIsLoading(true); await AuthService.logout() - .then((response) => { + .then(async (response) => { if (response.status === RESPONSE_STATUS.SUCCESSFUL) { localStorage.removeItem("isAuth"); + await client.clearStore(); setIsLoading(false); navigate(ROUTES.AUTHORIZATION); } else { diff --git a/src/features/edit-training/views/edit-lecture/edit-lecture.tsx b/src/features/edit-training/views/edit-lecture/edit-lecture.tsx index 1f2188ff..9536c710 100644 --- a/src/features/edit-training/views/edit-lecture/edit-lecture.tsx +++ b/src/features/edit-training/views/edit-lecture/edit-lecture.tsx @@ -4,11 +4,25 @@ import { FC, useRef, useState } from "react"; import { SubmitHandler, useForm } from "react-hook-form"; import { Clear, Save } from "@mui/icons-material"; import { useNavigate } from "react-router-dom"; +import { LECTURE_FILE_GET_URI, LECTURE_HOMEWORK_FILE_GET_URI } from "config"; import { InputText } from "shared/components/form"; import { Editor } from "shared/components/text-editor"; -import { RichTextEditorRef } from "shared/lib/mui-tiptap"; +import { + blobUrlToFile, + collectFileIds, + findNodeByUrl, + RichTextEditorRef, +} from "shared/lib/mui-tiptap"; import { UserRole } from "api/graphql/generated/graphql"; +import { PendingFile } from "shared/components/text-editor/types"; +import { createUrlWithParams } from "shared/utils"; +import { + useLectureFileDelete, + useLectureFileUpload, + useLectureHomeworkFileDelete, + useLectureHomeworkFileUpload, +} from "shared/hooks"; import { SelectLectors } from "../../containers"; import { @@ -28,10 +42,22 @@ const EditLecture: FC = ({ updateLecture, dataLectureHomework, }) => { - const { id, subject, speakers, content } = dataLecture.lecture!; + const { id: lectureId, subject, speakers, content } = dataLecture.lecture!; const contentHomework = dataLectureHomework.lectureHomeWork; const { enqueueSnackbar } = useSnackbar(); const navigate = useNavigate(); + const [pendingFiles, setPendingFiles] = useState([]); + const [deletedLectureFileIds, setDeletedLectureFileIds] = useState( + [] + ); + const [deletedHomeworkFileIds, setDeletedHomeworkFileIds] = useState< + string[] + >([]); + + const { uploadLectureFile } = useLectureFileUpload(); + const { uploadLectureHomeworkFile } = useLectureHomeworkFileUpload(); + const { deleteLectureFile } = useLectureFileDelete(); + const { deleteLectureHomeworkFile } = useLectureHomeworkFileDelete(); const [description, setDescription] = useState( dataLecture?.lecture?.description! @@ -40,40 +66,142 @@ const EditLecture: FC = ({ const rteRefContentHomeWork = useRef(null); const { handleSubmit, control } = useForm({ - defaultValues: { id, subject, description, speakers }, + defaultValues: { id: lectureId, subject, description, speakers }, }); const onSubmit: SubmitHandler = async (data) => { const { speakers, ...restData } = data; + const emails = speakers?.map((s) => s?.email); + if (!lectureId) return; + + let content = rteRefContent.current?.editor?.getHTML().trim() || ""; + let contentHomework = + rteRefContentHomeWork.current?.editor?.getHTML().trim() || ""; + + const editorContent = rteRefContent.current?.editor!; + const editorHomework = rteRefContentHomeWork.current?.editor!; + + const contentBlobUrls = Array.from( + content.matchAll(/(blob:[^"'\s>]+)/g) + ).map((m) => m[1]); + const homeworkBlobUrls = Array.from( + contentHomework.matchAll(/(blob:[^"'\s>]+)/g) + ).map((m) => m[1]); + + const recoveredLecture = await Promise.all( + contentBlobUrls + .filter((url) => !pendingFiles.find((f) => f.localUrl === url)) + .map(async (url) => { + const node = findNodeByUrl(editorContent.state.doc, url); + const fileName = node?.fileName || "recovered_file"; + const file = await blobUrlToFile(url, fileName); + return { file, localUrl: url, source: "lecture" as const }; + }) + ); + + const recoveredHomework = await Promise.all( + homeworkBlobUrls + .filter((url) => !pendingFiles.find((f) => f.localUrl === url)) + .map(async (url) => { + const node = findNodeByUrl(editorHomework.state.doc, url); + const fileName = node?.fileName || "recovered_file"; + const file = await blobUrlToFile(url, fileName); + return { file, localUrl: url, source: "lectureHomework" as const }; + }) + ); + + const allFiles = [ + ...pendingFiles, + ...recoveredLecture, + ...recoveredHomework, + ]; + const lectureFiles = allFiles.filter((f) => f.source === "lecture"); + const homeworkFiles = allFiles.filter( + (f) => f.source === "lectureHomework" + ); + + const uploadedLectureFiles = await Promise.all( + lectureFiles.map(async ({ file, localUrl }) => { + const uploaded = await uploadLectureFile(file, lectureId); + return { + localUrl, + realUrl: createUrlWithParams(LECTURE_FILE_GET_URI, { + lectureId, + fileId: uploaded?.id!, + }), + }; + }) + ); + + const uploadedHomeworkFiles = await Promise.all( + homeworkFiles.map(async ({ file, localUrl }) => { + const uploaded = await uploadLectureHomeworkFile(file, lectureId); + return { + localUrl, + realUrl: createUrlWithParams(LECTURE_HOMEWORK_FILE_GET_URI, { + lectureId, + fileId: uploaded?.id!, + }), + }; + }) + ); + + uploadedLectureFiles.forEach(({ localUrl, realUrl }) => { + content = content.replaceAll(localUrl, realUrl); + }); + + uploadedHomeworkFiles.forEach(({ localUrl, realUrl }) => { + contentHomework = contentHomework.replaceAll(localUrl, realUrl); + }); + + const contentNode = rteRefContent.current?.editor?.state.doc; + const contentHomeworkNode = + rteRefContentHomeWork.current?.editor?.state.doc; - const emails = speakers?.map((speaker) => speaker?.email); + const currentFileIds = [ + ...collectFileIds(contentNode!), + ...collectFileIds(contentHomeworkNode!), + ]; const submissionData = { ...restData, speakers: emails, description, - content: rteRefContent.current?.editor?.getHTML(), - contentHomeWork: rteRefContentHomeWork.current?.editor?.getHTML(), + content, + contentHomeWork: contentHomework, }; await updateLecture({ - variables: { - input: submissionData, - }, - onCompleted: () => { + variables: { input: submissionData }, + onCompleted: async () => { + for (const fileId of deletedLectureFileIds) { + if (!currentFileIds.includes(fileId)) { + await deleteLectureFile(lectureId, fileId); + } + } + + for (const fileId of deletedHomeworkFileIds) { + if (!currentFileIds.includes(fileId)) { + await deleteLectureHomeworkFile(lectureId, fileId); + } + } + enqueueSnackbar("Урок обновлен", { variant: "success" }); }, onError: () => { - enqueueSnackbar( - "Не удалось обновить данные. Пожалуйста, попробуйте снова", - { variant: "error" } - ); + enqueueSnackbar("Ошибка при обновлении", { variant: "error" }); }, }); + + setPendingFiles([]); + setDeletedLectureFileIds([]); + setDeletedHomeworkFileIds([]); + rteRefContent.current?.editor?.commands.clearContent(); + rteRefContentHomeWork.current?.editor?.commands.clearContent(); }; const handleBack = () => { - const newPathname = location.pathname.replace(`/${id}`, ""); + const newPathname = location.pathname.replace(`/${lectureId}`, ""); navigate(newPathname); }; @@ -83,6 +211,13 @@ const EditLecture: FC = ({ navigate("/"); })(); }; + const handleDeleteLectureFile = (fileId: string) => { + setDeletedLectureFileIds((prev) => Array.from(new Set([...prev, fileId]))); + }; + + const handleDeleteHomeworkFile = (fileId: string) => { + setDeletedHomeworkFileIds((prev) => Array.from(new Set([...prev, fileId]))); + }; return ( @@ -116,7 +251,13 @@ const EditLecture: FC = ({ Материалы урока - + @@ -125,6 +266,9 @@ const EditLecture: FC = ({ diff --git a/src/features/header/containers/user/user-container.tsx b/src/features/header/containers/user/user-container.tsx index 524370b7..3f8ee3fc 100644 --- a/src/features/header/containers/user/user-container.tsx +++ b/src/features/header/containers/user/user-container.tsx @@ -13,7 +13,7 @@ const UserContainer: FC = () => { onCompleted: (data) => { userIdVar(data?.user?.id); }, - fetchPolicy: FETCH_POLICY.CACHE_FIRST, + fetchPolicy: FETCH_POLICY.CACHE_AND_NETWORK, }); if (loading) return ; diff --git a/src/features/kanban-lecture/containers/homeworks/homeworks-container.tsx b/src/features/kanban-lecture/containers/homeworks/homeworks-container.tsx index 955f07e6..d33cc641 100644 --- a/src/features/kanban-lecture/containers/homeworks/homeworks-container.tsx +++ b/src/features/kanban-lecture/containers/homeworks/homeworks-container.tsx @@ -37,7 +37,7 @@ const HomeworksContainer: FC = () => { field: StudentHomeWorkSortField.CreationDate, order: Order.Desc, }, - filter: { ...filterObject, status: StudentHomeWorkStatus.New }, + filter: { ...filterObject, status: StudentHomeWorkStatus.Review }, }, }); diff --git a/src/features/kanban-lecture/views/board/board.tsx b/src/features/kanban-lecture/views/board/board.tsx index 73447aa7..1ab4673e 100644 --- a/src/features/kanban-lecture/views/board/board.tsx +++ b/src/features/kanban-lecture/views/board/board.tsx @@ -35,7 +35,7 @@ const Board: FC = ({ setColumns([ createColumnItem( STATUS_COLUMN.NEW, - StudentHomeWorkStatus.New, + StudentHomeWorkStatus.Review, newItems as StudentHomeWorkDto[], newTotalElements ), diff --git a/src/features/kanban-mentor/containers/homeworks/homeworks-container.tsx b/src/features/kanban-mentor/containers/homeworks/homeworks-container.tsx index 8823833d..79e74ebf 100644 --- a/src/features/kanban-mentor/containers/homeworks/homeworks-container.tsx +++ b/src/features/kanban-mentor/containers/homeworks/homeworks-container.tsx @@ -37,7 +37,7 @@ const HomeworksContainer: FC = () => { field: StudentHomeWorkSortField.CreationDate, order: Order.Desc, }, - filter: { status: StudentHomeWorkStatus.New }, + filter: { status: StudentHomeWorkStatus.Review }, }, }); diff --git a/src/features/kanban-mentor/hooks/use-update-homework-status.ts b/src/features/kanban-mentor/hooks/use-update-homework-status.ts index 13aa127c..ba3a060b 100644 --- a/src/features/kanban-mentor/hooks/use-update-homework-status.ts +++ b/src/features/kanban-mentor/hooks/use-update-homework-status.ts @@ -42,7 +42,7 @@ const useUpdateHomeworkStatus = () => { }, filter: { ...filterObject, - status: StudentHomeWorkStatus.New, + status: StudentHomeWorkStatus.Review, mentorId: undefined, }, }, @@ -79,7 +79,7 @@ const useUpdateHomeworkStatus = () => { }, filter: { ...filterObject, - status: StudentHomeWorkStatus.New, + status: StudentHomeWorkStatus.Review, mentorId: undefined, }, }, diff --git a/src/features/kanban-mentor/views/board/board.tsx b/src/features/kanban-mentor/views/board/board.tsx index fda66f8d..8cc5365a 100644 --- a/src/features/kanban-mentor/views/board/board.tsx +++ b/src/features/kanban-mentor/views/board/board.tsx @@ -46,7 +46,7 @@ const Board: FC = ({ setColumns([ createColumnItem( STATUS_COLUMN.NEW, - StudentHomeWorkStatus.New, + StudentHomeWorkStatus.Review, newItems as StudentHomeWorkDto[], newTotalElements ), diff --git a/src/features/kanban-mentor/views/status-select/status-select.tsx b/src/features/kanban-mentor/views/status-select/status-select.tsx index 2eba26f9..bc9fa73f 100644 --- a/src/features/kanban-mentor/views/status-select/status-select.tsx +++ b/src/features/kanban-mentor/views/status-select/status-select.tsx @@ -24,7 +24,7 @@ const StatusSelect: FC = ({ currentStatus, homeworkId }) => { currentStatus?: Maybe ) => { switch (currentStatus) { - case StudentHomeWorkStatus.New: + case StudentHomeWorkStatus.Review: return [StudentHomeWorkStatus.InReview]; case StudentHomeWorkStatus.InReview: return [ diff --git a/src/features/kanban-mentor/views/status-select/status-select.types.ts b/src/features/kanban-mentor/views/status-select/status-select.types.ts index 424aa9b7..10f6dc43 100644 --- a/src/features/kanban-mentor/views/status-select/status-select.types.ts +++ b/src/features/kanban-mentor/views/status-select/status-select.types.ts @@ -11,7 +11,7 @@ export interface IStatusSelect { export const states = [ { - value: "NEW", + value: "REVIEW", Icon: Clock, text: "Новые", }, diff --git a/src/features/kanban-student/containers/homeworks/homeworks-container.tsx b/src/features/kanban-student/containers/homeworks/homeworks-container.tsx index a45ceb7d..7ff6e313 100644 --- a/src/features/kanban-student/containers/homeworks/homeworks-container.tsx +++ b/src/features/kanban-student/containers/homeworks/homeworks-container.tsx @@ -37,7 +37,7 @@ const HomeworksContainer: FC = () => { field: StudentHomeWorkSortField.CreationDate, order: Order.Desc, }, - filter: { ...filterObject, status: StudentHomeWorkStatus.New }, + filter: { ...filterObject, status: StudentHomeWorkStatus.Review }, }, }); diff --git a/src/features/kanban-student/views/board/board.tsx b/src/features/kanban-student/views/board/board.tsx index d9d54647..26016ab3 100644 --- a/src/features/kanban-student/views/board/board.tsx +++ b/src/features/kanban-student/views/board/board.tsx @@ -36,7 +36,7 @@ const Board: FC = ({ setColumns([ createColumnItem( STATUS_COLUMN.NEW, - StudentHomeWorkStatus.New, + StudentHomeWorkStatus.Review, newItems as StudentHomeWorkDto[], newTotalElements ), diff --git a/src/features/kanban-student/views/mobile-board/mobile-board.types.ts b/src/features/kanban-student/views/mobile-board/mobile-board.types.ts index 7ac66b57..c72c224b 100644 --- a/src/features/kanban-student/views/mobile-board/mobile-board.types.ts +++ b/src/features/kanban-student/views/mobile-board/mobile-board.types.ts @@ -14,7 +14,7 @@ export interface IMobileBoard { export const states = [ { - value: "NEW", + value: "REVIEW", Icon: Clock, text: "Новые", }, diff --git a/src/features/kanban/constants.ts b/src/features/kanban/constants.ts index 5baa5191..c3ea81af 100644 --- a/src/features/kanban/constants.ts +++ b/src/features/kanban/constants.ts @@ -16,11 +16,6 @@ export const STANDARD_QUERY_DEFAULTS = { LIMIT: 100, }; -export const UI_CONSTANTS = { - MIN_COLUMN_WIDTH: "65%", - MAX_COLUMN_WIDTH: "100%", -}; - export const ROUTES = { KANBAN: "/kanban", }; diff --git a/src/features/kanban/containers/homeworks/homeworks-container.tsx b/src/features/kanban/containers/homeworks/homeworks-container.tsx index 07d1caab..d9d0b3ac 100644 --- a/src/features/kanban/containers/homeworks/homeworks-container.tsx +++ b/src/features/kanban/containers/homeworks/homeworks-container.tsx @@ -43,8 +43,7 @@ const HomeworksContainer: FC = () => { }, filter: { ...filterObject, - status: StudentHomeWorkStatus.New, - mentorId: undefined, + status: StudentHomeWorkStatus.Review, }, }, }); diff --git a/src/features/kanban/hooks/use-update-homework-status.ts b/src/features/kanban/hooks/use-update-homework-status.ts index 23b833bf..a0648896 100644 --- a/src/features/kanban/hooks/use-update-homework-status.ts +++ b/src/features/kanban/hooks/use-update-homework-status.ts @@ -46,8 +46,7 @@ const useUpdateHomeworkStatus = () => { }, filter: { ...filterObject, - status: StudentHomeWorkStatus.New, - mentorId: undefined, + status: StudentHomeWorkStatus.Review, }, }, }); @@ -83,7 +82,7 @@ const useUpdateHomeworkStatus = () => { }, filter: { ...filterObject, - status: StudentHomeWorkStatus.New, + status: StudentHomeWorkStatus.Review, mentorId: undefined, }, }, diff --git a/src/features/kanban/views/board/board.styled.ts b/src/features/kanban/views/board/board.styled.ts index 354f6843..61607f39 100644 --- a/src/features/kanban/views/board/board.styled.ts +++ b/src/features/kanban/views/board/board.styled.ts @@ -1,17 +1,14 @@ import { Box, Button, Stack, StepLabel, Stepper } from "@mui/material"; import { styled } from "@mui/system"; -import { UI_CONSTANTS } from "../../constants"; - -interface IColumnBox { - showHomeworkDetails: boolean; - isUpLg: boolean; -} - -export const StyledWrapper = styled(Box)({ - display: "flex", - minWidth: "100%", -}); +export const StyledWrapper = styled(Box)<{ showHomeworkDetails: boolean }>( + ({ showHomeworkDetails }) => ({ + display: "grid", + gridTemplateColumns: showHomeworkDetails ? "65% 1fr" : "100%", + width: "100%", + height: "100vh", + }) +); export const StyledStack = styled(Stack)(({ theme }) => ({ flexDirection: "row", @@ -52,12 +49,7 @@ export const StyledStepperButton = styled(Button)({ minWidth: "5px", }); -export const StyledColumnBox = styled(Box, { - shouldForwardProp: (prop) => - !["showHomeworkDetails", "isUpLg"].includes(prop as string), -})(({ showHomeworkDetails, isUpLg }) => ({ - width: - showHomeworkDetails && isUpLg - ? UI_CONSTANTS.MIN_COLUMN_WIDTH - : UI_CONSTANTS.MAX_COLUMN_WIDTH, -})); +export const StyledColumnBox = styled(Box)({ + height: "100%", + overflow: "auto", +}); diff --git a/src/features/kanban/views/board/board.tsx b/src/features/kanban/views/board/board.tsx index fda66f8d..8cc5365a 100644 --- a/src/features/kanban/views/board/board.tsx +++ b/src/features/kanban/views/board/board.tsx @@ -46,7 +46,7 @@ const Board: FC = ({ setColumns([ createColumnItem( STATUS_COLUMN.NEW, - StudentHomeWorkStatus.New, + StudentHomeWorkStatus.Review, newItems as StudentHomeWorkDto[], newTotalElements ), diff --git a/src/features/kanban/views/desktop-board/desktop-board.tsx b/src/features/kanban/views/desktop-board/desktop-board.tsx index c4ab41ff..b64eb012 100644 --- a/src/features/kanban/views/desktop-board/desktop-board.tsx +++ b/src/features/kanban/views/desktop-board/desktop-board.tsx @@ -64,10 +64,10 @@ const DesktopBoard: FC = ({ ); return ( - + {columns?.map((column, index) => ( diff --git a/src/features/kanban/views/homework-details-full/homework-details-full.tsx b/src/features/kanban/views/homework-details-full/homework-details-full.tsx index adbd3b03..36d0dd25 100644 --- a/src/features/kanban/views/homework-details-full/homework-details-full.tsx +++ b/src/features/kanban/views/homework-details-full/homework-details-full.tsx @@ -8,6 +8,8 @@ import Homework from "shared/features/homework/view"; import StatusText from "shared/components/status-text"; import HomeworkBaseInfo from "shared/components/homework-base-info"; import { formatId } from "shared/helpers"; +import { useRoleAccess } from "shared/hooks"; +import { UserRole } from "api/graphql/generated/graphql"; import StatusSelect from "../../views/status-select"; import { IHomeworkDescriptionFull } from "./homework-details-full.types"; @@ -27,10 +29,14 @@ const HomeworkDetailsFull: FC = ({ data }) => { id, } = data?.homeWork!; + const hasAdminRoleAcces = useRoleAccess({ + allowedRoles: [UserRole.Admin], + }); + const currentUserId = useReactiveVar(userIdVar); const isCurrentMentor = currentUserId === mentor?.id; const hasNoMentor = mentor?.id === undefined; - const showSelect = isCurrentMentor || hasNoMentor; + const showSelect = hasAdminRoleAcces || isCurrentMentor || hasNoMentor; return ( diff --git a/src/features/kanban/views/homework-details/homework-details.styled.tsx b/src/features/kanban/views/homework-details/homework-details.styled.tsx index e91a8604..37e6c2d9 100644 --- a/src/features/kanban/views/homework-details/homework-details.styled.tsx +++ b/src/features/kanban/views/homework-details/homework-details.styled.tsx @@ -10,17 +10,18 @@ import { import OpenInNewIcon from "@mui/icons-material/OpenInNew"; export const StyledHomeworkDetails = styled(Box)(({ theme }) => ({ - position: "relative", - maxWidth: "33vw", + height: "100%", + overflowY: "auto", backgroundColor: alpha(theme.palette.app.secondary, 0.2), - marginLeft: "10px", + display: "flex", + flexDirection: "column", + marginTop: "15px", })); export const StyledBox = styled(Box)(({ theme }) => ({ - marginTop: "20px", padding: "35px 25px", - maxHeight: "calc(100vh - 217px)", - overflowY: "scroll", + overflowY: "auto", + flex: 1, backgroundColor: theme.palette.app.lightGrey, })); diff --git a/src/features/kanban/views/status-select/status-select.tsx b/src/features/kanban/views/status-select/status-select.tsx index df5bcc51..44de06a9 100644 --- a/src/features/kanban/views/status-select/status-select.tsx +++ b/src/features/kanban/views/status-select/status-select.tsx @@ -24,7 +24,7 @@ const StatusSelect: FC = ({ currentStatus, homeworkId }) => { currentStatus?: Maybe ) => { switch (currentStatus) { - case StudentHomeWorkStatus.New: + case StudentHomeWorkStatus.Review: return [StudentHomeWorkStatus.InReview]; case StudentHomeWorkStatus.InReview: return [ diff --git a/src/features/lecture-detail/containers/lecture-detail/lecture-detail-container.tsx b/src/features/lecture-detail/containers/lecture-detail/lecture-detail-container.tsx index 43636231..16b76eb8 100644 --- a/src/features/lecture-detail/containers/lecture-detail/lecture-detail-container.tsx +++ b/src/features/lecture-detail/containers/lecture-detail/lecture-detail-container.tsx @@ -20,32 +20,33 @@ const LectureDetailContainer: FC = () => { const { data: dataLecture, loading: loadingLecture } = useLectureQuery({ variables: { id: lectureId! }, - fetchPolicy: FETCH_POLICY.CACHE_FIRST, + fetchPolicy: FETCH_POLICY.CACHE_AND_NETWORK, }); const { data: dataTrainingLectures, loading: loadingTrainingLectures } = useTrainingLecturesQuery({ variables: { id: trainingId! }, - fetchPolicy: FETCH_POLICY.CACHE_FIRST, + fetchPolicy: FETCH_POLICY.CACHE_AND_NETWORK, }); const { data: dataLectureHomework, loading: loadingLectureHomeWork } = useLectureHomeWorkQuery({ variables: { lectureId: lectureId! }, skip: !tariffHomework, - fetchPolicy: FETCH_POLICY.CACHE_FIRST, + fetchPolicy: FETCH_POLICY.CACHE_AND_NETWORK, }); - if ( - loadingLecture || - loadingLectureHomeWork || - loadingTrainingLectures || - !tariffHomework - ) + if (loadingLecture || loadingTrainingLectures) { return ; + } - if (!dataLecture || !lectureId || !dataTrainingLectures) + if (tariffHomework && loadingLectureHomeWork) { + return ; + } + + if (!dataLecture || !lectureId || !dataTrainingLectures) { return ; + } return ( { const { trainingId } = useParams(); const { data: dataTrainingLectures } = useTrainingLecturesQuery({ variables: { id: trainingId! }, - fetchPolicy: FETCH_POLICY.CACHE_FIRST, + fetchPolicy: FETCH_POLICY.CACHE_AND_NETWORK, }); return ; diff --git a/src/features/lecture-detail/hooks/use-tariff.ts b/src/features/lecture-detail/hooks/use-tariff.ts index 881b6885..f471a890 100644 --- a/src/features/lecture-detail/hooks/use-tariff.ts +++ b/src/features/lecture-detail/hooks/use-tariff.ts @@ -10,12 +10,14 @@ const useTariff = ({ trainingId }: ITariffHook) => { const { data } = useTrainingPurchasesQuery(); const tariffHomework = useMemo(() => { - return ( - data?.trainingPurchases?.some( - (item) => - item?.trainingTariff.training?.id === trainingId && - item?.trainingTariff.homeWork - ) ?? false + if (!trainingId || !data?.trainingPurchases) { + return false; + } + + return data.trainingPurchases.some( + (item) => + item?.trainingTariff.training?.id === trainingId && + item?.trainingTariff.homeWork ); }, [data, trainingId]); diff --git a/src/features/profile/containers/user-info/user-info-container.tsx b/src/features/profile/containers/user-info/user-info-container.tsx index c6b2b752..50ca35f1 100644 --- a/src/features/profile/containers/user-info/user-info-container.tsx +++ b/src/features/profile/containers/user-info/user-info-container.tsx @@ -9,7 +9,7 @@ import UserInfo from "../../views/user-info"; const UserInfoContainer: FC = () => { const { loading, data } = useUserQuery({ - fetchPolicy: FETCH_POLICY.CACHE_FIRST, + fetchPolicy: FETCH_POLICY.CACHE_AND_NETWORK, }); if (loading) return ; diff --git a/src/features/training-lectures/containers/training-lectures/training-lectures-container.tsx b/src/features/training-lectures/containers/training-lectures/training-lectures-container.tsx index dbf22631..fe360201 100644 --- a/src/features/training-lectures/containers/training-lectures/training-lectures-container.tsx +++ b/src/features/training-lectures/containers/training-lectures/training-lectures-container.tsx @@ -17,11 +17,11 @@ const TrainingLecturesContainer: FC = () => { const { data: dataTrainingLectures, loading: loadingTrainingLectures } = useTrainingLecturesQuery({ variables: { id: trainingId! }, - fetchPolicy: FETCH_POLICY.CACHE_FIRST, + fetchPolicy: FETCH_POLICY.CACHE_AND_NETWORK, }); const { data: dataTraining, loading: loadingTraining } = useTrainingQuery({ variables: { id: trainingId! }, - fetchPolicy: FETCH_POLICY.CACHE_FIRST, + fetchPolicy: FETCH_POLICY.CACHE_AND_NETWORK, }); if (loadingTrainingLectures || loadingTraining) return ; diff --git a/src/features/user-detail/containers/user-by-id/user-by-id-container.tsx b/src/features/user-detail/containers/user-by-id/user-by-id-container.tsx index c5d152cd..2e1a537a 100644 --- a/src/features/user-detail/containers/user-by-id/user-by-id-container.tsx +++ b/src/features/user-detail/containers/user-by-id/user-by-id-container.tsx @@ -14,7 +14,7 @@ const UserByIdContainer: FC = () => { variables: { id: userId, }, - fetchPolicy: FETCH_POLICY.CACHE_FIRST, + fetchPolicy: FETCH_POLICY.CACHE_AND_NETWORK, onError: () => { return ; }, diff --git a/src/shared/components/form/input-phone/input-phone.tsx b/src/shared/components/form/input-phone/input-phone.tsx index f66e7884..fb6182c5 100644 --- a/src/shared/components/form/input-phone/input-phone.tsx +++ b/src/shared/components/form/input-phone/input-phone.tsx @@ -21,6 +21,8 @@ const InputPhone = ({ onChange(newInputValue)} getOptionLabel={(option) => { if (typeof option === "string") { return option; @@ -41,12 +43,7 @@ const InputPhone = ({ )} renderInput={(params) => ( = ({ status }) => { let statusText; switch (status) { - case StudentHomeWorkStatus.New: + case StudentHomeWorkStatus.Review: icon = ; statusText = "Новые"; break; diff --git a/src/shared/components/text-editor/comment-editor/comment-editor.tsx b/src/shared/components/text-editor/comment-editor/comment-editor.tsx index f78f6ed7..f74564bb 100644 --- a/src/shared/components/text-editor/comment-editor/comment-editor.tsx +++ b/src/shared/components/text-editor/comment-editor/comment-editor.tsx @@ -3,7 +3,12 @@ import { Box, Stack } from "@mui/material"; import type { EditorOptions } from "@tiptap/core"; import { FC, useCallback, useState } from "react"; -import { LinkBubbleMenu, RichTextEditor } from "shared/lib/mui-tiptap"; +import { + insertFiles, + insertImages, + LinkBubbleMenu, + RichTextEditor, +} from "shared/lib/mui-tiptap"; import { TableBubbleMenu, MenuButton } from "shared/lib/mui-tiptap/controls"; import { EditorMenuControls } from "./ui"; @@ -11,9 +16,18 @@ import { fileListToImageFiles } from "../utils/file-list-to-image-files"; import useExtensions from "../hooks/use-extensions"; import { ITextEditor } from "../types"; -const CommentEditor: FC = ({ rteRef, content }) => { +const CommentEditor: FC = ({ + rteRef, + content, + setPendingFiles, + source, + handleDeleteFile, +}) => { const extensions = useExtensions({ placeholder: "Введите текст...", + onFileDelete: async (fileId: string) => { + await handleDeleteFile?.(fileId); + }, }); const [isEditable, setIsEditable] = useState(true); const [showMenuBar, setShowMenuBar] = useState(true); @@ -43,6 +57,62 @@ const CommentEditor: FC = ({ rteRef, content }) => { return pastedImageFiles.length > 0; }, []); + const handleNewImageFiles = useCallback( + (files: File[], insertPosition?: number): void => { + if (!rteRef.current?.editor) return; + + const filesWithUrl = files.map((file) => ({ + file, + localUrl: URL.createObjectURL(file), + source, + })); + + setPendingFiles?.((prev) => [...prev, ...filesWithUrl]); + + const attributesForImageFiles = filesWithUrl.map( + ({ file, localUrl }) => ({ + src: localUrl, + alt: file.name, + }) + ); + + insertImages({ + images: attributesForImageFiles, + editor: rteRef.current.editor, + position: insertPosition, + }); + }, + [rteRef, setPendingFiles] + ); + + const handleNewFiles = useCallback( + (files: File[], insertPosition?: number): void => { + if (!rteRef.current?.editor) { + return; + } + + const filesWithUrl = files.map((file) => ({ + file, + localUrl: URL.createObjectURL(file), + source, + })); + + setPendingFiles?.((prev) => [...prev, ...filesWithUrl]); + + const attributesForFiles = filesWithUrl.map(({ localUrl, file }) => ({ + href: localUrl, + fileName: file.name, + })); + + insertFiles({ + files: attributesForFiles, + editor: rteRef.current.editor, + position: insertPosition, + }); + }, + [rteRef, setPendingFiles] + ); + return ( <> = ({ rteRef, content }) => { handleDrop, handlePaste, }} - renderControls={() => } + renderControls={() => ( + + )} RichTextFieldProps={{ variant: "outlined", MenuBarProps: { diff --git a/src/shared/components/text-editor/comment-editor/ui/editor-menu-controls.tsx b/src/shared/components/text-editor/comment-editor/ui/editor-menu-controls.tsx index 55dadbb9..0228adfc 100644 --- a/src/shared/components/text-editor/comment-editor/ui/editor-menu-controls.tsx +++ b/src/shared/components/text-editor/comment-editor/ui/editor-menu-controls.tsx @@ -2,6 +2,8 @@ import { MenuButtonCodeBlock, MenuButtonEditLink, MenuButtonEmoji, + MenuButtonFileUpload, + MenuButtonImageUpload, MenuButtonRedo, MenuButtonUndo, MenuButtonYoutube, @@ -9,8 +11,18 @@ import { MenuDivider, } from "shared/lib/mui-tiptap/controls"; import { useResponsive } from "shared/hooks"; +import { Maybe } from "api/graphql/generated/graphql"; -export default function EditorMenuControls() { +interface EditorMenuControlsProps { + homeWorkId?: Maybe; + onUploadImageFiles: (files: File[]) => any; + onUploadFiles: (files: File[]) => any; +} + +export default function EditorMenuControls({ + onUploadImageFiles, + onUploadFiles, +}: EditorMenuControlsProps) { const { isDesktop } = useResponsive(); return ( @@ -29,6 +41,15 @@ export default function EditorMenuControls() { + + + {isDesktop && ( diff --git a/src/shared/components/text-editor/editor/editor.tsx b/src/shared/components/text-editor/editor/editor.tsx index e9b87219..61722e2d 100644 --- a/src/shared/components/text-editor/editor/editor.tsx +++ b/src/shared/components/text-editor/editor/editor.tsx @@ -2,7 +2,6 @@ import { Lock, LockOpen, TextFields } from "@mui/icons-material"; import { Box, Stack } from "@mui/material"; import type { EditorOptions } from "@tiptap/core"; import { FC, useCallback, useState } from "react"; -import { useSnackbar } from "notistack"; import { insertFiles, insertImages } from "shared/lib/mui-tiptap/utils"; import { LinkBubbleMenu, RichTextEditor } from "shared/lib/mui-tiptap"; @@ -12,100 +11,78 @@ import { EditorMenuControls } from "./ui"; import { ITextEditor } from "../types"; import useExtensions from "../hooks/use-extensions"; import { fileListToImageFiles } from "../utils/file-list-to-image-files"; -import { useHomeworkFileUpload } from "../hooks/use-homework-file-upload"; -const Editor: FC = ({ rteRef, content, homeWorkId }) => { +const Editor: FC = ({ + rteRef, + content, + setPendingFiles, + source, + handleDeleteFile, +}) => { const extensions = useExtensions({ placeholder: "Введите текст...", + onFileDelete: async (fileId: string) => { + await handleDeleteFile?.(fileId); + }, }); + const [isEditable, setIsEditable] = useState(true); const [showMenuBar, setShowMenuBar] = useState(true); - const { uploadHomeworkFile } = useHomeworkFileUpload(); - const { enqueueSnackbar } = useSnackbar(); + const handleNewImageFiles = useCallback( + (files: File[], insertPosition?: number): void => { + if (!rteRef.current?.editor) return; - const baseUrl = import.meta.env.VITE_APP_ENDPOINT; + const filesWithUrl = files.map((file) => ({ + file, + localUrl: URL.createObjectURL(file), + source, + })); - const handleNewImageFiles = useCallback( - async (files: File[], insertPosition?: number): Promise => { - if (!rteRef.current?.editor || !homeWorkId) { - return; - } + setPendingFiles?.((prev) => [...prev, ...filesWithUrl]); - const attributesForImageFiles = await Promise.all( - files.map(async (file) => { - try { - const uploadedFile = await uploadHomeworkFile(file, homeWorkId); - - if (uploadedFile) { - const serverUrl = `${baseUrl}/homework/${homeWorkId}/file/${uploadedFile.id}`; - - return { - src: serverUrl, - alt: uploadedFile.fileName, - }; - } - } catch { - enqueueSnackbar(`Не удалось загрузить файл: ${file.name}`, { - variant: "error", - }); - } - - return { - src: "", - alt: file.name, - }; + const attributesForImageFiles = filesWithUrl.map( + ({ file, localUrl }) => ({ + src: localUrl, + alt: file.name, }) ); insertImages({ - images: attributesForImageFiles.filter((img) => img.src), + images: attributesForImageFiles, editor: rteRef.current.editor, position: insertPosition, }); }, - [rteRef, homeWorkId, uploadHomeworkFile] + [rteRef, setPendingFiles] ); const handleNewFiles = useCallback( - async (files: File[], insertPosition?: number): Promise => { - if (!rteRef.current?.editor || !homeWorkId) { + (files: File[], insertPosition?: number): void => { + if (!rteRef.current?.editor) { return; } - const attributesForFiles = await Promise.all( - files.map(async (file) => { - try { - const uploadedFile = await uploadHomeworkFile(file, homeWorkId); - - if (uploadedFile) { - const serverUrl = `/homework/${homeWorkId}/file/${uploadedFile.id}`; - - return { - href: serverUrl, - fileName: uploadedFile.fileName, - }; - } - } catch { - enqueueSnackbar(`Не удалось загрузить файл: ${file.name}`, { - variant: "error", - }); - } - - return { - href: "", - fileName: file.name, - }; - }) - ); + const filesWithUrl = files.map((file) => ({ + file, + localUrl: URL.createObjectURL(file), + source, + })); + + setPendingFiles?.((prev) => [...prev, ...filesWithUrl]); + + const attributesForFiles = filesWithUrl.map(({ localUrl, file }) => ({ + href: localUrl, + fileName: file.name, + })); insertFiles({ - files: attributesForFiles.filter((file) => file.href), + files: attributesForFiles, editor: rteRef.current.editor, position: insertPosition, }); }, - [rteRef, homeWorkId, uploadHomeworkFile, enqueueSnackbar] + [rteRef, setPendingFiles] ); const handleDrop: NonNullable = @@ -184,7 +161,6 @@ const Editor: FC = ({ rteRef, content, homeWorkId }) => { }} renderControls={() => ( diff --git a/src/shared/components/text-editor/editor/ui/editor-menu-controls.tsx b/src/shared/components/text-editor/editor/ui/editor-menu-controls.tsx index 2e227157..77753109 100644 --- a/src/shared/components/text-editor/editor/ui/editor-menu-controls.tsx +++ b/src/shared/components/text-editor/editor/ui/editor-menu-controls.tsx @@ -38,8 +38,8 @@ import { Maybe } from "api/graphql/generated/graphql"; interface EditorMenuControlsProps { homeWorkId?: Maybe; - onUploadImageFiles: (files: File[]) => Promise; - onUploadFiles: (files: File[]) => Promise; + onUploadImageFiles: (files: File[]) => any; + onUploadFiles: (files: File[]) => any; } export default function EditorMenuControls({ diff --git a/src/shared/components/text-editor/hooks/use-extensions.ts b/src/shared/components/text-editor/hooks/use-extensions.ts index d6889f82..c3df3a75 100644 --- a/src/shared/components/text-editor/hooks/use-extensions.ts +++ b/src/shared/components/text-editor/hooks/use-extensions.ts @@ -31,6 +31,7 @@ import { TextAlign } from "@tiptap/extension-text-align"; import { TextStyle } from "@tiptap/extension-text-style"; import { Underline } from "@tiptap/extension-underline"; import { Youtube } from "@tiptap/extension-youtube"; +import { ReactNodeViewRenderer } from "@tiptap/react"; import { CodeBlockLowlight } from "@tiptap/extension-code-block-lowlight"; import { useMemo } from "react"; import { common, createLowlight } from "lowlight"; @@ -42,6 +43,8 @@ import { ResizableImage, } from "shared/lib/mui-tiptap/extensions"; import { HeadingWithAnchor } from "shared/lib/mui-tiptap/hooks"; +import { FileDeletionTracker } from "shared/lib/mui-tiptap/extensions/file-deletion-tracker"; +import FileNodeView from "shared/lib/mui-tiptap/extensions/file-node-view"; import { mentionSuggestionOptions } from "../utils/mention-suggestion-options"; @@ -105,7 +108,7 @@ const Iframe = Node.create({ }, }); -export const FileNode = Node.create({ +export const FileNode = Node.create({ name: "file", group: "inline", @@ -126,40 +129,49 @@ export const FileNode = Node.create({ parseHTML() { return [ { - tag: "a[data-file]", + tag: "file-node", + getAttrs: (el) => { + if (!(el instanceof HTMLElement)) return false; + + return { + href: el.getAttribute("href"), + fileName: el.getAttribute("fileName") || el.textContent, + }; + }, }, ]; }, renderHTML({ HTMLAttributes }) { return [ - "a", + "file-node", mergeAttributes(HTMLAttributes, { - "data-file": "", - href: HTMLAttributes.href, - download: HTMLAttributes.fileName, - target: "_blank", + "data-file-node": "true", }), - HTMLAttributes.fileName || "Download file", ]; }, addCommands() { return { setFile: - (options) => + (attrs) => ({ commands }) => { return commands.insertContent({ type: this.name, - attrs: options, + attrs, }); }, }; }, + + addNodeView() { + return ReactNodeViewRenderer(FileNodeView); + }, }); export type UseExtensionsOptions = { placeholder?: string; + onFileDelete?: (fileId: string) => void; }; const CustomLinkExtension = Link.extend({ @@ -188,8 +200,23 @@ const CustomParagraph = Paragraph.extend({ content: "inline*", // Поддержка всех inline-узлов, включая file }); +const CustomResizableImage = ResizableImage.extend({ + addAttributes() { + return { + ...this.parent?.(), + width: { + default: "300px", + }, + height: { + default: "auto", + }, + }; + }, +}); + export default function useExtensions({ placeholder, + onFileDelete, }: UseExtensionsOptions = {}): EditorOptions["extensions"] { return useMemo(() => { return [ @@ -238,7 +265,7 @@ export default function useExtensions({ Highlight.configure({ multicolor: true }), HorizontalRule, - ResizableImage.configure({ + CustomResizableImage.configure({ allowBase64: true, }), @@ -267,6 +294,9 @@ export default function useExtensions({ History, FileNode, + FileDeletionTracker.configure({ + onFileDelete, + }), ]; - }, [placeholder]); + }, [placeholder, onFileDelete]); } diff --git a/src/shared/components/text-editor/types/index.ts b/src/shared/components/text-editor/types/index.ts index 62a8c09a..46c17213 100644 --- a/src/shared/components/text-editor/types/index.ts +++ b/src/shared/components/text-editor/types/index.ts @@ -9,6 +9,18 @@ export type MentionSuggestion = { mentionLabel: string; }; +export type FileSourceType = + | "lectureHomework" + | "lecture" + | "studentHomework" + | "comment"; + +export type PendingFile = { + file: File; + localUrl: string; + source?: FileSourceType; +}; + export type SuggestionListRef = { onKeyDown: NonNullable< ReturnType< @@ -22,5 +34,7 @@ export type SuggestionListProps = SuggestionProps; export interface ITextEditor { rteRef: RefObject; content?: Maybe; - homeWorkId?: Maybe; + setPendingFiles?: React.Dispatch>; + source: FileSourceType; + handleDeleteFile?: (fileId: string) => void; } diff --git a/src/shared/constants/constants.ts b/src/shared/constants/constants.ts index 99409720..e6c8f15b 100644 --- a/src/shared/constants/constants.ts +++ b/src/shared/constants/constants.ts @@ -47,7 +47,7 @@ export const HOMEWORKS_QUERY_DEFAULTS = { export const STATES = [ { - value: "NEW", + value: "REVIEW", Icon: Clock, text: "Новые", }, diff --git a/src/shared/features/answer-comment/view/answer-comment.tsx b/src/shared/features/answer-comment/view/answer-comment.tsx index 40e1c3a9..19c80a29 100644 --- a/src/shared/features/answer-comment/view/answer-comment.tsx +++ b/src/shared/features/answer-comment/view/answer-comment.tsx @@ -50,7 +50,7 @@ const AnswerComment: FC = (props) => {
- + {error && {error}} = (props) => { /> ); } else { - homeworkContent = ; + homeworkContent = ; } return <>{homeworkContent}; diff --git a/src/shared/features/kanban-profile-mentor/containers/homeworks/homeworks-container.tsx b/src/shared/features/kanban-profile-mentor/containers/homeworks/homeworks-container.tsx index ca1eb73e..bf0e06e6 100644 --- a/src/shared/features/kanban-profile-mentor/containers/homeworks/homeworks-container.tsx +++ b/src/shared/features/kanban-profile-mentor/containers/homeworks/homeworks-container.tsx @@ -41,7 +41,7 @@ const HomeworksContainer: FC = () => { field: StudentHomeWorkSortField.CreationDate, order: Order.Desc, }, - filter: { status: StudentHomeWorkStatus.New }, + filter: { status: StudentHomeWorkStatus.Review }, }, }); diff --git a/src/shared/features/kanban-profile-mentor/views/board/board.tsx b/src/shared/features/kanban-profile-mentor/views/board/board.tsx index fb44a61d..d5e86818 100644 --- a/src/shared/features/kanban-profile-mentor/views/board/board.tsx +++ b/src/shared/features/kanban-profile-mentor/views/board/board.tsx @@ -35,7 +35,7 @@ const Board: FC = ({ setColumns([ createColumnItem( STATUS_COLUMN.NEW, - StudentHomeWorkStatus.New, + StudentHomeWorkStatus.Review, newItems as StudentHomeWorkDto[], newTotalElements ), diff --git a/src/shared/features/kanban-profile-student/containers/homeworks/homeworks-container.tsx b/src/shared/features/kanban-profile-student/containers/homeworks/homeworks-container.tsx index 5c9b9bce..e8b86135 100644 --- a/src/shared/features/kanban-profile-student/containers/homeworks/homeworks-container.tsx +++ b/src/shared/features/kanban-profile-student/containers/homeworks/homeworks-container.tsx @@ -41,7 +41,7 @@ const HomeworksContainer: FC = () => { field: StudentHomeWorkSortField.CreationDate, order: Order.Desc, }, - filter: { ...filterObject, status: StudentHomeWorkStatus.New }, + filter: { ...filterObject, status: StudentHomeWorkStatus.Review }, }, }); diff --git a/src/shared/features/kanban-profile-student/views/board/board.tsx b/src/shared/features/kanban-profile-student/views/board/board.tsx index fb44a61d..d5e86818 100644 --- a/src/shared/features/kanban-profile-student/views/board/board.tsx +++ b/src/shared/features/kanban-profile-student/views/board/board.tsx @@ -35,7 +35,7 @@ const Board: FC = ({ setColumns([ createColumnItem( STATUS_COLUMN.NEW, - StudentHomeWorkStatus.New, + StudentHomeWorkStatus.Review, newItems as StudentHomeWorkDto[], newTotalElements ), diff --git a/src/shared/features/send-comment/container/send-comment-container.tsx b/src/shared/features/send-comment/container/send-comment-container.tsx index 323885aa..08be63e1 100644 --- a/src/shared/features/send-comment/container/send-comment-container.tsx +++ b/src/shared/features/send-comment/container/send-comment-container.tsx @@ -5,6 +5,7 @@ import { CommentsHomeWorkByHomeWorkQuery, useSendCommentMutation, Maybe, + useUpdateCommentMutation, } from "api/graphql/generated/graphql"; import { INDEX_OFFSET, @@ -18,11 +19,25 @@ import SendComment from "../view"; const SendCommentContainer: FC = (props) => { const { homeworkId } = props; - const [sendComment, { loading }] = useSendCommentMutation({ - update: (cache, { data }) => { - const newComment = data?.sendComment; - const existingComments: Maybe = - cache.readQuery({ + const [sendComment, { loading: loadingSendComment }] = useSendCommentMutation( + { + update: (cache, { data }) => { + const newComment = data?.sendComment; + const existingComments: Maybe = + cache.readQuery({ + query: CommentsHomeWorkByHomeWorkDocument, + variables: { + offset: QUERY_DEFAULTS.OFFSET, + limit: QUERY_DEFAULTS.LIMIT, + sort: { + field: "CREATION_DATE", + order: "DESC", + }, + homeWorkId: homeworkId, + }, + }); + + cache.writeQuery({ query: CommentsHomeWorkByHomeWorkDocument, variables: { offset: QUERY_DEFAULTS.OFFSET, @@ -33,41 +48,49 @@ const SendCommentContainer: FC = (props) => { }, homeWorkId: homeworkId, }, + data: { + commentsHomeWorkByHomeWork: { + ...existingComments?.commentsHomeWorkByHomeWork, + items: [ + newComment, + ...(existingComments?.commentsHomeWorkByHomeWork?.items || []), + ], + totalElements: + parseInt( + existingComments?.commentsHomeWorkByHomeWork?.totalElements, + PARSE_INT_RADIX + ) + INDEX_OFFSET, + }, + }, }); + }, + } + ); - cache.writeQuery({ - query: CommentsHomeWorkByHomeWorkDocument, - variables: { - offset: QUERY_DEFAULTS.OFFSET, - limit: QUERY_DEFAULTS.LIMIT, - sort: { - field: "CREATION_DATE", - order: "DESC", - }, - homeWorkId: homeworkId, - }, - data: { - commentsHomeWorkByHomeWork: { - ...existingComments?.commentsHomeWorkByHomeWork, - items: [ - newComment, - ...(existingComments?.commentsHomeWorkByHomeWork?.items || []), - ], - totalElements: - parseInt( - existingComments?.commentsHomeWorkByHomeWork?.totalElements, - PARSE_INT_RADIX - ) + INDEX_OFFSET, - }, - }, - }); - }, - }); + const [updateComment, { loading: loadingUpdateComment }] = + useUpdateCommentMutation({ + update: (cache, { data }) => { + const updateComment = data?.updateComment; + + if (updateComment) { + cache.modify({ + id: cache.identify(updateComment), + fields: { + content() { + return updateComment?.content; + }, + }, + }); + } + }, + }); return ( ); diff --git a/src/shared/features/send-comment/view/send-comment.tsx b/src/shared/features/send-comment/view/send-comment.tsx index 6bfc3ab3..10d347a4 100644 --- a/src/shared/features/send-comment/view/send-comment.tsx +++ b/src/shared/features/send-comment/view/send-comment.tsx @@ -1,45 +1,152 @@ import { FC, useRef, useState } from "react"; +import { HOMEWORK_COMMENT_FILE_GET_URI } from "config"; import { CommentEditor } from "shared/components/text-editor"; -import { type RichTextEditorRef } from "shared/lib/mui-tiptap"; +import { collectFileIds, type RichTextEditorRef } from "shared/lib/mui-tiptap"; import SendButtons from "shared/components/send-buttons"; +import { PendingFile } from "shared/components/text-editor/types"; +import { + useHomeworkCommentFileDelete, + useHomeworkCommentFileUpload, +} from "shared/hooks"; +import { createUrlWithParams } from "shared/utils"; +import { findNodeByUrl } from "shared/lib/mui-tiptap/utils/find-node-by-url"; +import { blobUrlToFile } from "shared/lib/mui-tiptap/utils/blob-url-to-file"; import { ISendComment } from "./send-comment.types"; import { StyledBox, StyledFormHelperText } from "./send-comment.styled"; const SendComment: FC = (props) => { - const { sendComment, loading, homeworkId } = props; + const { + sendComment, + loadingUpdateComment, + loadingSendComment, + homeworkId, + updateComment, + } = props; + const rteRef = useRef(null); + const [pendingFiles, setPendingFiles] = useState([]); + const [deletedFileIds, setDeletedFileIds] = useState([]); + const { uploadHomeworkCommentFile } = useHomeworkCommentFileUpload(); + const { deleteHomeworkCommentFile } = useHomeworkCommentFileDelete(); const [error, setError] = useState(""); const handleSendComment = async () => { - const content = rteRef.current?.editor?.getHTML() ?? ""; - - if (homeworkId && content.trim() !== "" && content.trim() !== "

") { - try { - await sendComment({ - variables: { - homeWorkId: homeworkId, - content, - }, - }); - setError(""); - rteRef.current?.editor?.commands.clearContent(); - } catch (error) { - setError("Произошла ошибка при отправке комментария."); - } - } else { + const editor = rteRef.current?.editor; + if (!editor || !homeworkId) return; + + let html = editor.getHTML().trim(); + if (!html || html === "

") { setError("Введите текст"); + return; + } + + try { + await sendComment({ + variables: { + homeWorkId: homeworkId, + content: html, + }, + onCompleted: async (response) => { + const commentId = response?.sendComment?.id; + if (!commentId) return; + + const contentBlobUrls = Array.from( + html.matchAll(/(blob:[^"'\s>]+)/g) + ).map((match) => match[1]); + + const recoveredBlobs = contentBlobUrls + .filter((url) => !pendingFiles.find((f) => f.localUrl === url)) + .map((url) => { + const node = findNodeByUrl(editor.state.doc, url); + const fileName = node?.fileName || "recovered_file"; + return { file: blobUrlToFile(url, fileName), localUrl: url }; + }); + + const resolvedRecoveredFiles = await Promise.all( + recoveredBlobs.map(async ({ file, localUrl }) => ({ + localUrl, + file: await file, + })) + ); + + const allFilesToUpload = [...pendingFiles, ...resolvedRecoveredFiles]; + + const uploadResults = await Promise.all( + allFilesToUpload.map(async ({ file, localUrl }) => { + const uploaded = await uploadHomeworkCommentFile(file, commentId); + const realUrl = createUrlWithParams( + HOMEWORK_COMMENT_FILE_GET_URI, + { + commentId, + fileId: uploaded?.id!, + } + ); + + return { localUrl, realUrl }; + }) + ); + + uploadResults.forEach(({ localUrl, realUrl }) => { + html = html.replaceAll(localUrl, realUrl); + }); + + await updateComment({ + variables: { + id: commentId, + content: html, + }, + }); + + const contentNode = editor.state.doc; + const currentFileIds = collectFileIds(contentNode); + const stillDeleted = deletedFileIds.filter( + (id) => !currentFileIds.includes(id) + ); + + for (const id of stillDeleted) { + await deleteHomeworkCommentFile(commentId, id); + } + + setPendingFiles([]); + setDeletedFileIds([]); + setError(""); + editor.commands.clearContent(); + }, + }); + } catch (error) { + console.error(error); + setError("Произошла ошибка при отправке комментария."); + } + }; + + const handleDeleteFile = (fileId: string) => { + if (fileId.startsWith("blob:")) { + setPendingFiles((prev) => + prev.filter((pending) => pending.localUrl !== fileId) + ); + } else { + setDeletedFileIds((prev) => Array.from(new Set([...prev, fileId]))); } }; return ( - + {error && {error}} - + ); }; diff --git a/src/shared/features/send-comment/view/send-comment.types.ts b/src/shared/features/send-comment/view/send-comment.types.ts index 0a9b1a3d..690c9618 100644 --- a/src/shared/features/send-comment/view/send-comment.types.ts +++ b/src/shared/features/send-comment/view/send-comment.types.ts @@ -1,7 +1,13 @@ -import { SendCommentMutationFn, Maybe } from "api/graphql/generated/graphql"; +import { + SendCommentMutationFn, + Maybe, + UpdateCommentMutationFn, +} from "api/graphql/generated/graphql"; export interface ISendComment { sendComment: SendCommentMutationFn; - loading: boolean; + updateComment: UpdateCommentMutationFn; + loadingUpdateComment: boolean; + loadingSendComment: boolean; homeworkId?: Maybe; } diff --git a/src/shared/features/send-homework/container/send-homework-container.tsx b/src/shared/features/send-homework/container/send-homework-container.tsx index dfa959bb..849ca25c 100644 --- a/src/shared/features/send-homework/container/send-homework-container.tsx +++ b/src/shared/features/send-homework/container/send-homework-container.tsx @@ -6,17 +6,17 @@ import { HomeWorkByLectureAndTrainingQuery, Maybe, useCreateHomeWorkToCheckMutation, + useSendHomeWorkToCheckMutation, + useUpdateHomeworkMutation, } from "api/graphql/generated/graphql"; import SendHomework from "../view"; -const SendHomeworkContainer: FC<{ homeWorkId?: Maybe }> = ({ - homeWorkId, -}) => { +const SendHomeworkContainer: FC = () => { const { lectureId, trainingId } = useParams(); - const [createHomeWorkToCheck, { loading }] = useCreateHomeWorkToCheckMutation( - { + const [createHomeWorkToCheck, { loading: loadingCreateHomeWorkToCheck }] = + useCreateHomeWorkToCheckMutation({ update: (cache, { data }) => { const newCreateHomeWorkToCheck = data?.createHomeWorkToCheck; @@ -29,7 +29,7 @@ const SendHomeworkContainer: FC<{ homeWorkId?: Maybe }> = ({ const updatedHomeWorkByLectureAndTraining = { homeWorkByLectureAndTraining: { ...existingHomeWorkByLectureAndTraining?.homeWorkByLectureAndTraining, - answer: newCreateHomeWorkToCheck?.answer, + ...newCreateHomeWorkToCheck, }, }; @@ -39,14 +39,45 @@ const SendHomeworkContainer: FC<{ homeWorkId?: Maybe }> = ({ data: updatedHomeWorkByLectureAndTraining, }); }, - } - ); + }); + + const [updateHomework, { loading: loadingUpdateHomework }] = + useUpdateHomeworkMutation({ + update: (cache, { data }) => { + const newUpdateHomework = data?.updateHomeWork; + + const existingHomeWorkByLectureAndTraining: Maybe = + cache.readQuery({ + query: HomeWorkByLectureAndTrainingDocument, + variables: { lectureId: lectureId!, trainingId: trainingId! }, + }); + + const updatedHomeWorkByLectureAndTraining = { + homeWorkByLectureAndTraining: { + ...existingHomeWorkByLectureAndTraining?.homeWorkByLectureAndTraining, + ...newUpdateHomework, + }, + }; + + cache.writeQuery({ + query: HomeWorkByLectureAndTrainingDocument, + variables: { lectureId: lectureId!, trainingId: trainingId! }, + data: updatedHomeWorkByLectureAndTraining, + }); + }, + }); + + const [sendHomeWorkToCheck, { loading: loadingSendHomeWorkToCheck }] = + useSendHomeWorkToCheckMutation(); return ( ); }; diff --git a/src/shared/features/send-homework/view/send-homework.styled.ts b/src/shared/features/send-homework/view/send-homework.styled.ts index 0420a2c3..3175ba10 100644 --- a/src/shared/features/send-homework/view/send-homework.styled.ts +++ b/src/shared/features/send-homework/view/send-homework.styled.ts @@ -1,6 +1,6 @@ import { styled } from "@mui/system"; import { LoadingButton } from "@mui/lab"; -import { Stack, Box, FormHelperText } from "@mui/material"; +import { Stack, Box, FormHelperText, IconButton } from "@mui/material"; export const StyledBox = styled(Box)({ width: "100%", @@ -21,3 +21,7 @@ export const StyledStack = styled(Stack)({ flexDirection: "column", alignItems: "flex-end", }); + +export const StyledIconButton = styled(IconButton)({ + padding: "10px 4px", +}); diff --git a/src/shared/features/send-homework/view/send-homework.tsx b/src/shared/features/send-homework/view/send-homework.tsx index 950fc063..80dbc1d6 100644 --- a/src/shared/features/send-homework/view/send-homework.tsx +++ b/src/shared/features/send-homework/view/send-homework.tsx @@ -1,48 +1,163 @@ import { FC, useRef, useState } from "react"; import { useParams } from "react-router-dom"; +import { HOMEWORK_FILE_GET_URI } from "config"; -import { type RichTextEditorRef } from "shared/lib/mui-tiptap"; +import { createUrlWithParams } from "shared/utils"; +import { collectFileIds, type RichTextEditorRef } from "shared/lib/mui-tiptap"; import { Editor } from "shared/components/text-editor"; +import { useHomeworkFileDelete, useHomeworkFileUpload } from "shared/hooks"; +import { PendingFile } from "shared/components/text-editor/types"; import SendButtons from "shared/components/send-buttons"; +import { findNodeByUrl } from "shared/lib/mui-tiptap/utils/find-node-by-url"; +import { blobUrlToFile } from "shared/lib/mui-tiptap/utils/blob-url-to-file"; import { ISendHomeWork } from "./send-homework.types"; import { StyledBox, StyledFormHelperText } from "./send-homework.styled"; const SendHomework: FC = (props) => { - const { createHomeWorkToCheck, loading, homeWorkId } = props; + const { + createHomeWorkToCheck, + sendHomeWorkToCheck, + loadingCreateHomeWorkToCheck, + loadingSendHomeWorkToCheck, + loadingUpdateHomework, + updateHomework, + } = props; const { lectureId, trainingId } = useParams(); - const [error, setError] = useState(""); + const rteRef = useRef(null); + const [pendingFiles, setPendingFiles] = useState([]); + const [deletedFileIds, setDeletedFileIds] = useState([]); + const { uploadHomeworkFile } = useHomeworkFileUpload(); + const { deleteHomeworkFile } = useHomeworkFileDelete(); + const [error, setError] = useState(""); const handleSendHomeWork = () => { - const content = rteRef.current?.editor?.getHTML() ?? ""; - - if (content.trim() !== "" && content.trim() !== "

") { - try { - createHomeWorkToCheck({ - variables: { - lectureId: lectureId!, - trainingId: trainingId!, - content: rteRef.current?.editor?.getHTML() ?? "", - }, - }); - setError(""); - rteRef.current?.editor?.commands.clearContent(); - } catch (error) { - setError("Произошла ошибка при отправке д/з."); - } - } else { + if (!rteRef.current?.editor) return; + + let content = rteRef.current.editor.getHTML().trim(); + if (!content || content === "

") { setError("Введите текст"); + return; + } + + try { + createHomeWorkToCheck({ + variables: { + lectureId: lectureId!, + trainingId: trainingId!, + content, + }, + onCompleted: async (response) => { + const homeWorkId = response?.createHomeWorkToCheck?.id; + if (!homeWorkId) return; + + const editor = rteRef.current?.editor; + if (!editor) return; + + const contentBlobUrls = Array.from( + content.matchAll(/(blob:[^"'\s>]+)/g) + ).map((match) => match[1]); + + const recoveredBlobs = contentBlobUrls + .filter((url) => !pendingFiles.find((f) => f.localUrl === url)) + .map((url) => { + const node = findNodeByUrl(editor.state.doc, url); + const fileName = node?.fileName || "recovered_file"; + return { file: blobUrlToFile(url, fileName), localUrl: url }; + }); + + const resolvedRecoveredFiles = await Promise.all( + recoveredBlobs.map(async ({ file, localUrl }) => ({ + localUrl, + file: await file, + })) + ); + + const allFilesToUpload = [...pendingFiles, ...resolvedRecoveredFiles]; + + const uploadPromises = allFilesToUpload.map( + async ({ file, localUrl }) => { + const uploadedFile = await uploadHomeworkFile(file, homeWorkId); + + const realUrl = createUrlWithParams(HOMEWORK_FILE_GET_URI, { + homeWorkId, + fileId: uploadedFile?.id!, + }); + + return { localUrl, realUrl }; + } + ); + + const results = await Promise.all(uploadPromises); + + results.forEach(({ localUrl, realUrl }) => { + content = content.replaceAll(localUrl, realUrl); + }); + + await updateHomework({ + variables: { + id: homeWorkId, + content, + }, + }); + + const contentNode = editor.state.doc; + const currentFileIds = collectFileIds(contentNode); + const stillDeleted = deletedFileIds.filter( + (id) => !currentFileIds.includes(id) + ); + + for (const id of stillDeleted) { + await deleteHomeworkFile(homeWorkId, id); + } + + await sendHomeWorkToCheck({ + variables: { homeWorkId }, + }); + + setPendingFiles([]); + setDeletedFileIds([]); + setError(""); + editor.commands.clearContent(); + }, + }); + } catch (error) { + console.error(error); + setError("Произошла ошибка при отправке д/з."); + } + }; + + const handleDeleteFile = (fileId: string) => { + if (fileId.startsWith("blob:")) { + setPendingFiles((prev) => + prev.filter((pending) => pending.localUrl !== fileId) + ); + } else { + setDeletedFileIds((prev) => Array.from(new Set([...prev, fileId]))); } }; return (
- + {error && {error}} + + - ); }; diff --git a/src/shared/features/send-homework/view/send-homework.types.ts b/src/shared/features/send-homework/view/send-homework.types.ts index 26dc7512..bd7dc54d 100644 --- a/src/shared/features/send-homework/view/send-homework.types.ts +++ b/src/shared/features/send-homework/view/send-homework.types.ts @@ -1,10 +1,16 @@ import { CreateHomeWorkToCheckMutationFn, Maybe, + SendHomeWorkToCheckMutationFn, + UpdateCommentMutationFn, } from "api/graphql/generated/graphql"; export interface ISendHomeWork { createHomeWorkToCheck: CreateHomeWorkToCheckMutationFn; - loading: boolean; + updateHomework: UpdateCommentMutationFn; + sendHomeWorkToCheck: SendHomeWorkToCheckMutationFn; + loadingCreateHomeWorkToCheck: boolean; + loadingUpdateHomework: boolean; + loadingSendHomeWorkToCheck: boolean; homeWorkId?: Maybe; } diff --git a/src/shared/features/update-comment/view/update-comment.tsx b/src/shared/features/update-comment/view/update-comment.tsx index c3281b40..529cf5b9 100644 --- a/src/shared/features/update-comment/view/update-comment.tsx +++ b/src/shared/features/update-comment/view/update-comment.tsx @@ -1,9 +1,16 @@ import { FC, useRef, useState } from "react"; +import { HOMEWORK_COMMENT_FILE_GET_URI } from "config"; import { type RichTextEditorRef } from "shared/lib/mui-tiptap"; import { CommentEditor } from "shared/components/text-editor"; import SendButtons from "shared/components/send-buttons"; import { useComment } from "shared/hooks/use-comment"; +import { + useHomeworkCommentFileDelete, + useHomeworkCommentFileUpload, + useRichTextFileManager, +} from "shared/hooks"; +import { createUrlWithParams } from "shared/utils"; import { IUpdateComment } from "./update-comment.types"; import { @@ -16,24 +23,71 @@ const UpdateComment: FC = (props) => { const { loading, updateComment, commentId, content } = props; const rteRef = useRef(null); const [error, setError] = useState(""); + const { uploadHomeworkCommentFile } = useHomeworkCommentFileUpload(); + const { deleteHomeworkCommentFile } = useHomeworkCommentFileDelete(); const { setSelectedComment } = useComment(); - const handleUpdateComment = () => { - const content = rteRef.current?.editor?.getHTML() ?? ""; + const { + pendingFiles, + setPendingFiles, + deleteFile: handleDeleteFile, + extractBlobUrls, + recoverMissingFiles, + uploadAllFiles, + removeDeletedFiles, + resetState, + } = useRichTextFileManager({ + upload: async (file, commentId) => { + const result = await uploadHomeworkCommentFile(file, commentId); + if (!result?.id) throw new Error("Upload failed"); + return { id: result.id }; + }, + remove: async (commentId, fileId) => { + const result = await deleteHomeworkCommentFile(commentId, fileId); + if (result === null) throw new Error("Delete failed"); + }, + fileUrlBuilder: (fileId, commentId) => + createUrlWithParams(HOMEWORK_COMMENT_FILE_GET_URI, { + commentId, + fileId, + }), + getEntityId: () => commentId!, + }); + + const handleUpdateComment = async () => { + const editor = rteRef.current?.editor; + if (!editor || !commentId) return; + + let html = editor.getHTML().trim(); + if (!html || html === "

") { + setError("Введите текст"); + return; + } + + try { + const blobUrls = extractBlobUrls(html); + const recoveredFiles = await recoverMissingFiles( + blobUrls, + editor.state.doc + ); + const allFiles = [...pendingFiles, ...recoveredFiles]; + + html = await uploadAllFiles(allFiles, html); + + await updateComment({ + variables: { id: commentId, content: html }, + onCompleted: async () => { + await removeDeletedFiles(editor.state.doc); - if (commentId && content.trim() !== "" && content.trim() !== "

") { - updateComment({ - variables: { - id: commentId, - content: rteRef.current?.editor?.getHTML() ?? "", - }, - onCompleted: () => { setSelectedComment(null); + resetState(); + setError(""); + editor.commands.clearContent(); }, }); - setError(""); - } else { - setError("Введите текст"); + } catch (err) { + console.error(err); + setError("Произошла ошибка при редактировании комментария"); } }; @@ -41,7 +95,13 @@ const UpdateComment: FC = (props) => {
- + {error && {error}} ({ @@ -29,3 +29,9 @@ export const StyledLoadingButton = styled(LoadingButton)(({ theme }) => ({ export const StyledCancelButton = styled(Button)(({ theme }) => ({ color: theme.palette.app.black, })); + +export const StyledFormHelperText = styled(FormHelperText)(({ theme }) => ({ + position: "absolute", + color: theme.palette.app.red, + margin: "5px 5px 0", +})); diff --git a/src/shared/features/update-homework/view/update-homework.tsx b/src/shared/features/update-homework/view/update-homework.tsx index 4954f15d..2697c47d 100644 --- a/src/shared/features/update-homework/view/update-homework.tsx +++ b/src/shared/features/update-homework/view/update-homework.tsx @@ -1,28 +1,88 @@ -import { FC, useRef } from "react"; +import { FC, useRef, useState } from "react"; +import { HOMEWORK_FILE_GET_URI } from "config"; import { type RichTextEditorRef } from "shared/lib/mui-tiptap"; import { Editor } from "shared/components/text-editor"; import SendButtons from "shared/components/send-buttons"; +import { createUrlWithParams } from "shared/utils"; +import { + useHomeworkFileDelete, + useHomeworkFileUpload, + useRichTextFileManager, +} from "shared/hooks"; import { IUpdateHomeWork } from "./update-homework.types"; -import { StyledBox, StyledWrapper } from "./update-homework.styled"; +import { + StyledBox, + StyledFormHelperText, + StyledWrapper, +} from "./update-homework.styled"; const UpdateHomework: FC = (props) => { const { loading, updateHomework, setOpenHomeWorkEdit, answer, homeWorkId } = props; const rteRef = useRef(null); + const [error, setError] = useState(""); + const { uploadHomeworkFile } = useHomeworkFileUpload(); + const { deleteHomeworkFile } = useHomeworkFileDelete(); - const handleUpdateHomework = () => { - if (rteRef && homeWorkId) { - updateHomework({ - variables: { - id: homeWorkId, - content: rteRef.current?.editor?.getHTML() ?? "", - }, - onCompleted: () => { - setOpenHomeWorkEdit(false); - }, + const { + pendingFiles, + setPendingFiles, + deleteFile: handleDeleteFile, + extractBlobUrls, + recoverMissingFiles, + uploadAllFiles, + removeDeletedFiles, + resetState, + } = useRichTextFileManager({ + upload: async (file, homeWorkId) => { + const result = await uploadHomeworkFile(file, homeWorkId); + if (!result?.id) throw new Error("Upload failed or missing file ID"); + return { id: result.id }; + }, + remove: async (homeWorkId, fileId) => { + const result = await deleteHomeworkFile(homeWorkId, fileId); + if (result === null) throw new Error("Deletion failed"); + }, + fileUrlBuilder: (fileId, homeWorkId) => + createUrlWithParams(HOMEWORK_FILE_GET_URI, { homeWorkId, fileId }), + getEntityId: () => homeWorkId!, + }); + + const handleUpdateHomework = async () => { + const editor = rteRef.current?.editor; + if (!editor || !homeWorkId) return; + + let content = editor.getHTML().trim(); + if (!content || content === "

") { + setError("Введите текст"); + return; + } + + try { + const blobUrls = extractBlobUrls(content); + const recoveredFiles = await recoverMissingFiles( + blobUrls, + editor.state.doc + ); + const allFiles = [...pendingFiles, ...recoveredFiles]; + + content = await uploadAllFiles(allFiles, content); + + await updateHomework({ + variables: { id: homeWorkId, content }, }); + + await removeDeletedFiles(editor.state.doc); + + setOpenHomeWorkEdit(false); + resetState(); + setError(""); + editor.commands.clearContent(); + } catch (err) { + console.error(err); + setError("Произошла ошибка при редактировании д/з."); } }; @@ -30,7 +90,15 @@ const UpdateHomework: FC = (props) => { - + + {error && {error}} + setOpenHomeWorkEdit(false)} diff --git a/src/shared/helpers/format-status.ts b/src/shared/helpers/format-status.ts index c698aecf..c8522d1a 100644 --- a/src/shared/helpers/format-status.ts +++ b/src/shared/helpers/format-status.ts @@ -4,7 +4,7 @@ export const formatStatus = (status: string) => { return "Принято"; case "IN_REVIEW": return "На проверке"; - case "NEW": + case "REVIEW": return "Новые"; case "NOT_APPROVED": return "Не принято"; diff --git a/src/shared/helpers/get-allowed-columns.ts b/src/shared/helpers/get-allowed-columns.ts index d90e027d..3d911a98 100644 --- a/src/shared/helpers/get-allowed-columns.ts +++ b/src/shared/helpers/get-allowed-columns.ts @@ -6,7 +6,7 @@ export const getAllowedColumns = (title: StudentHomeWorkStatus) => { let allowedColumns: string[] = []; switch (title) { - case StudentHomeWorkStatus.New: + case StudentHomeWorkStatus.Review: allowedColumns = [STATUS_COLUMN.IN_REVIEW]; break; case StudentHomeWorkStatus.InReview: diff --git a/src/shared/helpers/get-updated-allowed-columns.ts b/src/shared/helpers/get-updated-allowed-columns.ts index 238c9a3c..7350cbb2 100644 --- a/src/shared/helpers/get-updated-allowed-columns.ts +++ b/src/shared/helpers/get-updated-allowed-columns.ts @@ -16,6 +16,10 @@ export const getUpdatedAllowedColumns = ( allowedRoles: [UserRole.Mentor, UserRole.Lector, UserRole.Admin], }); + const hasAdminRoleAcces = useRoleAccess({ + allowedRoles: [UserRole.Admin], + }); + if (!hasDraggAccess) { return []; } @@ -27,17 +31,17 @@ export const getUpdatedAllowedColumns = ( allowedColumns = [STATUS_COLUMN.IN_REVIEW]; break; case STATUS_COLUMN.IN_REVIEW: - if (currentUserId === mentorId) { + if (hasAdminRoleAcces || currentUserId === mentorId) { allowedColumns = [STATUS_COLUMN.APPROVED, STATUS_COLUMN.NOT_APPROVED]; } break; case STATUS_COLUMN.APPROVED: - if (currentUserId === mentorId) { + if (hasAdminRoleAcces || currentUserId === mentorId) { allowedColumns = []; } break; case STATUS_COLUMN.NOT_APPROVED: - if (currentUserId === mentorId) { + if (hasAdminRoleAcces || currentUserId === mentorId) { allowedColumns = [STATUS_COLUMN.APPROVED]; } break; diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index b75e953b..6bdc99d4 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -8,3 +8,15 @@ export { useResponsive } from "./use-responsive"; export { useRoleAccess } from "./use-role-access"; export { useSettings } from "./use-theme"; export { useDragEffect } from "./use-drag-effect"; +export { useHomeworkFileDelete } from "./use-homework-file-delete"; +export { useHomeworkFileGet } from "./use-homework-file-get"; +export { useHomeworkFileUpload } from "./use-homework-file-upload"; +export { useLectureFileGet } from "./use-lecture-file-get"; +export { useLectureFileUpload } from "./use-lecture-file-upload"; +export { useLectureFileDelete } from "./use-lecture-file-delete"; +export { useLectureHomeworkFileUpload } from "./use-lecture-homework-file-upload"; +export { useLectureHomeworkFileDelete } from "./use-lecture-homework-file-delete"; +export { useHomeworkCommentFileGet } from "./use-homework-comment-file-get"; +export { useHomeworkCommentFileUpload } from "./use-homework-comment-file-upload"; +export { useHomeworkCommentFileDelete } from "./use-homework-comment-file-delete"; +export { useRichTextFileManager } from "./use-rich-text-file-manager"; diff --git a/src/shared/hooks/use-drag-effect.ts b/src/shared/hooks/use-drag-effect.ts index 1696245a..748f9bcd 100644 --- a/src/shared/hooks/use-drag-effect.ts +++ b/src/shared/hooks/use-drag-effect.ts @@ -32,6 +32,10 @@ export const useDragEffect = ({ allowedRoles: [UserRole.Mentor, UserRole.Lector, UserRole.Admin], }); + const hasAdminRoleAcces = useRoleAccess({ + allowedRoles: [UserRole.Admin], + }); + const currentUserId = useReactiveVar(userIdVar); const mentorId = card.mentor?.id; @@ -54,7 +58,11 @@ export const useDragEffect = ({ return; } - if (currentUserId !== mentorId && (isFromInReview || isFromNotApproved)) { + if ( + !hasAdminRoleAcces && + currentUserId !== mentorId && + (isFromInReview || isFromNotApproved) + ) { enqueueSnackbar("Вы не можете поменять статус чужой домашней работы"); return; } @@ -76,5 +84,6 @@ export const useDragEffect = ({ mentorId, enqueueSnackbar, setDraggingState, + hasAdminRoleAcces, ]); }; diff --git a/src/shared/hooks/use-homework-comment-file-delete.ts b/src/shared/hooks/use-homework-comment-file-delete.ts new file mode 100644 index 00000000..c49c1db1 --- /dev/null +++ b/src/shared/hooks/use-homework-comment-file-delete.ts @@ -0,0 +1,45 @@ +import { useState } from "react"; +import { enqueueSnackbar } from "notistack"; + +import { Maybe } from "api/graphql/generated/graphql"; +import { RESPONSE_STATUS } from "shared/constants"; +import HomeworkCommentFileService from "api/rest/homework-comment-serivce"; + +export const useHomeworkCommentFileDelete = () => { + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState>(null); + + const deleteHomeworkCommentFile = async ( + commentId: string, + fileId: string + ) => { + setDeleting(true); + setError(null); + + try { + const response = await HomeworkCommentFileService.deleteFile( + commentId, + fileId + ); + + if (response.status === RESPONSE_STATUS.SUCCESSFUL) { + setDeleting(false); + enqueueSnackbar(`Файл успешно удален`, { + variant: "success", + }); + return response.data; + } else { + setDeleting(false); + enqueueSnackbar(`Не удалось удалить файл`); + return null; + } + } catch (err) { + setError(err as Error); + enqueueSnackbar(`Не удалось удалить файл`); + setDeleting(false); + return null; + } + }; + + return { deleteHomeworkCommentFile, deleting, error }; +}; diff --git a/src/shared/hooks/use-homework-comment-file-get.ts b/src/shared/hooks/use-homework-comment-file-get.ts new file mode 100644 index 00000000..ea7d42ec --- /dev/null +++ b/src/shared/hooks/use-homework-comment-file-get.ts @@ -0,0 +1,34 @@ +import { useState } from "react"; +import { enqueueSnackbar } from "notistack"; + +import HomeworkCommentFileService from "api/rest/homework-comment-serivce"; +import { Maybe } from "api/graphql/generated/graphql"; + +export const useHomeworkCommentFileGet = () => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState>(null); + + const getHomeworkCommentFile = async (commentId: string, fileId: string) => { + setLoading(true); + setError(null); + + try { + const response = await HomeworkCommentFileService.getFile( + commentId, + fileId + ); + setLoading(false); + enqueueSnackbar(`Файл успешно загружен`, { + variant: "success", + }); + return response.data; + } catch (err) { + setError(err as Error); + enqueueSnackbar(`Не удалось получить файл`, { variant: "error" }); + setLoading(false); + return null; + } + }; + + return { getHomeworkCommentFile, loading, error }; +}; diff --git a/src/shared/hooks/use-homework-comment-file-upload.ts b/src/shared/hooks/use-homework-comment-file-upload.ts new file mode 100644 index 00000000..9b2882f1 --- /dev/null +++ b/src/shared/hooks/use-homework-comment-file-upload.ts @@ -0,0 +1,42 @@ +import { useState } from "react"; +import { enqueueSnackbar } from "notistack"; + +import HomeworkCommentFileService from "api/rest/homework-comment-serivce"; +import { Maybe } from "api/graphql/generated/graphql"; +import { RESPONSE_STATUS } from "shared/constants"; + +export const useHomeworkCommentFileUpload = () => { + const [uploading, setUploading] = useState(false); + const [error, setError] = useState>(null); + + const uploadHomeworkCommentFile = async (file: File, commentId: string) => { + setUploading(true); + setError(null); + + try { + const response = await HomeworkCommentFileService.uploadFile( + commentId, + file + ); + + if (response.status === RESPONSE_STATUS.SUCCESSFUL) { + setUploading(false); + enqueueSnackbar(`Файл успешно загружен`, { + variant: "success", + }); + return response.data; + } else { + setUploading(false); + enqueueSnackbar(`Не удалось загрузить файл`, { variant: "error" }); + return null; + } + } catch (err) { + setError(err as Error); + enqueueSnackbar(`Не удалось загрузить файл`, { variant: "error" }); + setUploading(false); + return null; + } + }; + + return { uploadHomeworkCommentFile, uploading, error }; +}; diff --git a/src/shared/components/text-editor/hooks/use-homework-file-delete.ts b/src/shared/hooks/use-homework-file-delete.ts similarity index 92% rename from src/shared/components/text-editor/hooks/use-homework-file-delete.ts rename to src/shared/hooks/use-homework-file-delete.ts index 79bc7aca..be9abbff 100644 --- a/src/shared/components/text-editor/hooks/use-homework-file-delete.ts +++ b/src/shared/hooks/use-homework-file-delete.ts @@ -9,12 +9,12 @@ export const useHomeworkFileDelete = () => { const [deleting, setDeleting] = useState(false); const [error, setError] = useState>(null); - const deleteHomeworkFile = async (homeWorkId: string) => { + const deleteHomeworkFile = async (homeWorkId: string, fileId: string) => { setDeleting(true); setError(null); try { - const response = await HomeworkFileService.deleteFile(homeWorkId); + const response = await HomeworkFileService.deleteFile(homeWorkId, fileId); if (response.status === RESPONSE_STATUS.SUCCESSFUL) { setDeleting(false); diff --git a/src/shared/components/text-editor/hooks/use-homework-file-get.ts b/src/shared/hooks/use-homework-file-get.ts similarity index 100% rename from src/shared/components/text-editor/hooks/use-homework-file-get.ts rename to src/shared/hooks/use-homework-file-get.ts diff --git a/src/shared/components/text-editor/hooks/use-homework-file-upload.ts b/src/shared/hooks/use-homework-file-upload.ts similarity index 100% rename from src/shared/components/text-editor/hooks/use-homework-file-upload.ts rename to src/shared/hooks/use-homework-file-upload.ts diff --git a/src/shared/hooks/use-lecture-file-delete.ts b/src/shared/hooks/use-lecture-file-delete.ts new file mode 100644 index 00000000..f9969971 --- /dev/null +++ b/src/shared/hooks/use-lecture-file-delete.ts @@ -0,0 +1,39 @@ +import { useState } from "react"; +import { enqueueSnackbar } from "notistack"; + +import { Maybe } from "api/graphql/generated/graphql"; +import { RESPONSE_STATUS } from "shared/constants"; +import LectureFileService from "api/rest/lecture-file-service"; + +export const useLectureFileDelete = () => { + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState>(null); + + const deleteLectureFile = async (homeWorkId: string, fileId: string) => { + setDeleting(true); + setError(null); + + try { + const response = await LectureFileService.deleteFile(homeWorkId, fileId); + + if (response.status === RESPONSE_STATUS.SUCCESSFUL) { + setDeleting(false); + enqueueSnackbar(`Файл успешно удален`, { + variant: "success", + }); + return response.data; + } else { + setDeleting(false); + enqueueSnackbar(`Не удалось удалить файл`); + return null; + } + } catch (err) { + setError(err as Error); + enqueueSnackbar(`Не удалось удалить файл`); + setDeleting(false); + return null; + } + }; + + return { deleteLectureFile, deleting, error }; +}; diff --git a/src/shared/hooks/use-lecture-file-get.ts b/src/shared/hooks/use-lecture-file-get.ts new file mode 100644 index 00000000..3141fff0 --- /dev/null +++ b/src/shared/hooks/use-lecture-file-get.ts @@ -0,0 +1,31 @@ +import { useState } from "react"; +import { enqueueSnackbar } from "notistack"; + +import LectureFileService from "api/rest/lecture-file-service"; +import { Maybe } from "api/graphql/generated/graphql"; + +export const useLectureFileGet = () => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState>(null); + + const getLectureFile = async (lectureId: string, fileId: string) => { + setLoading(true); + setError(null); + + try { + const response = await LectureFileService.getFile(lectureId, fileId); + setLoading(false); + enqueueSnackbar(`Файл успешно загружен`, { + variant: "success", + }); + return response.data; + } catch (err) { + setError(err as Error); + enqueueSnackbar(`Не удалось получить файл`, { variant: "error" }); + setLoading(false); + return null; + } + }; + + return { getLectureFile, loading, error }; +}; diff --git a/src/shared/hooks/use-lecture-file-upload.ts b/src/shared/hooks/use-lecture-file-upload.ts new file mode 100644 index 00000000..55547be8 --- /dev/null +++ b/src/shared/hooks/use-lecture-file-upload.ts @@ -0,0 +1,39 @@ +import { useState } from "react"; +import { enqueueSnackbar } from "notistack"; + +import { Maybe } from "api/graphql/generated/graphql"; +import LectureFileService from "api/rest/lecture-file-service"; +import { RESPONSE_STATUS } from "shared/constants"; + +export const useLectureFileUpload = () => { + const [uploading, setUploading] = useState(false); + const [error, setError] = useState>(null); + + const uploadLectureFile = async (file: File, lectureId: string) => { + setUploading(true); + setError(null); + + try { + const response = await LectureFileService.uploadFile(lectureId, file); + + if (response.status === RESPONSE_STATUS.SUCCESSFUL) { + setUploading(false); + enqueueSnackbar(`Файл успешно загружен`, { + variant: "success", + }); + return response.data; + } else { + setUploading(false); + enqueueSnackbar(`Не удалось загрузить файл`, { variant: "error" }); + return null; + } + } catch (err) { + setError(err as Error); + enqueueSnackbar(`Не удалось загрузить файл`, { variant: "error" }); + setUploading(false); + return null; + } + }; + + return { uploadLectureFile, uploading, error }; +}; diff --git a/src/shared/hooks/use-lecture-homework-file-delete.ts b/src/shared/hooks/use-lecture-homework-file-delete.ts new file mode 100644 index 00000000..5a76d8cb --- /dev/null +++ b/src/shared/hooks/use-lecture-homework-file-delete.ts @@ -0,0 +1,45 @@ +import { useState } from "react"; +import { enqueueSnackbar } from "notistack"; + +import LectureHomeworkFileService from "api/rest/lecture-homework-file-service"; +import { Maybe } from "api/graphql/generated/graphql"; +import { RESPONSE_STATUS } from "shared/constants"; + +export const useLectureHomeworkFileDelete = () => { + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState>(null); + + const deleteLectureHomeworkFile = async ( + homeWorkId: string, + fileId: string + ) => { + setDeleting(true); + setError(null); + + try { + const response = await LectureHomeworkFileService.deleteFile( + homeWorkId, + fileId + ); + + if (response.status === RESPONSE_STATUS.SUCCESSFUL) { + setDeleting(false); + enqueueSnackbar(`Файл успешно удален`, { + variant: "success", + }); + return response.data; + } else { + setDeleting(false); + enqueueSnackbar(`Не удалось удалить файл`); + return null; + } + } catch (err) { + setError(err as Error); + enqueueSnackbar(`Не удалось удалить файл`); + setDeleting(false); + return null; + } + }; + + return { deleteLectureHomeworkFile, deleting, error }; +}; diff --git a/src/shared/hooks/use-lecture-homework-file-upload.ts b/src/shared/hooks/use-lecture-homework-file-upload.ts new file mode 100644 index 00000000..bd814ca7 --- /dev/null +++ b/src/shared/hooks/use-lecture-homework-file-upload.ts @@ -0,0 +1,42 @@ +import { useState } from "react"; +import { enqueueSnackbar } from "notistack"; + +import { Maybe } from "api/graphql/generated/graphql"; +import { RESPONSE_STATUS } from "shared/constants"; +import LectureHomeworkFileService from "api/rest/lecture-homework-file-service"; + +export const useLectureHomeworkFileUpload = () => { + const [uploading, setUploading] = useState(false); + const [error, setError] = useState>(null); + + const uploadLectureHomeworkFile = async (file: File, lectureId: string) => { + setUploading(true); + setError(null); + + try { + const response = await LectureHomeworkFileService.uploadFile( + lectureId, + file + ); + + if (response.status === RESPONSE_STATUS.SUCCESSFUL) { + setUploading(false); + enqueueSnackbar(`Файл успешно загружен`, { + variant: "success", + }); + return response.data; + } else { + setUploading(false); + enqueueSnackbar(`Не удалось загрузить файл`, { variant: "error" }); + return null; + } + } catch (err) { + setError(err as Error); + enqueueSnackbar(`Не удалось загрузить файл`, { variant: "error" }); + setUploading(false); + return null; + } + }; + + return { uploadLectureHomeworkFile, uploading, error }; +}; diff --git a/src/shared/hooks/use-rich-text-file-manager.ts b/src/shared/hooks/use-rich-text-file-manager.ts new file mode 100644 index 00000000..8f1c5328 --- /dev/null +++ b/src/shared/hooks/use-rich-text-file-manager.ts @@ -0,0 +1,105 @@ +import { useState } from "react"; +import type { Node as ProseMirrorNode } from "prosemirror-model"; + +import { collectFileIds } from "shared/lib/mui-tiptap"; +import { blobUrlToFile } from "shared/lib/mui-tiptap/utils/blob-url-to-file"; +import { findNodeByUrl } from "shared/lib/mui-tiptap/utils/find-node-by-url"; +import type { PendingFile } from "shared/components/text-editor/types"; + +interface UseFileManagerParams { + upload: (file: File, entityId: string) => Promise<{ id: string }>; + remove: (entityId: string, fileId: string) => Promise; + fileUrlBuilder: (fileId: string, entityId: string) => string; + getEntityId: () => string; +} + +export function useRichTextFileManager({ + upload, + remove, + fileUrlBuilder, + getEntityId, +}: UseFileManagerParams) { + const [pendingFiles, setPendingFiles] = useState([]); + const [deletedFileIds, setDeletedFileIds] = useState([]); + + const extractBlobUrls = (html: string): string[] => + Array.from(html.matchAll(/(blob:[^"'\s>]+)/g)).map((m) => m[1]); + + const recoverMissingFiles = async ( + blobUrls: string[], + doc: ProseMirrorNode + // eslint-disable-next-line require-await + ): Promise => { + const missing = blobUrls.filter( + (url) => !pendingFiles.find((f) => f.localUrl === url) + ); + + return Promise.all( + missing.map(async (url) => { + const node = findNodeByUrl(doc, url); + const file = await blobUrlToFile(url, node?.fileName || "recovered"); + return { file, localUrl: url }; + }) + ); + }; + + const uploadAllFiles = async ( + allFiles: PendingFile[], + html: string + ): Promise => { + const entityId = getEntityId(); + + const uploads = await Promise.all( + allFiles.map(async ({ file, localUrl }) => { + const uploaded = await upload(file, entityId); + const realUrl = fileUrlBuilder(uploaded.id, entityId); + return { localUrl, realUrl }; + }) + ); + + let updatedHtml = html; + uploads.forEach(({ localUrl, realUrl }) => { + updatedHtml = updatedHtml.replaceAll(localUrl, realUrl); + }); + + return updatedHtml; + }; + + const removeDeletedFiles = async (doc: ProseMirrorNode) => { + const entityId = getEntityId(); + const currentFileIds = collectFileIds(doc); + + const stillDeleted = deletedFileIds.filter( + (id) => !currentFileIds.includes(id) + ); + + for (const id of stillDeleted) { + await remove(entityId, id); + } + }; + + const deleteFile = (fileId: string) => { + if (fileId.startsWith("blob:")) { + setPendingFiles((prev) => prev.filter((f) => f.localUrl !== fileId)); + } else { + setDeletedFileIds((prev) => Array.from(new Set([...prev, fileId]))); + } + }; + + const resetState = () => { + setPendingFiles([]); + setDeletedFileIds([]); + }; + + return { + pendingFiles, + setPendingFiles, + deletedFileIds, + deleteFile, + extractBlobUrls, + recoverMissingFiles, + uploadAllFiles, + removeDeletedFiles, + resetState, + }; +} diff --git a/src/shared/lib/mui-tiptap/extensions/file-deletion-tracker.ts b/src/shared/lib/mui-tiptap/extensions/file-deletion-tracker.ts new file mode 100644 index 00000000..819a63e8 --- /dev/null +++ b/src/shared/lib/mui-tiptap/extensions/file-deletion-tracker.ts @@ -0,0 +1,45 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "prosemirror-state"; + +import { collectFileIds } from "../utils"; + +export const FileDeletionTracker = Extension.create<{ + onFileDelete: (fileIdOrBlob: string) => void; +}>({ + name: "fileDeletionTracker", + + addOptions() { + return { + onFileDelete: () => {}, + }; + }, + + addProseMirrorPlugins() { + const onFileDelete = this.options?.onFileDelete; + + return [ + new Plugin({ + key: new PluginKey("file-deletion-tracker"), + appendTransaction(transactions, oldState, newState) { + const docChanged = transactions.some((tr) => tr.docChanged); + if (!docChanged) return null; + + const oldFileIds = collectFileIds(oldState.doc); + const newFileIds = collectFileIds(newState.doc); + + const deletedIds = oldFileIds.filter( + (id) => !newFileIds.includes(id) + ); + + if (typeof onFileDelete === "function") { + deletedIds.forEach((id) => { + onFileDelete(id); + }); + } + + return null; + }, + }), + ]; + }, +}); diff --git a/src/shared/lib/mui-tiptap/extensions/file-node-view.tsx b/src/shared/lib/mui-tiptap/extensions/file-node-view.tsx new file mode 100644 index 00000000..ec840e62 --- /dev/null +++ b/src/shared/lib/mui-tiptap/extensions/file-node-view.tsx @@ -0,0 +1,92 @@ +import { NodeViewWrapper } from "@tiptap/react"; +import { + IconButton, + Typography, + Tooltip, + Paper, + useTheme, +} from "@mui/material"; +import DownloadIcon from "@mui/icons-material/Download"; +import CloseIcon from "@mui/icons-material/Close"; + +export default function FileNodeView({ node, deleteNode }: any) { + const { href, fileName } = node.attrs; + const theme = useTheme(); + + return ( + + + + + + + + + + {fileName} + + + + + + + + + + ); +} diff --git a/src/shared/lib/mui-tiptap/utils/blob-url-to-file.ts b/src/shared/lib/mui-tiptap/utils/blob-url-to-file.ts new file mode 100644 index 00000000..99181717 --- /dev/null +++ b/src/shared/lib/mui-tiptap/utils/blob-url-to-file.ts @@ -0,0 +1,14 @@ +export async function blobUrlToFile( + blobUrl: string, + name: string +): Promise { + const res = await fetch(blobUrl); + const blob = await res.blob(); + + const hasExtension = /\.[a-zA-Z0-9]+$/.test(name); + const finalName = hasExtension + ? name + : `${name}.${blob.type.split("/")[1] || "bin"}`; + + return new File([blob], finalName, { type: blob.type }); +} diff --git a/src/shared/lib/mui-tiptap/utils/collect-file-ids.ts b/src/shared/lib/mui-tiptap/utils/collect-file-ids.ts new file mode 100644 index 00000000..045dc72e --- /dev/null +++ b/src/shared/lib/mui-tiptap/utils/collect-file-ids.ts @@ -0,0 +1,25 @@ +import type { Node as ProseMirrorNode } from "prosemirror-model"; + +export default function collectFileIds(doc: ProseMirrorNode): string[] { + const ids: string[] = []; + + doc.descendants((node: any) => { + if ( + (node.type.name === "image" || node.type.name === "file") && + (node.attrs?.src || node.attrs?.href) + ) { + const url = node.attrs.src || node.attrs.href; + + if (url.startsWith("blob:")) { + ids.push(url); + } else { + const match = url.match(/\/file\/(\d+)/); + if (match) { + ids.push(match[1]); + } + } + } + }); + + return ids; +} diff --git a/src/shared/lib/mui-tiptap/utils/find-node-by-url.ts b/src/shared/lib/mui-tiptap/utils/find-node-by-url.ts new file mode 100644 index 00000000..306b17ae --- /dev/null +++ b/src/shared/lib/mui-tiptap/utils/find-node-by-url.ts @@ -0,0 +1,21 @@ +import type { Node as ProseMirrorNode } from "prosemirror-model"; + +export function findNodeByUrl( + doc: ProseMirrorNode, + url: string +): { fileName?: string } | null { + let result: { fileName?: string } | null = null; + + doc.descendants((node: any) => { + if ( + (node.type.name === "image" || node.type.name === "file") && + (node.attrs?.src === url || node.attrs?.href === url) + ) { + result = { fileName: node.attrs?.fileName || node.attrs?.alt }; + return false; + } + return true; + }); + + return result; +} diff --git a/src/shared/lib/mui-tiptap/utils/index.ts b/src/shared/lib/mui-tiptap/utils/index.ts index a71766ff..a832a6f5 100644 --- a/src/shared/lib/mui-tiptap/utils/index.ts +++ b/src/shared/lib/mui-tiptap/utils/index.ts @@ -8,3 +8,6 @@ export { getModShortcutKey, isMac, isTouchDevice } from "./platform"; export { default as slugify } from "./slugify"; export { default as truncateMiddle } from "./truncateMiddle"; export * from "./files"; +export { default as collectFileIds } from "./collect-file-ids"; +export { blobUrlToFile } from "./blob-url-to-file"; +export { findNodeByUrl } from "./find-node-by-url"; diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts new file mode 100644 index 00000000..48b8ff99 --- /dev/null +++ b/src/shared/utils/index.ts @@ -0,0 +1 @@ +export { createUrlWithParams } from "./url-utils"; diff --git a/src/shared/utils/url-utils.ts b/src/shared/utils/url-utils.ts new file mode 100644 index 00000000..5a4be762 --- /dev/null +++ b/src/shared/utils/url-utils.ts @@ -0,0 +1,10 @@ +export const createUrlWithParams = ( + urlTemplate: string, + params: Record +): string => { + let url = urlTemplate; + for (const [key, value] of Object.entries(params)) { + url = url.replace(`:${key}`, encodeURIComponent(String(value))); + } + return url; +}; diff --git a/vite.config.ts b/vite.config.ts index ccd3828f..d17c0492 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,14 +6,19 @@ import tsconfigPaths from "vite-tsconfig-paths"; export default ({ mode }: any) => { process.env = { ...process.env, ...loadEnv(mode, process.cwd()) }; + const API_URL = process.env.VITE_APP_ENDPOINT; + const proxyConfig = { - "^/graphql": process.env.VITE_APP_ENDPOINT, - "^/login": process.env.VITE_APP_ENDPOINT, - "^/logout": process.env.VITE_APP_ENDPOINT, - "^/refreshtoken": process.env.VITE_APP_ENDPOINT, - "^/upload/avatar": process.env.VITE_APP_ENDPOINT, - "^/upload/training/.*": process.env.VITE_APP_ENDPOINT, - "^/homework/.*": process.env.VITE_APP_ENDPOINT, + "^/graphql": API_URL, + "^/login": API_URL, + "^/logout": API_URL, + "^/refreshtoken": API_URL, + "^/upload/avatar": API_URL, + "^/upload/training/.*": API_URL, + "^/lecture/.*": API_URL, + "^/student/homework/.*": API_URL, + "^/lecture/.*/homework/.*": API_URL, + "^/homework/comment/.*": API_URL, }; return defineConfig({