diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index b933e6b59..bd1a4d731 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -4,7 +4,6 @@ import { ConditionalModule, ConfigModule, ConfigService } from "@nestjs/config"; import { DrizzlePostgresModule } from "@knaadh/nestjs-drizzle-postgres"; import { JwtModule } from "@nestjs/jwt"; import { Module } from "@nestjs/common"; -import { UsersModule } from "./users/users.module"; import * as schema from "./storage/schema"; import database from "./common/configuration/database"; import jwtConfig from "./common/configuration/jwt"; @@ -25,6 +24,7 @@ import { QuestionsModule } from "./questions/questions.module"; import { StudentCompletedLessonItemsModule } from "./studentCompletedLessonItem/studentCompletedLessonItems.module"; import { S3Module } from "./file/s3.module"; import { StripeModule } from "./stripe/stripe.module"; +import { UsersModule } from "src/users/users.module"; @Module({ imports: [ diff --git a/apps/api/src/categories/api/categories.controller.ts b/apps/api/src/categories/api/categories.controller.ts index 0f9daceea..f0e427194 100644 --- a/apps/api/src/categories/api/categories.controller.ts +++ b/apps/api/src/categories/api/categories.controller.ts @@ -15,25 +15,27 @@ import { BaseResponse, paginatedResponse, PaginatedResponse, + UUIDSchema, + type UUIDType, } from "src/common"; import { CurrentUser } from "src/common/decorators/user.decorator"; -import { UserRole } from "src/users/schemas/user-roles"; +import type { UserRole } from "src/users/schemas/user-roles"; import { CategoriesService } from "../categories.service"; import { - AllCategoriesResponse, - CategorySchema, + type AllCategoriesResponse, + type CategorySchema, categorySchema, } from "../schemas/category.schema"; import { - SortCategoryFieldsOptions, + type SortCategoryFieldsOptions, sortCategoryFieldsOptions, } from "../schemas/categoryQuery"; import { - CreateCategoryBody, + type CreateCategoryBody, createCategorySchema, } from "../schemas/createCategorySchema"; import { - UpdateCategoryBody, + type UpdateCategoryBody, updateCategorySchema, } from "../schemas/updateCategorySchema"; @@ -96,11 +98,17 @@ export class CategoriesController { schema: createCategorySchema, }, ], + response: baseResponse( + Type.Object({ id: UUIDSchema, message: Type.String() }), + ), }) - async createCategory(@Body() createCategoryBody: CreateCategoryBody) { - return new BaseResponse( - await this.categoriesService.createCategory(createCategoryBody), - ); + async createCategory( + @Body() createCategoryBody: CreateCategoryBody, + ): Promise> { + const { id } = + await this.categoriesService.createCategory(createCategoryBody); + + return new BaseResponse({ id, message: "Category created" }); } @Patch(":id") diff --git a/apps/api/src/courses/api/courses.controller.ts b/apps/api/src/courses/api/courses.controller.ts index 83f6e956d..6fd57f6d9 100644 --- a/apps/api/src/courses/api/courses.controller.ts +++ b/apps/api/src/courses/api/courses.controller.ts @@ -20,6 +20,7 @@ import { nullResponse, PaginatedResponse, UUIDSchema, + type UUIDType, } from "src/common"; import { CurrentUser } from "src/common/decorators/user.decorator"; import { CoursesService } from "../courses.service"; @@ -29,7 +30,7 @@ import { type SortCourseFieldsOptions, } from "../schemas/courseQuery"; import { - CreateCourseBody, + type CreateCourseBody, createCourseSchema, } from "../schemas/createCourse.schema"; import { @@ -38,7 +39,7 @@ import { } from "../schemas/showCourseCommon.schema"; import { allCoursesValidation } from "./validations"; import { - UpdateCourseBody, + type UpdateCourseBody, updateCourseSchema, } from "../schemas/updateCourse.schema"; import { RolesGuard } from "src/common/guards/roles.guard"; @@ -190,14 +191,20 @@ export class CoursesController { @Post() @Validate({ request: [{ type: "body", schema: createCourseSchema }], - response: baseResponse(Type.Object({ message: Type.String() })), + response: baseResponse( + Type.Object({ id: UUIDSchema, message: Type.String() }), + ), }) async createCourse( @Body() createCourseBody: CreateCourseBody, @CurrentUser("userId") currentUserId: string, - ): Promise> { - await this.coursesService.createCourse(createCourseBody, currentUserId); - return new BaseResponse({ message: "Course enrolled successfully" }); + ): Promise> { + const { id } = await this.coursesService.createCourse( + createCourseBody, + currentUserId, + ); + + return new BaseResponse({ id, message: "Course created successfully" }); } @Patch(":id") diff --git a/apps/api/src/lessons/adminLessons.service.ts b/apps/api/src/lessons/adminLessons.service.ts index 919a99e9f..165df9efa 100644 --- a/apps/api/src/lessons/adminLessons.service.ts +++ b/apps/api/src/lessons/adminLessons.service.ts @@ -170,6 +170,8 @@ export class AdminLessonsService { ); if (!lesson) throw new NotFoundException("Lesson not found"); + + return { id: lesson.id }; } async updateLesson(id: string, body: UpdateLessonBody) { diff --git a/apps/api/src/lessons/api/lessons.controller.ts b/apps/api/src/lessons/api/lessons.controller.ts index 3feceb6ae..442dc1e71 100644 --- a/apps/api/src/lessons/api/lessons.controller.ts +++ b/apps/api/src/lessons/api/lessons.controller.ts @@ -17,7 +17,7 @@ import { paginatedResponse, PaginatedResponse, UUIDSchema, - UUIDType, + type UUIDType, } from "src/common"; import { Roles } from "src/common/decorators/roles.decorator"; import { CurrentUser } from "src/common/decorators/user.decorator"; @@ -26,34 +26,34 @@ import type { UserRole } from "src/users/schemas/user-roles"; import { AdminLessonItemsService } from "../adminLessonItems.service"; import { LessonsService } from "../lessons.service"; import { - AllLessonsResponse, + type AllLessonsResponse, allLessonsSchema, - CreateLessonBody, + type CreateLessonBody, createLessonSchema, type ShowLessonResponse, showLessonSchema, - UpdateLessonBody, + type UpdateLessonBody, updateLessonSchema, } from "../schemas/lesson.schema"; import { - FileInsertType, + type FileInsertType, fileUpdateSchema, - GetAllLessonItemsResponse, + type GetAllLessonItemsResponse, GetAllLessonItemsResponseSchema, - GetSingleLessonItemsResponse, + type GetSingleLessonItemsResponse, GetSingleLessonItemsResponseSchema, - QuestionInsertType, + type QuestionInsertType, questionUpdateSchema, - TextBlockInsertType, + type TextBlockInsertType, textBlockUpdateSchema, - UpdateFileBody, - UpdateQuestionBody, - UpdateTextBlockBody, + type UpdateFileBody, + type UpdateQuestionBody, + type UpdateTextBlockBody, } from "../schemas/lessonItem.schema"; import { - LessonsFilterSchema, + type LessonsFilterSchema, sortLessonFieldsOptions, - SortLessonFieldsOptions, + type SortLessonFieldsOptions, } from "../schemas/lessonQuery"; import { AdminLessonsService } from "../adminLessons.service"; @@ -165,14 +165,20 @@ export class LessonsController { schema: createLessonSchema, }, ], - response: baseResponse(Type.Object({ message: Type.String() })), + response: baseResponse( + Type.Object({ id: UUIDSchema, message: Type.String() }), + ), }) async createLesson( @Body() createLessonBody: CreateLessonBody, @CurrentUser("userId") userId: string, - ): Promise> { - await this.adminLessonsService.createLesson(createLessonBody, userId); - return new BaseResponse({ message: "Lesson created successfully" }); + ): Promise> { + const { id } = await this.adminLessonsService.createLesson( + createLessonBody, + userId, + ); + + return new BaseResponse({ id, message: "Lesson created successfully" }); } @Patch("lesson") @@ -513,6 +519,7 @@ export class LessonsController { @Body() body: UpdateFileBody, ): Promise> { await this.adminLessonItemsService.updateFileItem(id, body); + return new BaseResponse({ message: "File updated successfully" }); } @@ -530,14 +537,20 @@ export class LessonsController { }), }, ], - response: baseResponse(Type.Object({ message: Type.String() })), + response: baseResponse( + Type.Object({ id: UUIDSchema, message: Type.String() }), + ), }) async createTextBlock( @Body() body: TextBlockInsertType, @CurrentUser("userId") userId: string, - ): Promise> { - await this.adminLessonItemsService.createTextBlock(body, userId); - return new BaseResponse({ message: "Text block created successfully" }); + ): Promise> { + const { id } = await this.adminLessonItemsService.createTextBlock( + body, + userId, + ); + + return new BaseResponse({ id, message: "Text block created successfully" }); } @Post("create-question") @@ -568,9 +581,10 @@ export class LessonsController { body, userId, ); + return new BaseResponse({ - message: "Question created successfully", questionId: id, + message: "Question created successfully", }); } @@ -670,13 +684,16 @@ export class LessonsController { }), }, ], - response: baseResponse(Type.Object({ message: Type.String() })), + response: baseResponse( + Type.Object({ id: UUIDSchema, message: Type.String() }), + ), }) async createFile( @Body() body: FileInsertType, @CurrentUser("userId") userId: string, - ): Promise> { - await this.adminLessonItemsService.createFile(body, userId); - return new BaseResponse({ message: "File created successfully" }); + ): Promise> { + const { id } = await this.adminLessonItemsService.createFile(body, userId); + + return new BaseResponse({ id, message: "File created successfully" }); } } diff --git a/apps/api/src/swagger/api-schema.json b/apps/api/src/swagger/api-schema.json index 2c0875a4b..648c4a22e 100644 --- a/apps/api/src/swagger/api-schema.json +++ b/apps/api/src/swagger/api-schema.json @@ -587,6 +587,33 @@ } } }, + "/api/users/create": { + "post": { + "operationId": "UsersController_createUser", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUserBody" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUserResponse" + } + } + } + } + } + } + }, "/api/test-config/setup": { "post": { "operationId": "TestConfigController_setup", @@ -698,8 +725,14 @@ } }, "responses": { - "201": { - "description": "" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCategoryResponse" + } + } + } } } } @@ -2969,6 +3002,71 @@ "DeleteBulkUsersResponse": { "type": "null" }, + "CreateUserBody": { + "type": "object", + "properties": { + "email": { + "format": "email", + "type": "string" + }, + "firstName": { + "minLength": 1, + "maxLength": 64, + "type": "string" + }, + "lastName": { + "minLength": 1, + "maxLength": 64, + "type": "string" + }, + "role": { + "anyOf": [ + { + "const": "admin", + "type": "string" + }, + { + "const": "student", + "type": "string" + }, + { + "const": "tutor", + "type": "string" + } + ] + } + }, + "required": [ + "email", + "firstName", + "lastName", + "role" + ] + }, + "CreateUserResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "format": "uuid", + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "id", + "message" + ] + } + }, + "required": [ + "data" + ] + }, "GetAllCategoriesResponse": { "type": "object", "properties": { @@ -3095,6 +3193,30 @@ "title" ] }, + "CreateCategoryResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "format": "uuid", + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "id", + "message" + ] + } + }, + "required": [ + "data" + ] + }, "UpdateCategoryBody": { "type": "object", "properties": { @@ -3782,11 +3904,16 @@ "data": { "type": "object", "properties": { + "id": { + "format": "uuid", + "type": "string" + }, "message": { "type": "string" } }, "required": [ + "id", "message" ] } @@ -4520,11 +4647,16 @@ "data": { "type": "object", "properties": { + "id": { + "format": "uuid", + "type": "string" + }, "message": { "type": "string" } }, "required": [ + "id", "message" ] } @@ -5636,11 +5768,16 @@ "data": { "type": "object", "properties": { + "id": { + "format": "uuid", + "type": "string" + }, "message": { "type": "string" } }, "required": [ + "id", "message" ] } @@ -5809,11 +5946,16 @@ "data": { "type": "object", "properties": { + "id": { + "format": "uuid", + "type": "string" + }, "message": { "type": "string" } }, "required": [ + "id", "message" ] } diff --git a/apps/api/src/users/api/users.controller.ts b/apps/api/src/users/api/users.controller.ts index 6c46ef3d0..34096f353 100644 --- a/apps/api/src/users/api/users.controller.ts +++ b/apps/api/src/users/api/users.controller.ts @@ -5,10 +5,11 @@ import { ForbiddenException, Get, Patch, + Post, Query, UseGuards, } from "@nestjs/common"; -import { Static, Type } from "@sinclair/typebox"; +import { type Static, Type } from "@sinclair/typebox"; import { Validate } from "nestjs-typebox"; import { baseResponse, @@ -17,33 +18,41 @@ import { PaginatedResponse, paginatedResponse, UUIDSchema, + type UUIDType, } from "src/common"; import { Roles } from "src/common/decorators/roles.decorator"; import { CurrentUser } from "src/common/decorators/user.decorator"; import { RolesGuard } from "src/common/guards/roles.guard"; import { - CommonUser, + type CommonUser, commonUserSchema, } from "src/common/schemas/common-user.schema"; import { - ChangePasswordBody, + type ChangePasswordBody, changePasswordSchema, } from "../schemas/change-password.schema"; import { deleteUsersSchema, - DeleteUsersSchema, + type DeleteUsersSchema, } from "../schemas/delete-users.schema"; import { - UpdateUserBody, + type UpdateUserBody, updateUserSchema, } from "../schemas/update-user.schema"; import { - AllUsersResponse, + type AllUsersResponse, allUsersSchema, - UserResponse, + type UserResponse, } from "../schemas/user.schema"; import { UsersService } from "../users.service"; -import { SortUserFieldsOptions, UsersFilterSchema } from "../schemas/userQuery"; +import type { + SortUserFieldsOptions, + UsersFilterSchema, +} from "../schemas/userQuery"; +import { + type CreateUserBody, + createUserSchema, +} from "src/users/schemas/create-user.schema"; @Controller("users") @UseGuards(RolesGuard) @@ -192,4 +201,23 @@ export class UsersController { return null; } + + @Post("create") + @Roles("admin") + @Validate({ + response: baseResponse( + Type.Object({ id: UUIDSchema, message: Type.String() }), + ), + request: [{ type: "body", schema: createUserSchema }], + }) + async createUser( + @Body() data: CreateUserBody, + ): Promise> { + const { id } = await this.usersService.createUser(data); + + return new BaseResponse({ + id, + message: "User created successfully", + }); + } } diff --git a/apps/api/src/users/schemas/create-user.schema.ts b/apps/api/src/users/schemas/create-user.schema.ts new file mode 100644 index 000000000..1f7fae31b --- /dev/null +++ b/apps/api/src/users/schemas/create-user.schema.ts @@ -0,0 +1,14 @@ +import { type Static, Type } from "@sinclair/typebox"; + +export const createUserSchema = Type.Object({ + email: Type.String({ format: "email" }), + firstName: Type.String({ minLength: 1, maxLength: 64 }), + lastName: Type.String({ minLength: 1, maxLength: 64 }), + role: Type.Union([ + Type.Literal("admin"), + Type.Literal("student"), + Type.Literal("tutor"), + ]), +}); + +export type CreateUserBody = Static; diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts index 454469b2e..fa16272a7 100644 --- a/apps/api/src/users/users.module.ts +++ b/apps/api/src/users/users.module.ts @@ -1,9 +1,10 @@ import { Module } from "@nestjs/common"; import { UsersController } from "./api/users.controller"; import { UsersService } from "./users.service"; +import { EmailModule } from "src/common/emails/emails.module"; @Module({ - imports: [], + imports: [EmailModule], controllers: [UsersController], providers: [UsersService], exports: [], diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index 15483f537..a11f90d95 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -1,4 +1,5 @@ import { + ConflictException, Inject, Injectable, NotFoundException, @@ -6,18 +7,22 @@ import { } from "@nestjs/common"; import * as bcrypt from "bcrypt"; import { and, count, eq, ilike, inArray, or, sql } from "drizzle-orm"; -import { DatabasePg } from "src/common"; +import type { DatabasePg } from "src/common"; import hashPassword from "src/common/helpers/hashPassword"; -import { credentials, users } from "../storage/schema"; -import { UserRole } from "./schemas/user-roles"; +import { createTokens, credentials, users } from "../storage/schema"; +import type { UserRole } from "./schemas/user-roles"; import { - SortUserFieldsOptions, - UsersFilterSchema, - UserSortField, + type SortUserFieldsOptions, + type UsersFilterSchema, + type UserSortField, UserSortFields, } from "./schemas/userQuery"; import { DEFAULT_PAGE_SIZE } from "src/common/pagination"; import { getSortOptions } from "src/common/helpers/getSortOptions"; +import type { CreateUserBody } from "src/users/schemas/create-user.schema"; +import { nanoid } from "nanoid"; +import { CreatePasswordEmail } from "@repo/email-templates"; +import { EmailService } from "src/common/emails/emails.service"; type UsersQuery = { filters?: UsersFilterSchema; @@ -28,7 +33,10 @@ type UsersQuery = { @Injectable() export class UsersService { - constructor(@Inject("DB") private readonly db: DatabasePg) {} + constructor( + @Inject("DB") private readonly db: DatabasePg, + private emailService: EmailService, + ) {} public async getUsers(query: UsersQuery = {}) { const { @@ -195,6 +203,49 @@ export class UsersService { } } + public async createUser(data: CreateUserBody) { + const [existingUser] = await this.db + .select() + .from(users) + .where(eq(users.email, data.email)); + + if (existingUser) { + throw new ConflictException("User already exists"); + } + + return await this.db.transaction(async (trx) => { + const [createdUser] = await trx.insert(users).values(data).returning(); + + const token = nanoid(64); + const expiryDate = new Date(); + expiryDate.setHours(expiryDate.getHours() + 24); + + await trx.insert(createTokens).values({ + userId: createdUser.id, + createToken: token, + expiryDate, + }); + + const url = `${process.env.CORS_ORIGIN}/auth/create-new-password?createToken=${token}&email=${createdUser.email}`; + + const { text, html } = new CreatePasswordEmail({ + name: createdUser.firstName, + role: createdUser.role, + createPasswordLink: url, + }); + + await this.emailService.sendEmail({ + to: createdUser.email, + subject: "Welcome to the Platform!", + text, + html, + from: process.env.SES_EMAIL || "", + }); + + return createdUser; + }); + } + private getFiltersConditions(filters: UsersFilterSchema) { const conditions = []; diff --git a/apps/web/app/api/generated-api.ts b/apps/web/app/api/generated-api.ts index c1a518989..e1fa2cd04 100644 --- a/apps/web/app/api/generated-api.ts +++ b/apps/web/app/api/generated-api.ts @@ -209,6 +209,30 @@ export interface DeleteBulkUsersBody { export type DeleteBulkUsersResponse = null; +export interface CreateUserBody { + /** @format email */ + email: string; + /** + * @minLength 1 + * @maxLength 64 + */ + firstName: string; + /** + * @minLength 1 + * @maxLength 64 + */ + lastName: string; + role: "admin" | "student" | "tutor"; +} + +export interface CreateUserResponse { + data: { + /** @format uuid */ + id: string; + message: string; + }; +} + export interface GetAllCategoriesResponse { data: { /** @format uuid */ @@ -238,6 +262,14 @@ export interface CreateCategoryBody { title: string; } +export interface CreateCategoryResponse { + data: { + /** @format uuid */ + id: string; + message: string; + }; +} + export interface UpdateCategoryBody { /** @format uuid */ id?: string; @@ -417,6 +449,8 @@ export interface CreateCourseBody { export interface CreateCourseResponse { data: { + /** @format uuid */ + id: string; message: string; }; } @@ -606,6 +640,8 @@ export interface CreateLessonBody { export interface CreateLessonResponse { data: { + /** @format uuid */ + id: string; message: string; }; } @@ -886,6 +922,8 @@ export interface CreateTextBlockBody { export interface CreateTextBlockResponse { data: { + /** @format uuid */ + id: string; message: string; }; } @@ -942,6 +980,8 @@ export interface CreateFileBody { export interface CreateFileResponse { data: { + /** @format uuid */ + id: string; message: string; }; } @@ -1434,6 +1474,22 @@ export class API extends HttpClient + this.request({ + path: `/api/users/create`, + method: "POST", + body: data, + type: ContentType.Json, + format: "json", + ...params, + }), + /** * No description * @@ -1492,11 +1548,12 @@ export class API extends HttpClient - this.request({ + this.request({ path: `/api/categories`, method: "POST", body: data, type: ContentType.Json, + format: "json", ...params, }), diff --git a/apps/web/app/api/mutations/admin/useCreteCategory.ts b/apps/web/app/api/mutations/admin/useCreateCategory.ts similarity index 91% rename from apps/web/app/api/mutations/admin/useCreteCategory.ts rename to apps/web/app/api/mutations/admin/useCreateCategory.ts index 377a8e0e0..163330d6b 100644 --- a/apps/web/app/api/mutations/admin/useCreteCategory.ts +++ b/apps/web/app/api/mutations/admin/useCreateCategory.ts @@ -2,7 +2,7 @@ import { useMutation } from "@tanstack/react-query"; import { AxiosError } from "axios"; import { useToast } from "~/components/ui/use-toast"; import { ApiClient } from "../../api-client"; -import { CreateCategoryBody } from "../../generated-api"; +import type { CreateCategoryBody } from "../../generated-api"; type CreateCategoryOptions = { data: CreateCategoryBody; @@ -14,7 +14,7 @@ export function useCreateCategory() { return useMutation({ mutationFn: async (options: CreateCategoryOptions) => { const response = await ApiClient.api.categoriesControllerCreateCategory( - options.data + options.data, ); return response.data; diff --git a/apps/web/app/api/mutations/admin/useCreteFileItem.ts b/apps/web/app/api/mutations/admin/useCreateFileItem.ts similarity index 100% rename from apps/web/app/api/mutations/admin/useCreteFileItem.ts rename to apps/web/app/api/mutations/admin/useCreateFileItem.ts diff --git a/apps/web/app/api/mutations/admin/useCreateUser.ts b/apps/web/app/api/mutations/admin/useCreateUser.ts new file mode 100644 index 000000000..90de2f68f --- /dev/null +++ b/apps/web/app/api/mutations/admin/useCreateUser.ts @@ -0,0 +1,41 @@ +import { useMutation } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import type { CreateUserBody } from "~/api/generated-api"; +import { useToast } from "~/components/ui/use-toast"; +import { ApiClient } from "~/api/api-client"; +import { queryClient } from "~/api/queryClient"; +import { currentUserQueryOptions } from "~/api/queries"; + +type CreateUserOptions = { + data: CreateUserBody; +}; + +export function useCreateUser() { + const { toast } = useToast(); + + return useMutation({ + mutationFn: async (options: CreateUserOptions) => { + const response = await ApiClient.api.usersControllerCreateUser( + options.data, + ); + + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries(currentUserQueryOptions); + toast({ description: "User created successfully" }); + }, + onError: (error) => { + if (error instanceof AxiosError) { + return toast({ + description: error.response?.data.message, + variant: "destructive", + }); + } + toast({ + description: error.message, + variant: "destructive", + }); + }, + }); +} diff --git a/apps/web/app/api/mutations/index.ts b/apps/web/app/api/mutations/index.ts new file mode 100644 index 000000000..89fae9086 --- /dev/null +++ b/apps/web/app/api/mutations/index.ts @@ -0,0 +1,17 @@ +export { useChangePassword } from "./useChangePassword"; +export { useClearQuizProgress } from "./useClearQuizProgress"; +export { useCreateCourse } from "./useCreateCourse"; +export { useCreateNewPassword } from "./useCreateNewPassword"; +export { useCreateUser } from "./admin/useCreateUser"; +export { useDeleteLessonItem } from "./useDeleteLessonItem"; +export { useEnrollCourse } from "./useEnrollCourse"; +export { useLoginUser } from "./useLoginUser"; +export { useLogoutUser } from "./useLogoutUser"; +export { useMarkLessonItemAsCompleted } from "./useMarkLessonItemAsCompleted"; +export { useQuestionAnswer } from "./useQuestion"; +export { useRegisterUser } from "./useRegisterUser"; +export { useStripePaymentIntent } from "./useStripePaymentIntent"; +export { useSubmitQuiz } from "./useSubmitQuiz"; +export { useUnenrollCourse } from "./useUnenrollCourse"; +export { useUpdateLessonItem } from "./useUpdateLessonItem"; +export { useUpdateUser } from "./useUpdateUser"; diff --git a/apps/web/app/api/mutations/useCreateCourse.ts b/apps/web/app/api/mutations/useCreateCourse.ts index 71b4eb719..309ca51dd 100644 --- a/apps/web/app/api/mutations/useCreateCourse.ts +++ b/apps/web/app/api/mutations/useCreateCourse.ts @@ -2,8 +2,8 @@ import { useMutation } from "@tanstack/react-query"; import { AxiosError } from "axios"; import { useToast } from "~/components/ui/use-toast"; import { ApiClient } from "../api-client"; -import { CreateCourseBody } from "../generated-api"; -import { currentUserQueryOptions } from "../queries/useCurrentUser"; +import type { CreateCourseBody } from "../generated-api"; +import { currentUserQueryOptions } from "~/api/queries"; import { queryClient } from "../queryClient"; type CreateCourseOptions = { @@ -16,7 +16,7 @@ export function useCreateCourse() { return useMutation({ mutationFn: async (options: CreateCourseOptions) => { const response = await ApiClient.api.coursesControllerCreateCourse( - options.data + options.data, ); return response.data; diff --git a/apps/web/app/api/queries/index.ts b/apps/web/app/api/queries/index.ts index b5929e688..5904ac464 100644 --- a/apps/web/app/api/queries/index.ts +++ b/apps/web/app/api/queries/index.ts @@ -27,4 +27,8 @@ export { useStudentCourses, useStudentCoursesSuspense, } from "./useStudentCourses"; -export { useAllUsers, useAllUsersSuspense } from "./useUsers"; +export { + useAllUsers, + useAllUsersSuspense, + usersQueryOptions, +} from "./useUsers"; diff --git a/apps/web/app/assets/svgs/category.svg b/apps/web/app/assets/svgs/category.svg new file mode 100644 index 000000000..b9762d4a3 --- /dev/null +++ b/apps/web/app/assets/svgs/category.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/app/assets/svgs/course.svg b/apps/web/app/assets/svgs/course.svg new file mode 100644 index 000000000..3048a7636 --- /dev/null +++ b/apps/web/app/assets/svgs/course.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/app/assets/svgs/index.ts b/apps/web/app/assets/svgs/index.ts index de939bf9e..284a9e8a3 100644 --- a/apps/web/app/assets/svgs/index.ts +++ b/apps/web/app/assets/svgs/index.ts @@ -15,3 +15,8 @@ export { default as InputRoundedMarkerError } from "./input-rounded-marker-error export { default as InProgress } from "./in-progress.svg?react"; export { default as QuizStar } from "./quiz-star.svg?react"; export { default as NotStartedRounded } from "./not-startet-rounded.svg?react"; +export { default as User } from "./user.svg?react"; +export { default as Course } from "./course.svg?react"; +export { default as Category } from "./category.svg?react"; +export { default as Lesson } from "./lesson.svg?react"; +export { default as LessonContent } from "./lesson-content.svg?react"; diff --git a/apps/web/app/assets/svgs/lesson-content.svg b/apps/web/app/assets/svgs/lesson-content.svg new file mode 100644 index 000000000..93967c402 --- /dev/null +++ b/apps/web/app/assets/svgs/lesson-content.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/app/assets/svgs/lesson.svg b/apps/web/app/assets/svgs/lesson.svg new file mode 100644 index 000000000..23a2a1b49 --- /dev/null +++ b/apps/web/app/assets/svgs/lesson.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/app/assets/svgs/user.svg b/apps/web/app/assets/svgs/user.svg new file mode 100644 index 000000000..d6082d0f9 --- /dev/null +++ b/apps/web/app/assets/svgs/user.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/app/modules/Admin/Admin.layout.tsx b/apps/web/app/modules/Admin/Admin.layout.tsx index 5721fff0c..a8b36e505 100644 --- a/apps/web/app/modules/Admin/Admin.layout.tsx +++ b/apps/web/app/modules/Admin/Admin.layout.tsx @@ -49,31 +49,31 @@ const AdminLayout = () => { label: "courses", link: "courses", roles: ["admin"], - iconName: "CaretRight", + iconName: "Course", }, { label: "categories", link: "categories", roles: ["admin"], - iconName: "CaretRight", + iconName: "Category", }, { label: "lessons", link: "lessons", roles: ["admin"], - iconName: "CaretRight", + iconName: "Lesson", }, { label: "users", link: "users", roles: ["admin"], - iconName: "CaretRight", + iconName: "User", }, { - label: "Lesson Items", + label: "Lesson Content", link: "lesson-items", roles: ["admin"], - iconName: "CaretRight", + iconName: "LessonContent", }, ]} /> diff --git a/apps/web/app/modules/Admin/Categories/Categories.page.tsx b/apps/web/app/modules/Admin/Categories/Categories.page.tsx index 6f04e07ce..8603d2ddc 100644 --- a/apps/web/app/modules/Admin/Categories/Categories.page.tsx +++ b/apps/web/app/modules/Admin/Categories/Categories.page.tsx @@ -1,21 +1,29 @@ -import { useNavigate } from "@remix-run/react"; +import { Link, useNavigate } from "@remix-run/react"; import { - ColumnDef, + type ColumnDef, flexRender, getCoreRowModel, getSortedRowModel, - RowSelectionState, - SortingState, + type RowSelectionState, + type SortingState, useReactTable, } from "@tanstack/react-table"; import { isEmpty } from "lodash-es"; import { Trash } from "lucide-react"; import React from "react"; -import { GetAllCategoriesResponse } from "~/api/generated-api"; -import { useCategoriesSuspense } from "~/api/queries/useCategories"; -import { usersQueryOptions } from "~/api/queries/useUsers"; +import type { GetAllCategoriesResponse } from "~/api/generated-api"; +import { useCategoriesSuspense, usersQueryOptions } from "~/api/queries"; import { queryClient } from "~/api/queryClient"; import SortButton from "~/components/TableSortButton/TableSortButton"; + +import { cn } from "~/lib/utils"; +import { + type FilterConfig, + type FilterValue, + SearchFilter, +} from "~/modules/common/SearchFilter/SearchFilter"; +import { format } from "date-fns"; +import { formatHtmlString } from "~/lib/formatters/formatHtmlString"; import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; @@ -27,20 +35,12 @@ import { TableHeader, TableRow, } from "~/components/ui/table"; -import { cn } from "~/lib/utils"; -import { - FilterConfig, - FilterValue, - SearchFilter, -} from "~/modules/common/SearchFilter/SearchFilter"; -import { CreateNewCategory } from "./CreateNewCategory"; -import { format } from "date-fns"; -import { formatHtmlString } from "~/lib/formatters/formatHtmlString"; type TCategory = GetAllCategoriesResponse["data"][number]; export const clientLoader = async () => { - queryClient.prefetchQuery(usersQueryOptions()); + await queryClient.prefetchQuery(usersQueryOptions()); + return null; }; @@ -158,7 +158,9 @@ const Categories = () => { return (
- + + + {

Selected ({selectedCategories.length}) @@ -193,7 +195,7 @@ const Categories = () => { {flexRender( header.column.columnDef.header, - header.getContext() + header.getContext(), )} ))} diff --git a/apps/web/app/modules/Admin/Categories/Category.page.tsx b/apps/web/app/modules/Admin/Categories/Category.page.tsx index 0e721f728..e9f2c1a5a 100644 --- a/apps/web/app/modules/Admin/Categories/Category.page.tsx +++ b/apps/web/app/modules/Admin/Categories/Category.page.tsx @@ -1,8 +1,7 @@ import { useParams } from "@remix-run/react"; import { startCase } from "lodash-es"; -import { useState } from "react"; import { useForm } from "react-hook-form"; -import { UpdateCategoryBody } from "~/api/generated-api"; + import { useUpdateCategory } from "~/api/mutations/admin/useUpdateCategory"; import { categoryByIdQueryOptions, @@ -12,17 +11,18 @@ import { queryClient } from "~/api/queryClient"; import { Button } from "~/components/ui/button"; import { Label } from "~/components/ui/label"; import Loader from "~/modules/common/Loader/Loader"; -import { CategoryInfo } from "./CategoryInfo"; +import { CategoryDetails } from "./CategoryDetails"; +import type { UpdateCategoryBody } from "~/api/generated-api"; const displayedFields: Array = ["title", "archived"]; const Category = () => { const { id } = useParams<{ id: string }>(); + if (!id) throw new Error("Category ID not found"); const { data: category, isLoading } = useCategoryById(id); const { mutateAsync: updateCategory } = useUpdateCategory(); - const [isEditing, setIsEditing] = useState(false); const { control, @@ -36,55 +36,40 @@ const Category = () => {

); + if (!category) throw new Error("Category not found"); const onSubmit = (data: UpdateCategoryBody) => { updateCategory({ data, categoryId: id }).then(() => { queryClient.invalidateQueries(categoryByIdQueryOptions(id)); - setIsEditing(false); }); }; + const renderFields = () => { + return displayedFields.map((field) => ( +
+ + +
+ )); + }; + + const fields = renderFields(); + return ( -
-
+
+

Category Information

- {isEditing ? ( -
- - -
- ) : ( - - )} -
-
- {displayedFields.map((field) => ( -
- - -
- ))} +
+
{fields}
); diff --git a/apps/web/app/modules/Admin/Categories/CategoryInfo.tsx b/apps/web/app/modules/Admin/Categories/CategoryDetails.tsx similarity index 70% rename from apps/web/app/modules/Admin/Categories/CategoryInfo.tsx rename to apps/web/app/modules/Admin/Categories/CategoryDetails.tsx index d51d7d0da..e5766320a 100644 --- a/apps/web/app/modules/Admin/Categories/CategoryInfo.tsx +++ b/apps/web/app/modules/Admin/Categories/CategoryDetails.tsx @@ -1,39 +1,23 @@ import { memo } from "react"; -import { Control, Controller } from "react-hook-form"; -import { +import { type Control, Controller } from "react-hook-form"; +import type { GetCategoryByIdResponse, UpdateCategoryBody, } from "~/api/generated-api"; import { Checkbox } from "~/components/ui/checkbox"; import { Input } from "~/components/ui/input"; -export const CategoryInfo = memo<{ +export const CategoryDetails = memo<{ name: keyof UpdateCategoryBody; control: Control; - isEditing: boolean; category: GetCategoryByIdResponse["data"]; -}>(({ name, control, isEditing, category }) => { +}>(({ name, control, category }) => { return ( { - if (!isEditing) { - if (name === "archived") { - return ( - - {category[name] ? "Archived" : "Active"} - - ); - } - return ( - - {category[name]?.toString()} - - ); - } - if (name === "archived") { return (
diff --git a/apps/web/app/modules/Admin/Categories/CreateNewCategory.page.tsx b/apps/web/app/modules/Admin/Categories/CreateNewCategory.page.tsx new file mode 100644 index 000000000..cdb00cc87 --- /dev/null +++ b/apps/web/app/modules/Admin/Categories/CreateNewCategory.page.tsx @@ -0,0 +1,81 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { useCreateCategory } from "~/api/mutations/admin/useCreateCategory"; +import { CATEGORIES_QUERY_KEY } from "~/api/queries/useCategories"; +import { queryClient } from "~/api/queryClient"; +import { DialogFooter } from "~/components/ui/dialog"; +import { useNavigate } from "@remix-run/react"; +import { CreatePageHeader } from "~/modules/Admin/components"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "~/components/ui/form"; +import { Label } from "~/components/ui/label"; +import { Input } from "~/components/ui/input"; +import { Button } from "~/components/ui/button"; + +const formSchema = z.object({ + title: z.string().min(2, "Title must be at least 2 characters."), +}); + +type FormValues = z.infer; + +export default function CreateNewCategoryPage() { + const { mutateAsync: createCategory } = useCreateCategory(); + const navigate = useNavigate(); + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + title: "", + }, + }); + + const onSubmit = (values: FormValues) => { + createCategory({ + data: values, + }).then(({ data }) => { + queryClient.invalidateQueries({ queryKey: CATEGORIES_QUERY_KEY }); + + if (data.id) navigate(`/admin/categories/${data.id}`); + }); + }; + + const isFormValid = form.formState.isValid; + + return ( +
+ +
+ + ( + + + + + + + + )} + /> + + + + + +
+ ); +} diff --git a/apps/web/app/modules/Admin/Categories/CreateNewCategory.tsx b/apps/web/app/modules/Admin/Categories/CreateNewCategory.tsx deleted file mode 100644 index 9bd238805..000000000 --- a/apps/web/app/modules/Admin/Categories/CreateNewCategory.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import * as z from "zod"; -import { useCreateCategory } from "~/api/mutations/admin/useCreteCategory"; -import { CATEGORIES_QUERY_KEY } from "~/api/queries/useCategories"; -import { queryClient } from "~/api/queryClient"; -import { Button } from "~/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "~/components/ui/dialog"; -import { - Form, - FormControl, - FormField, - FormItem, - FormMessage, -} from "~/components/ui/form"; -import { Input } from "~/components/ui/input"; -import { Label } from "~/components/ui/label"; - -const formSchema = z.object({ - title: z.string().min(2, "Title must be at least 2 characters."), -}); - -export interface CreateCategoryBody { - title: string; -} - -type FormValues = z.infer; - -export const CreateNewCategory = () => { - const [open, setOpen] = useState(false); - const { mutateAsync: createCategory } = useCreateCategory(); - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - title: "", - }, - }); - - const onSubmit = (values: FormValues) => { - createCategory({ - data: values, - }).then(() => { - setOpen(false); - queryClient.invalidateQueries({ queryKey: CATEGORIES_QUERY_KEY }); - }); - }; - - const isFormValid = form.formState.isValid; - - return ( - - - - - - - Create New Category - - Enter a title for your new category. Click save when you're - done. - - -
- - ( - - - - - - - - )} - /> - - - - - -
-
- ); -}; diff --git a/apps/web/app/modules/Admin/Courses/Course.page.tsx b/apps/web/app/modules/Admin/Courses/Course.page.tsx index bb24697b3..9888ebf80 100644 --- a/apps/web/app/modules/Admin/Courses/Course.page.tsx +++ b/apps/web/app/modules/Admin/Courses/Course.page.tsx @@ -1,8 +1,7 @@ import { useParams } from "@remix-run/react"; import { startCase } from "lodash-es"; -import { useState } from "react"; import { useForm } from "react-hook-form"; -import { UpdateCourseBody } from "~/api/generated-api"; +import type { UpdateCourseBody } from "~/api/generated-api"; import { useUpdateCourse } from "~/api/mutations/admin/useUpdateCourse"; import { courseQueryOptions, @@ -14,10 +13,9 @@ import { } from "~/api/queries/useCategories"; import { queryClient } from "~/api/queryClient"; import LessonAssigner from "./LessonAssigner/LessonAssigner"; - +import { CourseDetails } from "./CourseDetails"; import { Button } from "~/components/ui/button"; import { Label } from "~/components/ui/label"; -import { CourseInfo } from "./CourseInfo"; export const clientLoader = async () => { await queryClient.prefetchQuery(categoriesQueryOptions()); @@ -35,13 +33,13 @@ const displayedFields: Array = [ ]; const Course = () => { - const { id } = useParams<{ id: string }>(); + const { id } = useParams(); + if (!id) throw new Error("Course ID not found"); const { data: course, isLoading } = useCourseById(id); const { mutateAsync: updateCourse } = useUpdateCourse(); const { data: categories } = useCategoriesSuspense(); - const [isEditing, setIsEditing] = useState(false); const { control, @@ -49,43 +47,33 @@ const Course = () => { formState: { isDirty }, } = useForm(); - if (isLoading) + if (isLoading) { return (
); + } + if (!course) throw new Error("Course not found"); - const onSubmit = (data: UpdateCourseBody) => { + const onSubmit = async (data: UpdateCourseBody) => { updateCourse({ data: { ...data, priceInCents: Number(data.priceInCents) }, courseId: id, }).then(() => { queryClient.invalidateQueries(courseQueryOptions(id)); - setIsEditing(false); }); }; return ( -
+
-

- Course Information -

- {isEditing ? ( -
- - -
- ) : ( - - )} +

Course Information

+
{displayedFields.map((field) => ( @@ -93,10 +81,9 @@ const Course = () => { - @@ -104,11 +91,8 @@ const Course = () => { ))}
-
-

- Lesson Assignment -

+

Lesson Assignment

diff --git a/apps/web/app/modules/Admin/Courses/CourseInfo.tsx b/apps/web/app/modules/Admin/Courses/CourseDetails.tsx similarity index 72% rename from apps/web/app/modules/Admin/Courses/CourseInfo.tsx rename to apps/web/app/modules/Admin/Courses/CourseDetails.tsx index 377356dd9..e68cb1c6c 100644 --- a/apps/web/app/modules/Admin/Courses/CourseInfo.tsx +++ b/apps/web/app/modules/Admin/Courses/CourseDetails.tsx @@ -1,16 +1,12 @@ import { capitalize, startCase } from "lodash-es"; import { memo } from "react"; -import { Control, Controller } from "react-hook-form"; -import { +import { type Control, Controller } from "react-hook-form"; +import type { GetAllCategoriesResponse, GetCourseByIdResponse, UpdateCourseBody, } from "~/api/generated-api"; import Editor from "~/components/RichText/Editor"; -import Viewer from "~/components/RichText/Viever"; -import { Checkbox } from "~/components/ui/checkbox"; -import { Input } from "~/components/ui/input"; -import { Label } from "~/components/ui/label"; import { Select, SelectContent, @@ -18,50 +14,22 @@ import { SelectTrigger, SelectValue, } from "~/components/ui/select"; +import { Checkbox } from "~/components/ui/checkbox"; +import { Label } from "~/components/ui/label"; +import { Input } from "~/components/ui/input"; -export const CourseInfo = memo<{ +export const CourseDetails = memo<{ name: keyof UpdateCourseBody; control: Control; - isEditing: boolean; course: GetCourseByIdResponse["data"]; categories: GetAllCategoriesResponse["data"]; -}>(({ name, control, isEditing, course, categories }) => { +}>(({ name, control, course, categories }) => { return ( { - if (!isEditing) { - if (name === "description") { - return ; - } - if (name === "archived") { - return ( - - {course[name] ? "Archived" : "Active"} - - ); - } - if (name === "currency") { - return ( - {course[name]} - ); - } - if (name === "categoryId") { - return ( - - {course["category"]} - - ); - } - return ( - - {course[name]?.toString()} - - ); - } - if (name === "categoryId") { return ( + + + + )} + /> + ( + + + + + + + + )} + /> + ( + + + + + + )} + /> + ( + + + + + + + + )} + /> + ( + + + + + + + + )} + /> + ( + + + + + + )} + /> + ( + + + +
+ + { + const file = e.target.files?.[0]; + if (file) { + handleImageUpload(file); + } + }} + disabled={isUploading} + className="w-full" + /> +
+
+ {isUploading &&

Uploading image...

} + +
+ )} + /> + + + +
+ ); +} diff --git a/apps/web/app/modules/Admin/Courses/CreateNewCourse.tsx b/apps/web/app/modules/Admin/Courses/CreateNewCourse.tsx deleted file mode 100644 index d1e93e302..000000000 --- a/apps/web/app/modules/Admin/Courses/CreateNewCourse.tsx +++ /dev/null @@ -1,286 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import * as z from "zod"; -import { useUploadFile } from "~/api/mutations/admin/useUploadFile"; -import { useCreateCourse } from "~/api/mutations/useCreateCourse"; -import { - categoriesQueryOptions, - useCategoriesSuspense, -} from "~/api/queries/useCategories"; -import { ALL_COURSES_QUERY_KEY } from "~/api/queries/useCourses"; -import { queryClient } from "~/api/queryClient"; -import Editor from "~/components/RichText/Editor"; -import { Button } from "~/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "~/components/ui/dialog"; -import { - Form, - FormControl, - FormField, - FormItem, - FormMessage, -} from "~/components/ui/form"; -import { Input } from "~/components/ui/input"; -import { Label } from "~/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "~/components/ui/select"; - -export const clientLoader = async () => { - await queryClient.prefetchQuery(categoriesQueryOptions()); - return null; -}; - -const formSchema = z.object({ - title: z.string().min(2, "Title must be at least 2 characters."), - description: z.string().min(2, "Description must be at least 2 characters."), - state: z.enum(["draft", "published"], { - required_error: "Please select a state.", - }), - priceInCents: z.string(), - currency: z.string().optional().default("usd"), - categoryId: z.string(), - lessons: z.array(z.string()).optional(), - imageUrl: z.string().url("Invalid image URL").optional(), -}); - -type FormValues = z.infer; - -export const CreateNewCourse = () => { - const [open, setOpen] = useState(false); - const [isUploading, setIsUploading] = useState(false); - const { mutateAsync: createCourse } = useCreateCourse(); - const { data: categories } = useCategoriesSuspense(); - const { mutateAsync: uploadFile } = useUploadFile(); - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - title: "", - description: "", - state: "draft", - priceInCents: "0", - currency: "usd", - categoryId: "", - lessons: [], - imageUrl: "", - }, - }); - - const handleImageUpload = async (file: File) => { - setIsUploading(true); - try { - const result = await uploadFile({ file, resource: "course" }); - form.setValue("imageUrl", result.fileUrl, { shouldValidate: true }); - } catch (error) { - console.error("Error uploading image:", error); - } finally { - setIsUploading(false); - } - }; - - const onSubmit = (values: FormValues) => { - createCourse({ - data: { ...values, priceInCents: Number(values.priceInCents) }, - }).then(() => { - setOpen(false); - queryClient.invalidateQueries({ queryKey: ALL_COURSES_QUERY_KEY }); - }); - }; - - const isFormValid = form.formState.isValid; - - return ( - - - - - - - Create New Course - - Fill in the details to create a new course. Click save when - you're done. - - -
- - ( - - - - - - - - )} - /> - ( - - - - - - - - )} - /> - ( - - - - - - )} - /> - ( - - - - - - - - )} - /> - ( - - - - - - - - )} - /> - ( - - - - - - )} - /> - ( - - - -
- - { - const file = e.target.files?.[0]; - if (file) { - handleImageUpload(file); - } - }} - disabled={isUploading} - className="w-full" - /> -
-
- {isUploading &&

Uploading image...

} - -
- )} - /> - - - - - -
-
- ); -}; diff --git a/apps/web/app/modules/Admin/Courses/LessonAssigner/LessonCard.tsx b/apps/web/app/modules/Admin/Courses/LessonAssigner/LessonCard.tsx index bb1150837..da179b79c 100644 --- a/apps/web/app/modules/Admin/Courses/LessonAssigner/LessonCard.tsx +++ b/apps/web/app/modules/Admin/Courses/LessonAssigner/LessonCard.tsx @@ -1,6 +1,7 @@ import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { GetAllLessonsResponse } from "~/api/generated-api"; +import Viewer from "~/components/RichText/Viever"; type TransformedLesson = GetAllLessonsResponse["data"][number] & { columnId: string; @@ -30,9 +31,7 @@ export function LessonCard({ lesson }: LessonCardProps) {

{lesson.title}

-

- {lesson.description} -

+
); diff --git a/apps/web/app/modules/Admin/LessonItems/CreateNewFile.page.tsx b/apps/web/app/modules/Admin/LessonItems/CreateNewFile.page.tsx new file mode 100644 index 000000000..3566b928c --- /dev/null +++ b/apps/web/app/modules/Admin/LessonItems/CreateNewFile.page.tsx @@ -0,0 +1,236 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { capitalize, startCase } from "lodash-es"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { useCreateFileItem } from "~/api/mutations/admin/useCreateFileItem"; +import { useUploadFile } from "~/api/mutations/admin/useUploadFile"; +import { useCurrentUserSuspense } from "~/api/queries"; +import { ALL_LESSON_ITEMS_QUERY_KEY } from "~/api/queries/admin/useAllLessonItems"; +import { queryClient } from "~/api/queryClient"; +import { Button } from "~/components/ui/button"; +import { DialogFooter } from "~/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "~/components/ui/form"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { CreatePageHeader } from "~/modules/Admin/components"; +import { useNavigate } from "@remix-run/react"; + +const formSchema = z.object({ + title: z.string().min(1, "Title is required"), + type: z.enum([ + "presentation", + "external_presentation", + "video", + "external_video", + ]), + url: z.string().url("Invalid URL"), + state: z.enum(["draft", "published"]), + authorId: z.string().uuid("Invalid author ID"), +}); + +type FormValues = z.infer; + +export default function CreateNewFilePage() { + const { mutateAsync: createFile } = useCreateFileItem(); + const { mutateAsync: uploadFile } = useUploadFile(); + const { data: currentUser } = useCurrentUserSuspense(); + const [isUploading, setIsUploading] = useState(false); + const navigate = useNavigate(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + title: "", + type: "presentation", + url: "", + state: "draft", + authorId: currentUser.id, + }, + }); + + const fileType = form.watch("type"); + + const handleFileUpload = async (file: File) => { + setIsUploading(true); + try { + await uploadFile({ file, resource: "lessonItem" }).then((result) => { + form.setValue("url", result.fileUrl); + }); + } catch (error) { + console.error("Error uploading file:", error); + } finally { + setIsUploading(false); + } + }; + + const onSubmit = async (values: FormValues) => { + try { + const { data } = await createFile({ data: values }); + navigate(`/admin/lesson-items/${data.id}`); + form.reset(); + await queryClient.invalidateQueries({ + queryKey: ALL_LESSON_ITEMS_QUERY_KEY, + }); + } catch (error) { + console.error("Error creating file:", error); + } + }; + + const renderFileInput = () => { + if (fileType === "presentation" || fileType === "video") { + const acceptedTypes = + fileType === "presentation" ? ".pptx,.ppt,.odp" : ".mp4,.avi,.mov"; + + return ( + + + + { + const file = e.target.files?.[0]; + if (file) { + await handleFileUpload(file); + } + }} + disabled={isUploading} + /> + + {isUploading &&

Uploading...

} + +
+ ); + } + return null; + }; + + const renderUrlInput = () => { + return ( + ( + + + + + + + + )} + /> + ); + }; + + return ( +
+ +
+ + ( + + + + + + + + )} + /> + ( + + + + + + )} + /> + {renderFileInput()} + {renderUrlInput()} + ( + + + + + + )} + /> + + + + + +
+ ); +} diff --git a/apps/web/app/modules/Admin/LessonItems/CreateNewFile.tsx b/apps/web/app/modules/Admin/LessonItems/CreateNewFile.tsx deleted file mode 100644 index a20a47c0c..000000000 --- a/apps/web/app/modules/Admin/LessonItems/CreateNewFile.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { capitalize, startCase } from "lodash-es"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import * as z from "zod"; -import { useCreateFileItem } from "~/api/mutations/admin/useCreteFileItem"; -import { useUploadFile } from "~/api/mutations/admin/useUploadFile"; -import { useCurrentUserSuspense } from "~/api/queries"; -import { ALL_LESSON_ITEMS_QUERY_KEY } from "~/api/queries/admin/useAllLessonItems"; -import { queryClient } from "~/api/queryClient"; -import { Button } from "~/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "~/components/ui/dialog"; -import { - Form, - FormControl, - FormField, - FormItem, - FormMessage, -} from "~/components/ui/form"; -import { Input } from "~/components/ui/input"; -import { Label } from "~/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "~/components/ui/select"; - -const formSchema = z.object({ - title: z.string().min(1, "Title is required"), - type: z.enum([ - "presentation", - "external_presentation", - "video", - "external_video", - ]), - url: z.string().url("Invalid URL"), - state: z.enum(["draft", "published"]), - authorId: z.string().uuid("Invalid author ID"), -}); - -type FormValues = z.infer; - -export const CreateNewFile = ({ - open, - onOpenChange, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; -}) => { - const { mutateAsync: createFile } = useCreateFileItem(); - const { mutateAsync: uploadFile } = useUploadFile(); - const { data: currentUser } = useCurrentUserSuspense(); - const [isUploading, setIsUploading] = useState(false); - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - title: "", - type: "presentation", - url: "", - state: "draft", - authorId: currentUser.id, - }, - }); - - const fileType = form.watch("type"); - - const handleFileUpload = async (file: File) => { - setIsUploading(true); - try { - await uploadFile({ file, resource: "lessonItem" }).then((result) => { - form.setValue("url", result.fileUrl); - }); - } catch (error) { - console.error("Error uploading file:", error); - } finally { - setIsUploading(false); - } - }; - - const onSubmit = async (values: FormValues) => { - try { - await createFile({ data: values }); - onOpenChange(false); - form.reset(); - queryClient.invalidateQueries({ queryKey: ALL_LESSON_ITEMS_QUERY_KEY }); - } catch (error) { - console.error("Error creating file:", error); - } - }; - - const renderFileInput = () => { - if (fileType === "presentation" || fileType === "video") { - const acceptedTypes = - fileType === "presentation" ? ".pptx,.ppt,.odp" : ".mp4,.avi,.mov"; - - return ( - - - - { - const file = e.target.files?.[0]; - if (file) { - handleFileUpload(file); - } - }} - disabled={isUploading} - /> - - {isUploading &&

Uploading...

} - -
- ); - } - return null; - }; - - const renderUrlInput = () => { - return ( - ( - - - - - - - - )} - /> - ); - }; - - return ( - - - - Create New File - - Fill in the details to create a new file. Click save when - you're done. - - -
- - ( - - - - - - - - )} - /> - ( - - - - - - )} - /> - {renderFileInput()} - {renderUrlInput()} - ( - - - - - - )} - /> - - - - - -
-
- ); -}; diff --git a/apps/web/app/modules/Admin/LessonItems/CreateNewQuestion.tsx b/apps/web/app/modules/Admin/LessonItems/CreateNewQuestion.page.tsx similarity index 71% rename from apps/web/app/modules/Admin/LessonItems/CreateNewQuestion.tsx rename to apps/web/app/modules/Admin/LessonItems/CreateNewQuestion.page.tsx index 639a33ab1..81d9551ba 100644 --- a/apps/web/app/modules/Admin/LessonItems/CreateNewQuestion.tsx +++ b/apps/web/app/modules/Admin/LessonItems/CreateNewQuestion.page.tsx @@ -8,14 +8,6 @@ import { ALL_LESSON_ITEMS_QUERY_KEY } from "~/api/queries/admin/useAllLessonItem import { queryClient } from "~/api/queryClient"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "~/components/ui/dialog"; import { Form, FormControl, @@ -32,8 +24,9 @@ import { SelectTrigger, SelectValue, } from "~/components/ui/select"; -import { useState } from "react"; import Editor from "~/components/RichText/Editor"; +import { CreatePageHeader } from "~/modules/Admin/components"; +import { useNavigate } from "@remix-run/react"; const questionFormSchema = z.object({ questionType: z.enum([ @@ -49,37 +42,22 @@ const questionFormSchema = z.object({ state: z.enum(["draft", "published"]), authorId: z.string().uuid("Invalid author ID."), solutionExplanation: z.string().optional(), -}); - -const answerOptionsSchema = z.object({ options: z.array( z.object({ value: z.string().min(1, "Option text is required"), isCorrect: z.boolean(), position: z.number(), - }) + }), ), }); type QuestionFormValues = z.infer; -type AnswerOptionsFormValues = z.infer; - -export const CreateNewQuestion = ({ - open, - onOpenChange, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; -}) => { - const [step, setStep] = useState<"question" | "options">("question"); - const [createdQuestionId, setCreatedQuestionId] = useState( - null - ); +export default function CreateNewQuestionPage() { const { mutateAsync: createQuestion } = useCreateQuestionItem(); const { mutateAsync: assignAnswerOption } = useUpdateQuestionOptions(); const { data: currentUser } = useCurrentUserSuspense(); - + const navigate = useNavigate(); const questionForm = useForm({ resolver: zodResolver(questionFormSchema), defaultValues: { @@ -88,18 +66,18 @@ export const CreateNewQuestion = ({ state: "draft", authorId: currentUser.id, solutionExplanation: "", - }, - }); - - const answerOptionsForm = useForm({ - resolver: zodResolver(answerOptionsSchema), - defaultValues: { - options: [], + options: [ + { + value: "", + isCorrect: false, + position: 0, + }, + ], }, }); const { fields, append, remove } = useFieldArray({ - control: answerOptionsForm.control, + control: questionForm.control, name: "options", }); @@ -107,108 +85,36 @@ export const CreateNewQuestion = ({ try { const response = await createQuestion({ data: values }); const newQuestionId = response.data.questionId; - setCreatedQuestionId(newQuestionId); - if (values.questionType === "open_answer") { - onOpenChange(false); - queryClient.invalidateQueries({ queryKey: ALL_LESSON_ITEMS_QUERY_KEY }); - } else { - setStep("options"); - } - } catch (error) { - console.error("Error creating question:", error); - } - }; + if (newQuestionId) { + const options = values.options.map((option, index) => ({ + questionId: newQuestionId, + optionText: option.value, + isCorrect: option.isCorrect, + position: index, + })); - const onOptionsSubmit = async (values: AnswerOptionsFormValues) => { - try { - if (!createdQuestionId) return; + await assignAnswerOption({ + data: options, + questionId: newQuestionId, + }); - const options = values.options.map((option, index) => ({ - questionId: createdQuestionId, - optionText: option.value, - isCorrect: option.isCorrect, - position: index, - })); + await queryClient.invalidateQueries({ + queryKey: ALL_LESSON_ITEMS_QUERY_KEY, + }); - await assignAnswerOption({ - data: options, - questionId: createdQuestionId, - }); + navigate(`/admin/lesson-items/${newQuestionId}`); + questionForm.reset(); + } - onOpenChange(false); - await queryClient.invalidateQueries({ - queryKey: ALL_LESSON_ITEMS_QUERY_KEY, - }); - setStep("question"); - questionForm.reset(); - answerOptionsForm.reset(); + if (values.questionType === "open_answer") { + queryClient.invalidateQueries({ queryKey: ALL_LESSON_ITEMS_QUERY_KEY }); + } } catch (error) { - console.error("Error assigning answer options:", error); + console.error("Error while creating question:", error); } }; - const renderAnswerOptionsForm = () => ( -
- -
- {fields.map((field, index) => ( -
- ( - - )} - /> - ( - -
- - field.onChange(checked)} - /> -
-
- )} - /> - -
- ))} - -
- - - -
- - ); - const renderQuestionForm = () => (
)} /> - - - + + +
+ +
); return ( - - - - - {step === "question" ? "Create New Question" : "Add Answer Options"} - - - {step === "question" - ? "Fill in the details to create a new question. Click next when you're done." - : "Add answer options for your question. Mark the correct answer(s)."} - - - {step === "question" ? renderQuestionForm() : renderAnswerOptionsForm()} - - +
+ + {renderQuestionForm()} +
); -}; +} diff --git a/apps/web/app/modules/Admin/LessonItems/CreateNewTextBlock.page.tsx b/apps/web/app/modules/Admin/LessonItems/CreateNewTextBlock.page.tsx new file mode 100644 index 000000000..984f54bba --- /dev/null +++ b/apps/web/app/modules/Admin/LessonItems/CreateNewTextBlock.page.tsx @@ -0,0 +1,140 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { useCreateTextBlockItem } from "~/api/mutations/admin/useCreateTextBlockItem"; +import { useCurrentUserSuspense } from "~/api/queries"; +import { ALL_LESSON_ITEMS_QUERY_KEY } from "~/api/queries/admin/useAllLessonItems"; +import { queryClient } from "~/api/queryClient"; +import Editor from "~/components/RichText/Editor"; +import { Button } from "~/components/ui/button"; +import { DialogFooter } from "~/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "~/components/ui/form"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { CreatePageHeader } from "~/modules/Admin/components"; +import { useNavigate } from "@remix-run/react"; + +const formSchema = z.object({ + title: z.string().min(2, "Title must be at least 2 characters."), + body: z.string().min(10, "Body must be at least 10 characters."), + state: z.enum(["draft", "published"], { + required_error: "Please select a state.", + }), + authorId: z.string().uuid("Invalid author ID."), +}); + +type FormValues = z.infer; + +export default function CreateNewTextBlockPage() { + const { mutateAsync: createTextBlock } = useCreateTextBlockItem(); + const { data: currentUser } = useCurrentUserSuspense(); + const navigate = useNavigate(); + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + title: "", + body: "", + state: "draft", + authorId: currentUser.id, + }, + }); + + const onSubmit = async (data: FormValues) => { + createTextBlock({ data }).then(({ data }) => { + queryClient.invalidateQueries({ + queryKey: ALL_LESSON_ITEMS_QUERY_KEY, + }); + + navigate(`/admin/lesson-items/${data.id}`); + }); + }; + + const isFormValid = form.formState.isValid; + + return ( +
+ +
+ + ( + + + + + + + + )} + /> + ( + + + + + + + + )} + /> + ( + + + + + + )} + /> + + + + + +
+ ); +} diff --git a/apps/web/app/modules/Admin/LessonItems/CreateNewTextBlock.tsx b/apps/web/app/modules/Admin/LessonItems/CreateNewTextBlock.tsx deleted file mode 100644 index de1e2a8cf..000000000 --- a/apps/web/app/modules/Admin/LessonItems/CreateNewTextBlock.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import * as z from "zod"; -import { useCreateTextBlockItem } from "~/api/mutations/admin/useCreateTextBlockItem"; -import { useCurrentUserSuspense } from "~/api/queries"; -import { ALL_LESSON_ITEMS_QUERY_KEY } from "~/api/queries/admin/useAllLessonItems"; -import { queryClient } from "~/api/queryClient"; -import Editor from "~/components/RichText/Editor"; -import { Button } from "~/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "~/components/ui/dialog"; -import { - Form, - FormControl, - FormField, - FormItem, - FormMessage, -} from "~/components/ui/form"; -import { Input } from "~/components/ui/input"; -import { Label } from "~/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "~/components/ui/select"; - -const formSchema = z.object({ - title: z.string().min(2, "Title must be at least 2 characters."), - body: z.string().min(10, "Body must be at least 10 characters."), - state: z.enum(["draft", "published"], { - required_error: "Please select a state.", - }), - authorId: z.string().uuid("Invalid author ID."), -}); - -type FormValues = z.infer; - -export const CreateNewTextBlock = ({ - open, - onOpenChange, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; -}) => { - const { mutateAsync: createTextBlock } = useCreateTextBlockItem(); - const { data: currentUser } = useCurrentUserSuspense(); - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - title: "", - body: "", - state: "draft", - authorId: currentUser.id, - }, - }); - - const onSubmit = (data: FormValues) => { - createTextBlock({ data }).then(() => { - onOpenChange(false); - queryClient.invalidateQueries({ queryKey: ALL_LESSON_ITEMS_QUERY_KEY }); - }); - }; - - const isFormValid = form.formState.isValid; - - return ( - - - - Create New Text Block - - Fill in the details to create a new text block. Click save when - you're done. - - -
- - ( - - - - - - - - )} - /> - ( - - - - - - - - )} - /> - ( - - - - - - )} - /> - - - - - -
-
- ); -}; diff --git a/apps/web/app/modules/Admin/LessonItems/FileItem.tsx b/apps/web/app/modules/Admin/LessonItems/FileItem.tsx index 15bcdc445..9e6e0a39a 100644 --- a/apps/web/app/modules/Admin/LessonItems/FileItem.tsx +++ b/apps/web/app/modules/Admin/LessonItems/FileItem.tsx @@ -1,4 +1,3 @@ -import React, { useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { capitalize, startCase } from "lodash-es"; import { Button } from "~/components/ui/button"; @@ -12,7 +11,8 @@ import { SelectValue, } from "~/components/ui/select"; import { useUpdateFileItem } from "~/api/mutations/admin/useUpdateFileItem"; -import { UpdateFileItemBody } from "~/api/generated-api"; +import type { UpdateFileItemBody } from "~/api/generated-api"; +import type { FC } from "react"; interface FileItemProps { id: string; @@ -25,12 +25,7 @@ interface FileItemProps { onUpdate: () => void; } -export const FileItem: React.FC = ({ - id, - initialData, - onUpdate, -}) => { - const [isEditing, setIsEditing] = useState(false); +export const FileItem: FC = ({ id, initialData, onUpdate }) => { const { mutateAsync: updateFileItem } = useUpdateFileItem(); const { @@ -44,7 +39,6 @@ export const FileItem: React.FC = ({ const onSubmit = (data: UpdateFileItemBody) => { updateFileItem({ data, fileId: id }).then(() => { onUpdate(); - setIsEditing(false); }); }; @@ -53,14 +47,6 @@ export const FileItem: React.FC = ({ name={name} control={control} render={({ field }) => { - if (!isEditing) { - if (name === "url") - return ( - {field.value} - ); - return {field.value}; - } - if (name === "state") { return ( = ({

Text Block Item

- {isEditing ? ( -
- - -
- ) : ( - - )} +
{(["title", "body", "state", "archived"] as const).map((field) => ( diff --git a/apps/web/app/modules/Admin/Lessons/CreateNewLesson.page.tsx b/apps/web/app/modules/Admin/Lessons/CreateNewLesson.page.tsx new file mode 100644 index 000000000..a9c24758c --- /dev/null +++ b/apps/web/app/modules/Admin/Lessons/CreateNewLesson.page.tsx @@ -0,0 +1,223 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { useCreateLesson } from "~/api/mutations/admin/useCreateLesson"; +import { useUploadFile } from "~/api/mutations/admin/useUploadFile"; +import { ALL_LESSONS_QUERY_KEY } from "~/api/queries/admin/useAllLessons"; +import { queryClient } from "~/api/queryClient"; +import Editor from "~/components/RichText/Editor"; +import { CreatePageHeader } from "~/modules/Admin/components"; +import { useNavigate } from "@remix-run/react"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "~/components/ui/form"; +import { Label } from "~/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { Input } from "~/components/ui/input"; +import { DialogFooter } from "~/components/ui/dialog"; +import { Button } from "~/components/ui/button"; + +const formSchema = z.object({ + type: z.enum(["multimedia", "quiz"]), + title: z.string().min(2, "Title must be at least 2 characters."), + description: z.string().min(2, "Description must be at least 2 characters."), + state: z.string().optional(), + imageUrl: z.string().url("Invalid image URL"), +}); + +type FormValues = z.infer; + +export default function CreateNewLessonPage() { + const [isUploading, setIsUploading] = useState(false); + const { mutateAsync: createLesson } = useCreateLesson(); + const { mutateAsync: uploadFile } = useUploadFile(); + const navigate = useNavigate(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + type: "multimedia", + title: "", + description: "", + state: "draft", + imageUrl: "", + }, + }); + + const handleImageUpload = async (file: File) => { + setIsUploading(true); + try { + const result = await uploadFile({ file, resource: "lesson" }); + form.setValue("imageUrl", result.fileUrl, { shouldValidate: true }); + } catch (error) { + console.error("Error uploading image:", error); + } finally { + setIsUploading(false); + } + }; + + const onSubmit = (values: FormValues) => { + createLesson({ + data: values, + }).then(({ data }) => { + queryClient.invalidateQueries({ queryKey: ALL_LESSONS_QUERY_KEY }); + + if (data?.id) navigate(`/admin/lessons/${data?.id}`); + }); + }; + + const isFormValid = form.formState.isValid; + + return ( +
+ + + + ( + + + + + + )} + /> + ( + + + + + + + + )} + /> + ( + + + + + + + + )} + /> + ( + + + + + + )} + /> + ( + + + +
+ + { + const file = e.target.files?.[0]; + if (file) { + handleImageUpload(file); + } + }} + disabled={isUploading} + className="w-full" + /> +
+
+ {isUploading &&

Uploading image...

} + +
+ )} + /> + + + + + +
+ ); +} diff --git a/apps/web/app/modules/Admin/Lessons/CreateNewLesson.tsx b/apps/web/app/modules/Admin/Lessons/CreateNewLesson.tsx deleted file mode 100644 index 5f14d10be..000000000 --- a/apps/web/app/modules/Admin/Lessons/CreateNewLesson.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import * as z from "zod"; -import { useCreateLesson } from "~/api/mutations/admin/useCreateLesson"; -import { useUploadFile } from "~/api/mutations/admin/useUploadFile"; -import { ALL_LESSONS_QUERY_KEY } from "~/api/queries/admin/useAllLessons"; -import { queryClient } from "~/api/queryClient"; -import Editor from "~/components/RichText/Editor"; -import { Button } from "~/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "~/components/ui/dialog"; -import { - Form, - FormControl, - FormField, - FormItem, - FormMessage, -} from "~/components/ui/form"; -import { Input } from "~/components/ui/input"; -import { Label } from "~/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "~/components/ui/select"; - -const formSchema = z.object({ - type: z.enum(["multimedia", "quiz"]), - title: z.string().min(2, "Title must be at least 2 characters."), - description: z.string().min(2, "Description must be at least 2 characters."), - state: z.string().optional(), - imageUrl: z.string().url("Invalid image URL"), -}); - -type FormValues = z.infer; - -export const CreateNewLesson = () => { - const [open, setOpen] = useState(false); - const [isUploading, setIsUploading] = useState(false); - const { mutateAsync: createLesson } = useCreateLesson(); - const { mutateAsync: uploadFile } = useUploadFile(); - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - type: "multimedia", - title: "", - description: "", - state: "draft", - imageUrl: "", - }, - }); - - const handleImageUpload = async (file: File) => { - setIsUploading(true); - try { - const result = await uploadFile({ file, resource: "lesson" }); - form.setValue("imageUrl", result.fileUrl, { shouldValidate: true }); - } catch (error) { - console.error("Error uploading image:", error); - } finally { - setIsUploading(false); - } - }; - - const onSubmit = (values: FormValues) => { - createLesson({ - data: values, - }).then(() => { - setOpen(false); - queryClient.invalidateQueries({ queryKey: ALL_LESSONS_QUERY_KEY }); - }); - }; - - const isFormValid = form.formState.isValid; - - return ( - - - - - - - Create New Lesson - - Fill in the details to create a new lesson. Click save when - you're done. - - -
- - ( - - - - - - )} - /> - ( - - - - - - - - )} - /> - ( - - - - - - - - )} - /> - ( - - - - - - )} - /> - ( - - - -
- - { - const file = e.target.files?.[0]; - if (file) { - handleImageUpload(file); - } - }} - disabled={isUploading} - className="w-full" - /> -
-
- {isUploading &&

Uploading image...

} - -
- )} - /> - - - - - -
-
- ); -}; diff --git a/apps/web/app/modules/Admin/Lessons/Lesson.page.tsx b/apps/web/app/modules/Admin/Lessons/Lesson.page.tsx index 699842502..3026f0be4 100644 --- a/apps/web/app/modules/Admin/Lessons/Lesson.page.tsx +++ b/apps/web/app/modules/Admin/Lessons/Lesson.page.tsx @@ -1,19 +1,18 @@ import { useParams } from "@remix-run/react"; import { startCase } from "lodash-es"; -import { useState } from "react"; import { useForm } from "react-hook-form"; -import { UpdateLessonBody } from "~/api/generated-api"; +import type { UpdateLessonBody } from "~/api/generated-api"; import { useUpdateLesson } from "~/api/mutations/admin/useUpdateLesson"; import { lessonByIdQueryOptions, useLessonById, } from "~/api/queries/admin/useLessonById"; import { queryClient } from "~/api/queryClient"; -import { Button } from "~/components/ui/button"; -import { Label } from "~/components/ui/label"; import Loader from "~/modules/common/Loader/Loader"; import LessonItemAssigner from "./LessonItemsAssigner/LessonItemAssigner"; -import { LessonInfo } from "./LessonInfo"; +import { LessonDetails } from "./LessonDetails"; +import { Button } from "~/components/ui/button"; +import { Label } from "~/components/ui/label"; const displayedFields: Array = [ "title", @@ -23,12 +22,12 @@ const displayedFields: Array = [ ]; const Lesson = () => { - const { id } = useParams<{ id: string }>(); + const { id } = useParams(); + if (!id) throw new Error("Lesson ID not found"); const { data: lesson, isLoading } = useLessonById(id); const { mutateAsync: updateLesson } = useUpdateLesson(); - const [isEditing, setIsEditing] = useState(false); const { control, @@ -36,45 +35,32 @@ const Lesson = () => { formState: { isDirty }, } = useForm(); - if (isLoading) + if (isLoading) { return (
); + } + if (!lesson) throw new Error("Lesson not found"); const onSubmit = (data: UpdateLessonBody) => { updateLesson({ data, lessonId: id }).then(() => { queryClient.invalidateQueries(lessonByIdQueryOptions(id)); - setIsEditing(false); }); }; return ( -
-
+
+

Lesson Information

- {isEditing ? ( -
- - -
- ) : ( - - )} +
{displayedFields.map((field) => ( @@ -82,12 +68,7 @@ const Lesson = () => { - +
))}
diff --git a/apps/web/app/modules/Admin/Lessons/LessonInfo.tsx b/apps/web/app/modules/Admin/Lessons/LessonDetails.tsx similarity index 73% rename from apps/web/app/modules/Admin/Lessons/LessonInfo.tsx rename to apps/web/app/modules/Admin/Lessons/LessonDetails.tsx index 551c615c7..da510cb75 100644 --- a/apps/web/app/modules/Admin/Lessons/LessonInfo.tsx +++ b/apps/web/app/modules/Admin/Lessons/LessonDetails.tsx @@ -1,11 +1,11 @@ import { capitalize, startCase } from "lodash-es"; import { memo } from "react"; -import { Control, Controller } from "react-hook-form"; -import { GetLessonByIdResponse, UpdateLessonBody } from "~/api/generated-api"; +import { type Control, Controller } from "react-hook-form"; +import type { + GetLessonByIdResponse, + UpdateLessonBody, +} from "~/api/generated-api"; import Editor from "~/components/RichText/Editor"; -import Viewer from "~/components/RichText/Viever"; -import { Checkbox } from "~/components/ui/checkbox"; -import { Input } from "~/components/ui/input"; import { Select, SelectContent, @@ -13,36 +13,19 @@ import { SelectTrigger, SelectValue, } from "~/components/ui/select"; +import { Checkbox } from "~/components/ui/checkbox"; +import { Input } from "~/components/ui/input"; -export const LessonInfo = memo<{ +export const LessonDetails = memo<{ name: keyof UpdateLessonBody; control: Control; - isEditing: boolean; lesson: GetLessonByIdResponse["data"]; -}>(({ name, control, isEditing, lesson }) => ( +}>(({ name, control, lesson }) => ( { - if (!isEditing) { - if (name === "archived") { - return ( - - {lesson[name] ? "Archived" : "Active"} - - ); - } - if (name === "description") { - return ; - } - return ( - - {lesson[name]?.toString()} - - ); - } - if (name === "state") { return ( + + + + )} + /> + ( + + + + + + + + )} + /> + ( + + + + + + + + )} + /> + ( + + + + + + )} + /> + + + + + +
+ ); +} diff --git a/apps/web/app/modules/Admin/Users/CreateNewUser.tsx b/apps/web/app/modules/Admin/Users/CreateNewUser.tsx deleted file mode 100644 index 1506456d7..000000000 --- a/apps/web/app/modules/Admin/Users/CreateNewUser.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import React from "react"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import * as z from "zod"; -import { Button } from "~/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "~/components/ui/dialog"; -import { - Form, - FormControl, - FormField, - FormItem, - FormMessage, -} from "~/components/ui/form"; -import { Input } from "~/components/ui/input"; -import { Label } from "~/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "~/components/ui/select"; - -const formSchema = z.object({ - firstName: z.string().min(2, "First name must be at least 2 characters."), - lastName: z.string().min(2, "Last name must be at least 2 characters."), - email: z.string().email("Please enter a valid email address."), - role: z.enum(["student", "admin", "tutor"], { - required_error: "Please select a role.", - }), -}); - -type FormValues = z.infer; - -export const CreateNewUser = () => { - const [open, setOpen] = React.useState(false); - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - firstName: "", - lastName: "", - email: "", - role: "student", - }, - }); - - const onSubmit = (_values: FormValues) => { - alert("not implemented"); - setOpen(false); - }; - - const isFormValid = form.formState.isValid; - - return ( - - - - - - - Create New User - - Fill in the details to create a new user. Click save when - you're done. - - -
- - ( - - - - - - - - )} - /> - ( - - - - - - - - )} - /> - ( - - - - - - - - )} - /> - ( - - - - - - )} - /> - - - - - -
-
- ); -}; diff --git a/apps/web/app/modules/Admin/Users/User.page.tsx b/apps/web/app/modules/Admin/Users/User.page.tsx index fa1b82905..ef20a00b6 100644 --- a/apps/web/app/modules/Admin/Users/User.page.tsx +++ b/apps/web/app/modules/Admin/Users/User.page.tsx @@ -1,8 +1,7 @@ import { useParams } from "@remix-run/react"; import { startCase } from "lodash-es"; -import { useState } from "react"; import { useForm } from "react-hook-form"; -import { UpdateUserBody } from "~/api/generated-api"; +import type { UpdateUserBody } from "~/api/generated-api"; import { useAdminUpdateUser } from "~/api/mutations/admin/useAdminUpdateUser"; import { userQueryOptions, useUserById } from "~/api/queries/admin/useUserById"; import { Button } from "~/components/ui/button"; @@ -25,7 +24,6 @@ const User = () => { const { data: user, isLoading } = useUserById(id); const { mutateAsync: updateUser } = useAdminUpdateUser(); - const [isEditing, setIsEditing] = useState(false); const { control, @@ -33,46 +31,32 @@ const User = () => { formState: { isDirty }, } = useForm(); - if (isLoading) + if (isLoading) { return (
); + } + if (!user) throw new Error("User not found"); const onSubmit = (data: UpdateUserBody) => { updateUser({ data, userId: id }).then(() => { queryClient.invalidateQueries(userQueryOptions(id)); - setIsEditing(false); }); - setIsEditing(false); }; return ( -
-
+
+

User Information

- {isEditing ? ( -
- - -
- ) : ( - - )} +
{displayedFields.map((field) => ( @@ -80,12 +64,7 @@ const User = () => { - +
))}
diff --git a/apps/web/app/modules/Admin/Users/Users.page.tsx b/apps/web/app/modules/Admin/Users/Users.page.tsx index 99580c2a9..07e8c1d98 100644 --- a/apps/web/app/modules/Admin/Users/Users.page.tsx +++ b/apps/web/app/modules/Admin/Users/Users.page.tsx @@ -1,17 +1,17 @@ -import { useNavigate } from "@remix-run/react"; +import { Link, useNavigate } from "@remix-run/react"; import { - ColumnDef, + type ColumnDef, flexRender, getCoreRowModel, getSortedRowModel, - RowSelectionState, - SortingState, + type RowSelectionState, + type SortingState, useReactTable, } from "@tanstack/react-table"; import { isEmpty } from "lodash-es"; import { Trash } from "lucide-react"; import React from "react"; -import { GetUsersResponse } from "~/api/generated-api"; +import type { GetUsersResponse } from "~/api/generated-api"; import { useBulkDeleteUsers } from "~/api/mutations/admin/useBulkDeleteUsers"; import { useAllUsersSuspense, usersQueryOptions } from "~/api/queries/useUsers"; import { queryClient } from "~/api/queryClient"; @@ -28,10 +28,9 @@ import { TableRow, } from "~/components/ui/table"; import { cn } from "~/lib/utils"; -import { CreateNewUser } from "./CreateNewUser"; import { - FilterConfig, - FilterValue, + type FilterConfig, + type FilterValue, SearchFilter, } from "~/modules/common/SearchFilter/SearchFilter"; import { format } from "date-fns"; @@ -186,7 +185,9 @@ const Users = () => { return (
- + + + { {flexRender( header.column.columnDef.header, - header.getContext() + header.getContext(), )} ))} diff --git a/apps/web/app/modules/Admin/components/CreatePageHeader.tsx b/apps/web/app/modules/Admin/components/CreatePageHeader.tsx new file mode 100644 index 000000000..28d1fe5fd --- /dev/null +++ b/apps/web/app/modules/Admin/components/CreatePageHeader.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from "react"; + +type CreatePageHeaderProps = { + title: string | ReactNode; + description: string | ReactNode; +}; + +export const CreatePageHeader = ({ + title, + description, +}: CreatePageHeaderProps) => { + return ( +
+

{title}

+

{description}

+
+ ); +}; diff --git a/apps/web/app/modules/Admin/components/index.ts b/apps/web/app/modules/Admin/components/index.ts new file mode 100644 index 000000000..04f1eba9f --- /dev/null +++ b/apps/web/app/modules/Admin/components/index.ts @@ -0,0 +1 @@ +export { CreatePageHeader } from "./CreatePageHeader"; diff --git a/apps/web/app/modules/Courses/CourseView/CourseViewMainCard.tsx b/apps/web/app/modules/Courses/CourseView/CourseViewMainCard.tsx index 765170907..ee603a637 100644 --- a/apps/web/app/modules/Courses/CourseView/CourseViewMainCard.tsx +++ b/apps/web/app/modules/Courses/CourseView/CourseViewMainCard.tsx @@ -18,6 +18,7 @@ import { cn } from "~/lib/utils"; import CustomErrorBoundary from "~/modules/common/ErrorBoundary/ErrorBoundary"; import { PaymentModal } from "~/modules/stripe/PaymentModal"; import CourseProgress from "~/components/CourseProgress"; +import Viewer from "~/components/RichText/Viever"; export const CourseViewMainCard = ({ course, @@ -52,7 +53,7 @@ export const CourseViewMainCard = ({ const firstUncompletedLesson = course.lessons.find( - (lesson) => (lesson.itemsCompletedCount ?? 0) < lesson.itemsCount + (lesson) => (lesson.itemsCompletedCount ?? 0) < lesson.itemsCount, )?.id ?? last(course.lessons)?.id; const handleEnroll = () => { @@ -90,9 +91,7 @@ export const CourseViewMainCard = ({ {title}
-

- {description} -

+
{!isAdmin && isEnrolled && ( @@ -125,7 +124,7 @@ export const CourseViewMainCard = ({ { "bg-white border border-secondary-500 text-secondary-700 w-full mt-3": isEnrolled, - } + }, )} onClick={handleUnenroll} > diff --git a/apps/web/routes.ts b/apps/web/routes.ts index 88e2a6bb5..d425afdb5 100644 --- a/apps/web/routes.ts +++ b/apps/web/routes.ts @@ -1,4 +1,4 @@ -import { +import type { DefineRouteFunction, RouteManifest, } from "@remix-run/dev/dist/config/routes"; @@ -65,14 +65,33 @@ export const routes: ( route("courses", "modules/Admin/Courses/Courses.page.tsx", { index: true, }); + route("courses/new", "modules/Admin/Courses/CreateNewCourse.page.tsx"); route("courses/:id", "modules/Admin/Courses/Course.page.tsx"); route("users", "modules/Admin/Users/Users.page.tsx"); route("users/:id", "modules/Admin/Users/User.page.tsx"); + route("users/new", "modules/Admin/Users/CreateNewUser.page.tsx"); route("categories", "modules/Admin/Categories/Categories.page.tsx"); route("categories/:id", "modules/Admin/Categories/Category.page.tsx"); + route( + "categories/new", + "modules/Admin/Categories/CreateNewCategory.page.tsx", + ); route("lessons", "modules/Admin/Lessons/Lessons.page.tsx"); route("lessons/:id", "modules/Admin/Lessons/Lesson.page.tsx"); + route("lessons/new", "modules/Admin/Lessons/CreateNewLesson.page.tsx"); route("lesson-items", "modules/Admin/LessonItems/LessonItems.page.tsx"); + route( + "lesson-items/new-file", + "modules/Admin/LessonItems/CreateNewFile.page.tsx", + ); + route( + "lesson-items/new-text-block", + "modules/Admin/LessonItems/CreateNewTextBlock.page.tsx", + ); + route( + "lesson-items/new-question", + "modules/Admin/LessonItems/CreateNewQuestion.page.tsx", + ); route( "lesson-items/:id", "modules/Admin/LessonItems/LessonItem.page.tsx",