Compare commits

..

No commits in common. "dev" and "learner-dev-v1.0.2" have entirely different histories.

94 changed files with 1836 additions and 6999 deletions

View file

@ -634,8 +634,6 @@ enum AuditAction {
VERIFY_EMAIL VERIFY_EMAIL
DEACTIVATE_USER DEACTIVATE_USER
ACTIVATE_USER ACTIVATE_USER
ERROR
WARNING
} }
model AuditLog { model AuditLog {

View file

@ -1,10 +1,10 @@
import { Body, Get, Path, Post, Request, Response, Route, Security, SuccessResponse, Tags } from 'tsoa'; import { Body, Get, Path, Post, Request, Response, Route, Security, SuccessResponse, Tags } from 'tsoa';
import { ValidationError } from '../middleware/errorHandler'; import { ValidationError } from '../middleware/errorHandler';
import { AdminCourseApprovalService } from '../services/AdminCourseApproval.service'; import { AdminCourseApprovalService } from '../services/AdminCourseApproval.service';
import { RejectCourseValidator } from '../validators/AdminCourseApproval.validator';
import { import {
ListPendingCoursesResponse, ListPendingCoursesResponse,
GetCourseDetailForAdminResponse, GetCourseDetailForAdminResponse,
ApproveCourseBody,
ApproveCourseResponse, ApproveCourseResponse,
RejectCourseBody, RejectCourseBody,
RejectCourseResponse, RejectCourseResponse,
@ -25,8 +25,10 @@ export class AdminCourseApprovalController {
@Response('403', 'Forbidden - Admin only') @Response('403', 'Forbidden - Admin only')
public async listPendingCourses(@Request() request: any): Promise<ListPendingCoursesResponse> { public async listPendingCourses(@Request() request: any): Promise<ListPendingCoursesResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) {
return await AdminCourseApprovalService.listPendingCourses(token); throw new ValidationError('No token provided');
}
return await AdminCourseApprovalService.listPendingCourses();
} }
/** /**
@ -42,8 +44,10 @@ export class AdminCourseApprovalController {
@Response('404', 'Course not found') @Response('404', 'Course not found')
public async getCourseDetail(@Request() request: any, @Path() courseId: number): Promise<GetCourseDetailForAdminResponse> { public async getCourseDetail(@Request() request: any, @Path() courseId: number): Promise<GetCourseDetailForAdminResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) {
return await AdminCourseApprovalService.getCourseDetail(token, courseId); throw new ValidationError('No token provided');
}
return await AdminCourseApprovalService.getCourseDetail(courseId);
} }
/** /**
@ -60,12 +64,14 @@ export class AdminCourseApprovalController {
@Response('404', 'Course not found') @Response('404', 'Course not found')
public async approveCourse( public async approveCourse(
@Request() request: any, @Request() request: any,
@Path() courseId: number @Path() courseId: number,
@Body() body?: ApproveCourseBody
): Promise<ApproveCourseResponse> { ): Promise<ApproveCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) {
throw new ValidationError('No token provided');
return await AdminCourseApprovalService.approveCourse(token, courseId, undefined); }
return await AdminCourseApprovalService.approveCourse(token, courseId, body?.comment);
} }
/** /**
@ -86,12 +92,9 @@ export class AdminCourseApprovalController {
@Body() body: RejectCourseBody @Body() body: RejectCourseBody
): Promise<RejectCourseResponse> { ): Promise<RejectCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) {
throw new ValidationError('No token provided');
// Validate body }
const { error } = RejectCourseValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await AdminCourseApprovalService.rejectCourse(token, courseId, body.comment); return await AdminCourseApprovalService.rejectCourse(token, courseId, body.comment);
} }
} }

View file

@ -169,8 +169,8 @@ export class AuditController {
throw new ValidationError('No token provided'); throw new ValidationError('No token provided');
} }
if (days < 6) { if (days < 30) {
throw new ValidationError('Cannot delete logs newer than 6 days'); throw new ValidationError('Cannot delete logs newer than 30 days');
} }
const deleted = await auditService.deleteOldLogs(days); const deleted = await auditService.deleteOldLogs(days);

View file

@ -2,7 +2,6 @@ import { Get, Body, Post, Route, Tags, SuccessResponse, Response, Delete, Contro
import { ValidationError } from '../middleware/errorHandler'; import { ValidationError } from '../middleware/errorHandler';
import { CategoryService } from '../services/categories.service'; import { CategoryService } from '../services/categories.service';
import { createCategory, createCategoryResponse, deleteCategoryResponse, updateCategory, updateCategoryResponse, ListCategoriesResponse } from '../types/categories.type'; import { createCategory, createCategoryResponse, deleteCategoryResponse, updateCategory, updateCategoryResponse, ListCategoriesResponse } from '../types/categories.type';
import { CreateCategoryValidator, UpdateCategoryValidator } from '../validators/categories.validator';
@Route('api/categories') @Route('api/categories')
@Tags('Categories') @Tags('Categories')
@ -28,11 +27,6 @@ export class CategoriesAdminController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
public async createCategory(@Request() request: any, @Body() body: createCategory): Promise<createCategoryResponse> { public async createCategory(@Request() request: any, @Body() body: createCategory): Promise<createCategoryResponse> {
const token = request.headers.authorization?.replace('Bearer ', '') || ''; const token = request.headers.authorization?.replace('Bearer ', '') || '';
// Validate body
const { error } = CreateCategoryValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await this.categoryService.createCategory(token, body); return await this.categoryService.createCategory(token, body);
} }
@ -42,11 +36,6 @@ export class CategoriesAdminController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
public async updateCategory(@Request() request: any, @Body() body: updateCategory): Promise<updateCategoryResponse> { public async updateCategory(@Request() request: any, @Body() body: updateCategory): Promise<updateCategoryResponse> {
const token = request.headers.authorization?.replace('Bearer ', '') || ''; const token = request.headers.authorization?.replace('Bearer ', '') || '';
// Validate body
const { error } = UpdateCategoryValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await this.categoryService.updateCategory(token, body.id, body); return await this.categoryService.updateCategory(token, body.id, body);
} }
@ -56,6 +45,6 @@ export class CategoriesAdminController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
public async deleteCategory(@Request() request: any, @Path() id: number): Promise<deleteCategoryResponse> { public async deleteCategory(@Request() request: any, @Path() id: number): Promise<deleteCategoryResponse> {
const token = request.headers.authorization?.replace('Bearer ', '') || ''; const token = request.headers.authorization?.replace('Bearer ', '') || '';
return await this.categoryService.deleteCategory(token, id); return await this.categoryService.deleteCategory(id);
} }
} }

View file

@ -27,18 +27,6 @@ import {
UpdateQuizResponse, UpdateQuizResponse,
UpdateQuizBody, UpdateQuizBody,
} from '../types/ChaptersLesson.typs'; } from '../types/ChaptersLesson.typs';
import {
CreateChapterValidator,
UpdateChapterValidator,
ReorderChapterValidator,
CreateLessonValidator,
UpdateLessonValidator,
ReorderLessonsValidator,
AddQuestionValidator,
UpdateQuestionValidator,
ReorderQuestionValidator,
UpdateQuizValidator
} from '../validators/ChaptersLesson.validator';
const chaptersLessonService = new ChaptersLessonService(); const chaptersLessonService = new ChaptersLessonService();
@ -67,10 +55,6 @@ export class ChaptersLessonInstructorController {
): Promise<CreateChapterResponse> { ): Promise<CreateChapterResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
const { error } = CreateChapterValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.createChapter({ return await chaptersLessonService.createChapter({
token, token,
course_id: courseId, course_id: courseId,
@ -98,10 +82,6 @@ export class ChaptersLessonInstructorController {
): Promise<UpdateChapterResponse> { ): Promise<UpdateChapterResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
const { error } = UpdateChapterValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.updateChapter({ return await chaptersLessonService.updateChapter({
token, token,
course_id: courseId, course_id: courseId,
@ -145,10 +125,6 @@ export class ChaptersLessonInstructorController {
): Promise<ReorderChapterResponse> { ): Promise<ReorderChapterResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
const { error } = ReorderChapterValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.reorderChapter({ return await chaptersLessonService.reorderChapter({
token, token,
course_id: courseId, course_id: courseId,
@ -194,10 +170,6 @@ export class ChaptersLessonInstructorController {
): Promise<CreateLessonResponse> { ): Promise<CreateLessonResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
const { error } = CreateLessonValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.createLesson({ return await chaptersLessonService.createLesson({
token, token,
course_id: courseId, course_id: courseId,
@ -225,10 +197,6 @@ export class ChaptersLessonInstructorController {
): Promise<UpdateLessonResponse> { ): Promise<UpdateLessonResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
const { error } = UpdateLessonValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.updateLesson({ return await chaptersLessonService.updateLesson({
token, token,
course_id: courseId, course_id: courseId,
@ -278,10 +246,6 @@ export class ChaptersLessonInstructorController {
): Promise<ReorderLessonsResponse> { ): Promise<ReorderLessonsResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
const { error } = ReorderLessonsValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.reorderLessons({ return await chaptersLessonService.reorderLessons({
token, token,
course_id: courseId, course_id: courseId,
@ -311,10 +275,6 @@ export class ChaptersLessonInstructorController {
): Promise<AddQuestionResponse> { ): Promise<AddQuestionResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
const { error } = AddQuestionValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.addQuestion({ return await chaptersLessonService.addQuestion({
token, token,
course_id: courseId, course_id: courseId,
@ -340,10 +300,6 @@ export class ChaptersLessonInstructorController {
): Promise<UpdateQuestionResponse> { ): Promise<UpdateQuestionResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
const { error } = UpdateQuestionValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.updateQuestion({ return await chaptersLessonService.updateQuestion({
token, token,
course_id: courseId, course_id: courseId,
@ -366,10 +322,6 @@ export class ChaptersLessonInstructorController {
): Promise<ReorderQuestionResponse> { ): Promise<ReorderQuestionResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
const { error } = ReorderQuestionValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.reorderQuestion({ return await chaptersLessonService.reorderQuestion({
token, token,
course_id: courseId, course_id: courseId,
@ -419,10 +371,6 @@ export class ChaptersLessonInstructorController {
): Promise<UpdateQuizResponse> { ): Promise<UpdateQuizResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
const { error } = UpdateQuizValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.updateQuiz({ return await chaptersLessonService.updateQuiz({
token, token,
course_id: courseId, course_id: courseId,

View file

@ -2,28 +2,28 @@ import { Get, Body, Post, Route, Tags, SuccessResponse, Response, Security, Put,
import { ValidationError } from '../middleware/errorHandler'; import { ValidationError } from '../middleware/errorHandler';
import { CoursesInstructorService } from '../services/CoursesInstructor.service'; import { CoursesInstructorService } from '../services/CoursesInstructor.service';
import { import {
createCourses,
createCourseResponse, createCourseResponse,
ListMyCourseResponse,
GetMyCourseResponse, GetMyCourseResponse,
ListMyCourseResponse,
addinstructorCourseResponse,
removeinstructorCourseResponse,
setprimaryCourseInstructorResponse,
UpdateMyCourse, UpdateMyCourse,
UpdateMyCourseResponse, UpdateMyCourseResponse,
DeleteMyCourseResponse, DeleteMyCourseResponse,
submitCourseResponse, submitCourseResponse,
listinstructorCourseResponse, listinstructorCourseResponse,
addinstructorCourseResponse,
removeinstructorCourseResponse,
setprimaryCourseInstructorResponse,
GetEnrolledStudentsResponse,
GetEnrolledStudentDetailResponse,
GetQuizScoresResponse,
GetQuizAttemptDetailResponse,
GetCourseApprovalsResponse, GetCourseApprovalsResponse,
SearchInstructorResponse, SearchInstructorResponse,
GetEnrolledStudentsResponse,
GetQuizScoresResponse,
GetQuizAttemptDetailResponse,
GetEnrolledStudentDetailResponse,
GetCourseApprovalHistoryResponse, GetCourseApprovalHistoryResponse,
setCourseDraftResponse, setCourseDraftResponse,
CloneCourseResponse,
} from '../types/CoursesInstructor.types'; } from '../types/CoursesInstructor.types';
import { CreateCourseValidator, UpdateCourseValidator, CloneCourseValidator } from "../validators/CoursesInstructor.validator"; import { CreateCourseValidator } from "../validators/CoursesInstructor.validator";
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { config } from '../config'; import { config } from '../config';
@ -41,15 +41,12 @@ export class CoursesInstructorController {
@SuccessResponse('200', 'Courses retrieved successfully') @SuccessResponse('200', 'Courses retrieved successfully')
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Response('404', 'Courses not found') @Response('404', 'Courses not found')
public async listMyCourses( public async listMyCourses(@Request() request: any): Promise<ListMyCourseResponse> {
@Request() request: any,
@Query() status?: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'ARCHIVED'
): Promise<ListMyCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) { if (!token) {
throw new ValidationError('No token provided'); throw new ValidationError('No token provided');
} }
return await CoursesInstructorService.listMyCourses({ token, status }); return await CoursesInstructorService.listMyCourses(token);
} }
/** /**
@ -102,11 +99,9 @@ export class CoursesInstructorController {
@Response('404', 'Course not found') @Response('404', 'Course not found')
public async updateCourse(@Request() request: any, @Path() courseId: number, @Body() body: UpdateMyCourse): Promise<UpdateMyCourseResponse> { public async updateCourse(@Request() request: any, @Path() courseId: number, @Body() body: UpdateMyCourse): Promise<UpdateMyCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) {
throw new ValidationError('No token provided');
const { error } = UpdateCourseValidator.validate(body.data); }
if (error) throw new ValidationError(error.details[0].message);
return await CoursesInstructorService.updateCourse(token, courseId, body.data); return await CoursesInstructorService.updateCourse(token, courseId, body.data);
} }
@ -179,36 +174,6 @@ export class CoursesInstructorController {
return await CoursesInstructorService.deleteCourse(token, courseId); return await CoursesInstructorService.deleteCourse(token, courseId);
} }
/**
* (Clone Course)
* Clone an existing course to a new one with copied chapters, lessons, quizzes, and attachments
* @param courseId - / Source Course ID
* @param body - / New course title
*/
@Post('{courseId}/clone')
@Security('jwt', ['instructor'])
@SuccessResponse('201', 'Course cloned successfully')
@Response('401', 'Invalid or expired token')
@Response('403', 'Not an instructor of this course')
@Response('404', 'Course not found')
public async cloneCourse(
@Request() request: any,
@Path() courseId: number,
@Body() body: { title: { th: string; en: string } }
): Promise<CloneCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = CloneCourseValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
const result = await CoursesInstructorService.cloneCourse({
token,
course_id: courseId,
title: body.title
});
return result;
}
/** /**
* *
* Submit course for admin review and approval * Submit course for admin review and approval

View file

@ -16,7 +16,6 @@ import {
GetQuizAttemptsResponse, GetQuizAttemptsResponse,
} from '../types/CoursesStudent.types'; } from '../types/CoursesStudent.types';
import { EnrollmentStatus } from '@prisma/client'; import { EnrollmentStatus } from '@prisma/client';
import { SaveVideoProgressValidator, SubmitQuizValidator } from '../validators/CoursesStudent.validator';
@Route('api/students') @Route('api/students')
@Tags('CoursesStudent') @Tags('CoursesStudent')
@ -150,11 +149,9 @@ export class CoursesStudentController {
@Body() body: SaveVideoProgressBody @Body() body: SaveVideoProgressBody
): Promise<SaveVideoProgressResponse> { ): Promise<SaveVideoProgressResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) {
throw new ValidationError('No token provided');
const { error } = SaveVideoProgressValidator.validate(body); }
if (error) throw new ValidationError(error.details[0].message);
return await this.service.saveVideoProgress({ return await this.service.saveVideoProgress({
token, token,
lesson_id: lessonId, lesson_id: lessonId,
@ -228,11 +225,9 @@ export class CoursesStudentController {
@Body() body: SubmitQuizBody @Body() body: SubmitQuizBody
): Promise<SubmitQuizResponse> { ): Promise<SubmitQuizResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) {
throw new ValidationError('No token provided');
const { error } = SubmitQuizValidator.validate(body); }
if (error) throw new ValidationError(error.details[0].message);
return await this.service.submitQuiz({ return await this.service.submitQuiz({
token, token,
course_id: courseId, course_id: courseId,

View file

@ -11,7 +11,6 @@ import {
YouTubeVideoResponse, YouTubeVideoResponse,
SetYouTubeVideoBody, SetYouTubeVideoBody,
} from '../types/ChaptersLesson.typs'; } from '../types/ChaptersLesson.typs';
import { SetYouTubeVideoValidator } from '../validators/Lessons.validator';
const chaptersLessonService = new ChaptersLessonService(); const chaptersLessonService = new ChaptersLessonService();
@ -214,8 +213,12 @@ export class LessonsController {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
const { error } = SetYouTubeVideoValidator.validate(body); if (!body.youtube_video_id) {
if (error) throw new ValidationError(error.details[0].message); throw new ValidationError('YouTube video ID is required');
}
if (!body.video_title) {
throw new ValidationError('Video title is required');
}
return await chaptersLessonService.setYouTubeVideo({ return await chaptersLessonService.setYouTubeVideo({
token, token,

View file

@ -20,14 +20,12 @@ export class RecommendedCoursesController {
@SuccessResponse('200', 'Approved courses retrieved successfully') @SuccessResponse('200', 'Approved courses retrieved successfully')
@Response('401', 'Unauthorized') @Response('401', 'Unauthorized')
@Response('403', 'Forbidden - Admin only') @Response('403', 'Forbidden - Admin only')
public async listApprovedCourses( public async listApprovedCourses(@Request() request: any): Promise<ListApprovedCoursesResponse> {
@Request() request: any,
@Query() search?: string,
@Query() categoryId?: number
): Promise<ListApprovedCoursesResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) {
return await RecommendedCoursesService.listApprovedCourses(token, { search, categoryId }); throw new ValidationError('No token provided');
}
return await RecommendedCoursesService.listApprovedCourses();
} }
/** /**
@ -44,8 +42,10 @@ export class RecommendedCoursesController {
@Response('404', 'Course not found') @Response('404', 'Course not found')
public async getCourseById(@Request() request: any, @Path() courseId: number): Promise<GetCourseByIdResponse> { public async getCourseById(@Request() request: any, @Path() courseId: number): Promise<GetCourseByIdResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) {
return await RecommendedCoursesService.getCourseById(token, courseId); throw new ValidationError('No token provided');
}
return await RecommendedCoursesService.getCourseById(courseId);
} }
/** /**
@ -66,7 +66,9 @@ export class RecommendedCoursesController {
@Query() is_recommended: boolean @Query() is_recommended: boolean
): Promise<ToggleRecommendedResponse> { ): Promise<ToggleRecommendedResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) {
throw new ValidationError('No token provided');
}
return await RecommendedCoursesService.toggleRecommended(token, courseId, is_recommended); return await RecommendedCoursesService.toggleRecommended(token, courseId, is_recommended);
} }
} }

View file

@ -10,8 +10,7 @@ import {
ChangePasswordResponse, ChangePasswordResponse,
updateAvatarResponse, updateAvatarResponse,
SendVerifyEmailResponse, SendVerifyEmailResponse,
VerifyEmailResponse, VerifyEmailResponse
rolesResponse
} from '../types/user.types'; } from '../types/user.types';
import { ChangePassword } from '../types/auth.types'; import { ChangePassword } from '../types/auth.types';
import { profileUpdateSchema, changePasswordSchema } from "../validators/user.validator"; import { profileUpdateSchema, changePasswordSchema } from "../validators/user.validator";
@ -57,18 +56,6 @@ export class UserController {
return await this.userService.updateProfile(token, body); return await this.userService.updateProfile(token, body);
} }
@Get('roles')
@Security('jwt')
@SuccessResponse('200', 'Roles retrieved successfully')
@Response('401', 'Invalid or expired token')
public async getRoles(@Request() request: any): Promise<rolesResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await this.userService.getRoles(token);
}
/** /**
* Change password * Change password
* @summary Change user password using old password * @summary Change user password using old password

View file

@ -1,7 +1,6 @@
import { Body, Delete, Get, Path, Post, Put, Query, Request, Response, Route, Security, SuccessResponse, Tags, UploadedFile, UploadedFiles, FormField } from 'tsoa'; import { Body, Delete, Get, Path, Post, Put, Query, Request, Response, Route, Security, SuccessResponse, Tags, UploadedFile, UploadedFiles, FormField } from 'tsoa';
import { ValidationError } from '../middleware/errorHandler'; import { ValidationError } from '../middleware/errorHandler';
import { AnnouncementsService } from '../services/announcements.service'; import { AnnouncementsService } from '../services/announcements.service';
import { CreateAnnouncementValidator, UpdateAnnouncementValidator } from '../validators/announcements.validator';
import { import {
ListAnnouncementResponse, ListAnnouncementResponse,
CreateAnnouncementResponse, CreateAnnouncementResponse,
@ -69,10 +68,6 @@ export class AnnouncementsController {
// Parse JSON data field // Parse JSON data field
const parsed = JSON.parse(data) as CreateAnnouncementBody; const parsed = JSON.parse(data) as CreateAnnouncementBody;
// Validate parsed data
const { error } = CreateAnnouncementValidator.validate(parsed);
if (error) throw new ValidationError(error.details[0].message);
return await announcementsService.createAnnouncement({ return await announcementsService.createAnnouncement({
token, token,
course_id: courseId, course_id: courseId,
@ -105,11 +100,6 @@ export class AnnouncementsController {
): Promise<UpdateAnnouncementResponse> { ): Promise<UpdateAnnouncementResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
// Validate body
const { error } = UpdateAnnouncementValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await announcementsService.updateAnnouncement({ return await announcementsService.updateAnnouncement({
token, token,
course_id: courseId, course_id: courseId,

View file

@ -18,7 +18,7 @@ export class AdminCourseApprovalService {
/** /**
* Get all pending courses for admin review * Get all pending courses for admin review
*/ */
static async listPendingCourses(token: string): Promise<ListPendingCoursesResponse> { static async listPendingCourses(): Promise<ListPendingCoursesResponse> {
try { try {
const courses = await prisma.course.findMany({ const courses = await prisma.course.findMany({
where: { status: 'PENDING' }, where: { status: 'PENDING' },
@ -96,16 +96,6 @@ export class AdminCourseApprovalService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to list pending courses', { error }); logger.error('Failed to list pending courses', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: 0,
metadata: {
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -113,7 +103,7 @@ export class AdminCourseApprovalService {
/** /**
* Get course details for admin review * Get course details for admin review
*/ */
static async getCourseDetail(token: string, courseId: number): Promise<GetCourseDetailForAdminResponse> { static async getCourseDetail(courseId: number): Promise<GetCourseDetailForAdminResponse> {
try { try {
const course = await prisma.course.findUnique({ const course = await prisma.course.findUnique({
where: { id: courseId }, where: { id: courseId },
@ -133,11 +123,7 @@ export class AdminCourseApprovalService {
}, },
chapters: { chapters: {
orderBy: { sort_order: 'asc' }, orderBy: { sort_order: 'asc' },
select: { include: {
id: true,
title: true,
sort_order: true,
is_published: true,
lessons: { lessons: {
orderBy: { sort_order: 'asc' }, orderBy: { sort_order: 'asc' },
select: { select: {
@ -228,16 +214,6 @@ export class AdminCourseApprovalService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to get course detail', { error }); logger.error('Failed to get course detail', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: courseId,
metadata: {
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -299,17 +275,6 @@ export class AdminCourseApprovalService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to approve course', { error }); logger.error('Failed to approve course', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: courseId,
metadata: {
operation: 'approve_course',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -376,17 +341,6 @@ export class AdminCourseApprovalService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to reject course', { error }); logger.error('Failed to reject course', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: courseId,
metadata: {
operation: 'reject_course',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }

View file

@ -142,17 +142,6 @@ export class ChaptersLessonService {
return { code: 200, message: 'Chapter created successfully', data: chapter as ChapterData }; return { code: 200, message: 'Chapter created successfully', data: chapter as ChapterData };
} catch (error) { } catch (error) {
logger.error(`Error creating chapter: ${error}`); logger.error(`Error creating chapter: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Chapter',
entityId: 0,
metadata: {
operation: 'create_chapter',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -174,17 +163,6 @@ export class ChaptersLessonService {
return { code: 200, message: 'Chapter updated successfully', data: chapter as ChapterData }; return { code: 200, message: 'Chapter updated successfully', data: chapter as ChapterData };
} catch (error) { } catch (error) {
logger.error(`Error updating chapter: ${error}`); logger.error(`Error updating chapter: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Chapter',
entityId: request.chapter_id,
metadata: {
operation: 'update_chapter',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -219,17 +197,6 @@ export class ChaptersLessonService {
return { code: 200, message: 'Chapter deleted successfully' }; return { code: 200, message: 'Chapter deleted successfully' };
} catch (error) { } catch (error) {
logger.error(`Error deleting chapter: ${error}`); logger.error(`Error deleting chapter: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Chapter',
entityId: request.chapter_id,
metadata: {
operation: 'delete_chapter',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -313,17 +280,6 @@ export class ChaptersLessonService {
return { code: 200, message: 'Chapter reordered successfully', data: chapters as ChapterData[] }; return { code: 200, message: 'Chapter reordered successfully', data: chapters as ChapterData[] };
} catch (error) { } catch (error) {
logger.error(`Error reordering chapter: ${error}`); logger.error(`Error reordering chapter: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Chapter',
entityId: request.chapter_id,
metadata: {
operation: 'reorder_chapter',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -398,17 +354,6 @@ export class ChaptersLessonService {
return { code: 200, message: 'Lesson created successfully', data: lesson as LessonData }; return { code: 200, message: 'Lesson created successfully', data: lesson as LessonData };
} catch (error) { } catch (error) {
logger.error(`Error creating lesson: ${error}`); logger.error(`Error creating lesson: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Lesson',
entityId: 0,
metadata: {
operation: 'create_lesson',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -549,17 +494,6 @@ export class ChaptersLessonService {
return { code: 200, message: 'Lesson fetched successfully', data: lessonData as LessonData }; return { code: 200, message: 'Lesson fetched successfully', data: lessonData as LessonData };
} catch (error) { } catch (error) {
logger.error(`Error fetching lesson: ${error}`); logger.error(`Error fetching lesson: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Lesson',
entityId: request.lesson_id,
metadata: {
operation: 'get_lesson',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -581,17 +515,6 @@ export class ChaptersLessonService {
return { code: 200, message: 'Lesson updated successfully', data: lesson as LessonData }; return { code: 200, message: 'Lesson updated successfully', data: lesson as LessonData };
} catch (error) { } catch (error) {
logger.error(`Error updating lesson: ${error}`); logger.error(`Error updating lesson: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Lesson',
entityId: request.lesson_id,
metadata: {
operation: 'update_lesson',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -682,17 +605,6 @@ export class ChaptersLessonService {
return { code: 200, message: 'Lessons reordered successfully', data: lessons as LessonData[] }; return { code: 200, message: 'Lessons reordered successfully', data: lessons as LessonData[] };
} catch (error) { } catch (error) {
logger.error(`Error reordering lessons: ${error}`); logger.error(`Error reordering lessons: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Lesson',
entityId: request.lesson_id,
metadata: {
operation: 'reorder_lessons',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -764,17 +676,6 @@ export class ChaptersLessonService {
return { code: 200, message: 'Lesson deleted successfully' }; return { code: 200, message: 'Lesson deleted successfully' };
} catch (error) { } catch (error) {
logger.error(`Error deleting lesson: ${error}`); logger.error(`Error deleting lesson: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Lesson',
entityId: request.lesson_id,
metadata: {
operation: 'delete_lesson',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -853,17 +754,6 @@ export class ChaptersLessonService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error uploading video: ${error}`); logger.error(`Error uploading video: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Lesson',
entityId: request.lesson_id,
metadata: {
operation: 'upload_video',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -946,17 +836,6 @@ export class ChaptersLessonService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error updating video: ${error}`); logger.error(`Error updating video: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Lesson',
entityId: request.lesson_id,
metadata: {
operation: 'update_video',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -1038,17 +917,6 @@ export class ChaptersLessonService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error setting YouTube video: ${error}`); logger.error(`Error setting YouTube video: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Lesson',
entityId: request.lesson_id,
metadata: {
operation: 'set_youtube_video',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -1125,17 +993,6 @@ export class ChaptersLessonService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error uploading attachment: ${error}`); logger.error(`Error uploading attachment: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'LessonAttachment',
entityId: request.lesson_id,
metadata: {
operation: 'upload_attachment',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -1194,17 +1051,6 @@ export class ChaptersLessonService {
return { code: 200, message: 'Attachment deleted successfully' }; return { code: 200, message: 'Attachment deleted successfully' };
} catch (error) { } catch (error) {
logger.error(`Error deleting attachment: ${error}`); logger.error(`Error deleting attachment: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'LessonAttachment',
entityId: request.attachment_id,
metadata: {
operation: 'delete_attachment',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -1281,17 +1127,6 @@ export class ChaptersLessonService {
return { code: 200, message: 'Question added successfully', data: completeQuestion as QuizQuestionData }; return { code: 200, message: 'Question added successfully', data: completeQuestion as QuizQuestionData };
} catch (error) { } catch (error) {
logger.error(`Error adding question: ${error}`); logger.error(`Error adding question: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Question',
entityId: 0,
metadata: {
operation: 'add_question',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -1367,17 +1202,6 @@ export class ChaptersLessonService {
return { code: 200, message: 'Question updated successfully', data: completeQuestion as QuizQuestionData }; return { code: 200, message: 'Question updated successfully', data: completeQuestion as QuizQuestionData };
} catch (error) { } catch (error) {
logger.error(`Error updating question: ${error}`); logger.error(`Error updating question: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Question',
entityId: request.question_id,
metadata: {
operation: 'update_question',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -1471,17 +1295,6 @@ export class ChaptersLessonService {
return { code: 200, message: 'Question reordered successfully', data: questions as QuizQuestionData[] }; return { code: 200, message: 'Question reordered successfully', data: questions as QuizQuestionData[] };
} catch (error) { } catch (error) {
logger.error(`Error reordering question: ${error}`); logger.error(`Error reordering question: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Question',
entityId: request.question_id,
metadata: {
operation: 'reorder_question',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -1530,17 +1343,6 @@ export class ChaptersLessonService {
return { code: 200, message: 'Question deleted successfully' }; return { code: 200, message: 'Question deleted successfully' };
} catch (error) { } catch (error) {
logger.error(`Error deleting question: ${error}`); logger.error(`Error deleting question: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Question',
entityId: request.question_id,
metadata: {
operation: 'delete_question',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }

View file

@ -10,7 +10,6 @@ import {
UpdateCourseInput, UpdateCourseInput,
createCourseResponse, createCourseResponse,
GetMyCourseResponse, GetMyCourseResponse,
ListMyCoursesInput,
ListMyCourseResponse, ListMyCourseResponse,
addinstructorCourse, addinstructorCourse,
addinstructorCourseResponse, addinstructorCourseResponse,
@ -34,8 +33,6 @@ import {
GetEnrolledStudentDetailInput, GetEnrolledStudentDetailInput,
GetEnrolledStudentDetailResponse, GetEnrolledStudentDetailResponse,
GetCourseApprovalHistoryResponse, GetCourseApprovalHistoryResponse,
CloneCourseInput,
CloneCourseResponse,
setCourseDraft, setCourseDraft,
setCourseDraftResponse, setCourseDraftResponse,
} from "../types/CoursesInstructor.types"; } from "../types/CoursesInstructor.types";
@ -105,27 +102,16 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to create course', { error }); logger.error('Failed to create course', { error });
await auditService.logSync({
userId: userId,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: 0, // Failed to create, so no ID
metadata: {
operation: 'create_course',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
static async listMyCourses(input: ListMyCoursesInput): Promise<ListMyCourseResponse> { static async listMyCourses(token: string): Promise<ListMyCourseResponse> {
try { try {
const decoded = jwt.verify(input.token, config.jwt.secret) as { id: number; type: string }; const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
const courseInstructors = await prisma.courseInstructor.findMany({ const courseInstructors = await prisma.courseInstructor.findMany({
where: { where: {
user_id: decoded.id, user_id: decoded.id
course: input.status ? { status: input.status } : undefined
}, },
include: { include: {
course: true course: true
@ -157,17 +143,6 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to retrieve courses', { error }); logger.error('Failed to retrieve courses', { error });
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: 0,
metadata: {
operation: 'list_my_courses',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -225,17 +200,6 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to retrieve course', { error }); logger.error('Failed to retrieve course', { error });
const decoded = jwt.decode(getmyCourse.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: getmyCourse.course_id,
metadata: {
operation: 'get_my_course',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -258,17 +222,6 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to update course', { error }); logger.error('Failed to update course', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: courseId,
metadata: {
operation: 'update_course',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -322,17 +275,6 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to upload thumbnail', { error }); logger.error('Failed to upload thumbnail', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: courseId,
metadata: {
operation: 'upload_thumbnail',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -349,15 +291,6 @@ export class CoursesInstructorService {
id: courseId id: courseId
} }
}); });
await auditService.logSync({
userId: courseInstructorId.user_id,
action: AuditAction.DELETE,
entityType: 'Course',
entityId: courseId,
metadata: {
operation: 'delete_course'
}
});
return { return {
code: 200, code: 200,
message: 'Course deleted successfully', message: 'Course deleted successfully',
@ -365,17 +298,6 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to delete course', { error }); logger.error('Failed to delete course', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: courseId,
metadata: {
operation: 'delete_course',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -397,32 +319,12 @@ export class CoursesInstructorService {
status: 'PENDING' status: 'PENDING'
} }
}); });
await auditService.logSync({
userId: decoded.id,
action: AuditAction.UPDATE,
entityType: 'Course',
entityId: sendCourseForReview.course_id,
metadata: {
operation: 'send_course_for_review'
}
});
return { return {
code: 200, code: 200,
message: 'Course sent for review successfully', message: 'Course sent for review successfully',
}; };
} catch (error) { } catch (error) {
logger.error('Failed to send course for review', { error }); logger.error('Failed to send course for review', { error });
const decoded = jwt.decode(sendCourseForReview.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: sendCourseForReview.course_id,
metadata: {
operation: 'send_course_for_review',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -445,17 +347,6 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to set course to draft', { error }); logger.error('Failed to set course to draft', { error });
const decoded = jwt.decode(setCourseDraft.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: setCourseDraft.course_id,
metadata: {
operation: 'set_course_draft',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -467,6 +358,8 @@ export class CoursesInstructorService {
total: number; total: number;
}> { }> {
try { try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
// Validate instructor access // Validate instructor access
await this.validateCourseInstructor(token, courseId); await this.validateCourseInstructor(token, courseId);
@ -491,17 +384,6 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to retrieve course approvals', { error }); logger.error('Failed to retrieve course approvals', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: courseId,
metadata: {
operation: 'get_course_approvals',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -563,17 +445,6 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to search instructors', { error }); logger.error('Failed to search instructors', { error });
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: input.course_id,
metadata: {
operation: 'search_instructors',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -619,35 +490,12 @@ export class CoursesInstructorService {
} }
}); });
const decoded = jwt.decode(addinstructorCourse.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.CREATE,
entityType: 'Course',
entityId: addinstructorCourse.course_id,
metadata: {
operation: 'add_instructor_to_course',
instructor_id: user.id,
}
});
return { return {
code: 200, code: 200,
message: 'Instructor added to course successfully', message: 'Instructor added to course successfully',
}; };
} catch (error) { } catch (error) {
logger.error('Failed to add instructor to course', { error }); logger.error('Failed to add instructor to course', { error });
const decoded = jwt.decode(addinstructorCourse.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: addinstructorCourse.course_id,
metadata: {
operation: 'add_instructor_to_course',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -663,36 +511,12 @@ export class CoursesInstructorService {
}, },
} }
}); });
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.DELETE,
entityType: 'Course',
entityId: removeinstructorCourse.course_id,
metadata: {
operation: 'remove_instructor_from_course',
instructor_id: removeinstructorCourse.user_id,
course_id: removeinstructorCourse.course_id,
}
});
return { return {
code: 200, code: 200,
message: 'Instructor removed from course successfully', message: 'Instructor removed from course successfully',
}; };
} catch (error) { } catch (error) {
logger.error('Failed to remove instructor from course', { error }); logger.error('Failed to remove instructor from course', { error });
const decoded = jwt.decode(removeinstructorCourse.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: removeinstructorCourse.course_id,
metadata: {
operation: 'remove_instructor_from_course',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -743,17 +567,6 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to retrieve instructors of course', { error }); logger.error('Failed to retrieve instructors of course', { error });
const decoded = jwt.decode(listinstructorCourse.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: listinstructorCourse.course_id,
metadata: {
operation: 'list_instructors_of_course',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -772,36 +585,12 @@ export class CoursesInstructorService {
is_primary: true, is_primary: true,
} }
}); });
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.UPDATE,
entityType: 'Course',
entityId: setprimaryCourseInstructor.course_id,
metadata: {
operation: 'set_primary_instructor',
instructor_id: setprimaryCourseInstructor.user_id,
course_id: setprimaryCourseInstructor.course_id,
}
});
return { return {
code: 200, code: 200,
message: 'Primary instructor set successfully', message: 'Primary instructor set successfully',
}; };
} catch (error) { } catch (error) {
logger.error('Failed to set primary instructor', { error }); logger.error('Failed to set primary instructor', { error });
const decoded = jwt.decode(setprimaryCourseInstructor.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: setprimaryCourseInstructor.course_id,
metadata: {
operation: 'set_primary_instructor',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -840,6 +629,7 @@ export class CoursesInstructorService {
static async getEnrolledStudents(input: GetEnrolledStudentsInput): Promise<GetEnrolledStudentsResponse> { static async getEnrolledStudents(input: GetEnrolledStudentsInput): Promise<GetEnrolledStudentsResponse> {
try { try {
const { token, course_id, page = 1, limit = 20, search, status } = input; const { token, course_id, page = 1, limit = 20, search, status } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor // Validate instructor
await this.validateCourseInstructor(token, course_id); await this.validateCourseInstructor(token, course_id);
@ -917,17 +707,6 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error getting enrolled students: ${error}`); logger.error(`Error getting enrolled students: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: input.course_id,
metadata: {
operation: 'get_enrolled_students',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -1095,17 +874,6 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error getting quiz scores: ${error}`); logger.error(`Error getting quiz scores: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: input.course_id,
metadata: {
operation: 'get_quiz_scores',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -1117,6 +885,7 @@ export class CoursesInstructorService {
static async getQuizAttemptDetail(input: GetQuizAttemptDetailInput): Promise<GetQuizAttemptDetailResponse> { static async getQuizAttemptDetail(input: GetQuizAttemptDetailInput): Promise<GetQuizAttemptDetailResponse> {
try { try {
const { token, course_id, lesson_id, student_id } = input; const { token, course_id, lesson_id, student_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor // Validate instructor
await this.validateCourseInstructor(token, course_id); await this.validateCourseInstructor(token, course_id);
@ -1219,17 +988,6 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error getting quiz attempt detail: ${error}`); logger.error(`Error getting quiz attempt detail: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: input.course_id,
metadata: {
operation: 'get_quiz_attempt_detail',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -1367,17 +1125,6 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error getting enrolled student detail: ${error}`); logger.error(`Error getting enrolled student detail: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: input.course_id,
metadata: {
operation: 'get_enrolled_student_detail',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -1434,241 +1181,6 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error getting course approval history: ${error}`); logger.error(`Error getting course approval history: ${error}`);
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: courseId,
metadata: {
operation: 'get_course_approval_history',
error: error instanceof Error ? error.message : String(error)
}
});
throw error;
}
}
/**
* Clone a course (including chapters, lessons, quizzes, attachments)
*/
static async cloneCourse(input: CloneCourseInput): Promise<CloneCourseResponse> {
try {
const { token, course_id, title } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor
const courseInstructor = await this.validateCourseInstructor(token, course_id);
if (!courseInstructor) {
throw new ForbiddenError('You are not an instructor of this course');
}
// Fetch original course with all relations
const originalCourse = await prisma.course.findUnique({
where: { id: course_id },
include: {
chapters: {
orderBy: { sort_order: 'asc' },
include: {
lessons: {
orderBy: { sort_order: 'asc' },
include: {
attachments: true,
quiz: {
include: {
questions: {
include: {
choices: true
}
}
}
}
}
}
}
}
}
});
if (!originalCourse) {
throw new NotFoundError('Course not found');
}
// Use transaction for atomic creation
const newCourse = await prisma.$transaction(async (tx) => {
// 1. Create new Course
const createdCourse = await tx.course.create({
data: {
title: title as Prisma.InputJsonValue,
slug: `${originalCourse.slug}-clone-${Date.now()}`, // Temporary slug
description: originalCourse.description ?? Prisma.JsonNull,
thumbnail_url: originalCourse.thumbnail_url,
category_id: originalCourse.category_id,
price: originalCourse.price,
is_free: originalCourse.is_free,
have_certificate: originalCourse.have_certificate,
status: 'DRAFT', // Reset status
created_by: decoded.id
}
});
// 2. Add Instructor (Requester as primary)
await tx.courseInstructor.create({
data: {
course_id: createdCourse.id,
user_id: decoded.id,
is_primary: true
}
});
// Mapping for oldLessonId -> newLessonId for prerequisites
const lessonIdMap = new Map<number, number>();
const lessonsToUpdatePrerequisites: { newLessonId: number; oldPrerequisites: number[] }[] = [];
// 3. Clone Chapters and Lessons
for (const chapter of originalCourse.chapters) {
const newChapter = await tx.chapter.create({
data: {
course_id: createdCourse.id,
title: chapter.title as Prisma.InputJsonValue,
description: chapter.description ?? Prisma.JsonNull,
sort_order: chapter.sort_order,
is_published: chapter.is_published
}
});
for (const lesson of chapter.lessons) {
const newLesson = await tx.lesson.create({
data: {
chapter_id: newChapter.id,
title: lesson.title as Prisma.InputJsonValue,
content: lesson.content ?? Prisma.JsonNull,
type: lesson.type,
sort_order: lesson.sort_order,
is_published: lesson.is_published,
duration_minutes: lesson.duration_minutes,
prerequisite_lesson_ids: Prisma.JsonNull // Will update later
}
});
lessonIdMap.set(lesson.id, newLesson.id);
// Store prerequisites for later update
if (Array.isArray(lesson.prerequisite_lesson_ids) && lesson.prerequisite_lesson_ids.length > 0) {
lessonsToUpdatePrerequisites.push({
newLessonId: newLesson.id,
oldPrerequisites: lesson.prerequisite_lesson_ids as number[]
});
}
// Clone Attachments
if (lesson.attachments && lesson.attachments.length > 0) {
await tx.lessonAttachment.createMany({
data: lesson.attachments.map(att => ({
lesson_id: newLesson.id,
file_name: att.file_name,
file_path: att.file_path, // Reuse file path
file_size: att.file_size,
mime_type: att.mime_type,
sort_order: att.sort_order,
description: att.description ?? Prisma.JsonNull
}))
});
}
// Clone Quiz
if (lesson.quiz) {
const newQuiz = await tx.quiz.create({
data: {
lesson_id: newLesson.id,
title: lesson.quiz.title as Prisma.InputJsonValue,
description: lesson.quiz.description ?? Prisma.JsonNull,
passing_score: lesson.quiz.passing_score,
allow_multiple_attempts: lesson.quiz.allow_multiple_attempts,
time_limit: lesson.quiz.time_limit,
shuffle_questions: lesson.quiz.shuffle_questions,
shuffle_choices: lesson.quiz.shuffle_choices,
show_answers_after_completion: lesson.quiz.show_answers_after_completion,
created_by: decoded.id
}
});
for (const question of lesson.quiz.questions) {
await tx.question.create({
data: {
quiz_id: newQuiz.id,
question: question.question as Prisma.InputJsonValue,
explanation: question.explanation ?? Prisma.JsonNull,
question_type: question.question_type,
score: question.score,
sort_order: question.sort_order,
choices: {
create: question.choices.map(choice => ({
text: choice.text as Prisma.InputJsonValue,
is_correct: choice.is_correct,
sort_order: choice.sort_order
}))
}
}
});
}
}
}
}
// 4. Update Prerequisites
for (const item of lessonsToUpdatePrerequisites) {
const newPrerequisites = item.oldPrerequisites
.map(oldId => lessonIdMap.get(oldId))
.filter((id): id is number => id !== undefined);
if (newPrerequisites.length > 0) {
await tx.lesson.update({
where: { id: item.newLessonId },
data: {
prerequisite_lesson_ids: newPrerequisites
}
});
}
}
return createdCourse;
});
await auditService.logSync({
userId: decoded.id,
action: AuditAction.CREATE,
entityType: 'Course',
entityId: newCourse.id,
metadata: {
operation: 'clone_course',
original_course_id: course_id,
new_course_id: newCourse.id
}
});
return {
code: 201,
message: 'Course cloned successfully',
data: {
id: newCourse.id,
title: newCourse.title as { th: string; en: string }
}
};
} catch (error) {
logger.error(`Error cloning course: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: input.course_id,
metadata: {
operation: 'clone_course',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }

View file

@ -186,20 +186,7 @@ export class CoursesStudentService {
}, },
}; };
} catch (error) { } catch (error) {
logger.error(`Error enrolling in course: ${error}`); logger.error(error);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Enrollment',
entityId: 0,
metadata: {
operation: 'enroll_course',
course_id: input.course_id,
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -274,17 +261,6 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Enrollment',
entityId: 0,
metadata: {
operation: 'get_enrolled_courses',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -340,19 +316,6 @@ export class CoursesStudentService {
throw new ForbiddenError('You are not enrolled in this course'); throw new ForbiddenError('You are not enrolled in this course');
} }
// Update last_accessed_at (fire-and-forget — ไม่ block response)
if (enrollment.status === 'ENROLLED') {
prisma.enrollment.update({
where: {
unique_enrollment: {
user_id: decoded.id,
course_id,
},
},
data: { last_accessed_at: new Date() },
}).catch(err => logger.warn(`Failed to update last_accessed_at: ${err}`));
}
// Get all lesson progress for this user and course // Get all lesson progress for this user and course
const lessonIds = course.chapters.flatMap(ch => ch.lessons.map(l => l.id)); const lessonIds = course.chapters.flatMap(ch => ch.lessons.map(l => l.id));
const lessonProgress = await prisma.lessonProgress.findMany({ const lessonProgress = await prisma.lessonProgress.findMany({
@ -453,17 +416,6 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Enrollment',
entityId: 0,
metadata: {
operation: 'get_course_learning',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -726,17 +678,6 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Enrollment',
entityId: 0,
metadata: {
operation: 'get_course_learning',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -925,17 +866,6 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Enrollment',
entityId: 0,
metadata: {
operation: 'get_course_learning',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -1010,17 +940,6 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Enrollment',
entityId: 0,
metadata: {
operation: 'get_course_learning',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -1118,17 +1037,6 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Enrollment',
entityId: 0,
metadata: {
operation: 'get_course_learning',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -1260,19 +1168,7 @@ export class CoursesStudentService {
}, },
}; };
} catch (error) { } catch (error) {
logger.error(`Error completing lesson: ${error}`); logger.error(error);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'LessonProgress',
entityId: input.lesson_id,
metadata: {
operation: 'complete_lesson',
lesson_id: input.lesson_id,
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -1317,14 +1213,22 @@ export class CoursesStudentService {
}, },
}); });
if (!lesson) throw new NotFoundError('Lesson not found'); if (!lesson) {
throw new NotFoundError('Lesson not found');
}
if (lesson.type !== 'QUIZ') throw new ValidationError('This lesson is not a quiz'); if (lesson.type !== 'QUIZ') {
throw new ValidationError('This lesson is not a quiz');
}
if (!lesson.quiz) throw new NotFoundError('Quiz not found for this lesson'); if (!lesson.quiz) {
throw new NotFoundError('Quiz not found for this lesson');
}
// Verify lesson belongs to the course // Verify lesson belongs to the course
if (lesson.chapter.course_id !== course_id) throw new NotFoundError('Lesson not found in this course'); if (lesson.chapter.course_id !== course_id) {
throw new NotFoundError('Lesson not found in this course');
}
const quiz = lesson.quiz; const quiz = lesson.quiz;
@ -1428,20 +1332,7 @@ export class CoursesStudentService {
}, },
}; };
} catch (error) { } catch (error) {
logger.error(`Error submitting quiz: ${error}`); logger.error(error);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'QuizAttempt',
entityId: 0,
metadata: {
operation: 'submit_quiz',
course_id: input.course_id,
lesson_id: input.lesson_id,
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -1482,14 +1373,22 @@ export class CoursesStudentService {
}, },
}); });
if (!lesson) throw new NotFoundError('Lesson not found'); if (!lesson) {
throw new NotFoundError('Lesson not found');
}
if (lesson.type !== 'QUIZ') throw new ValidationError('This lesson is not a quiz'); if (lesson.type !== 'QUIZ') {
throw new ValidationError('This lesson is not a quiz');
}
if (!lesson.quiz) throw new NotFoundError('Quiz not found for this lesson'); if (!lesson.quiz) {
throw new NotFoundError('Quiz not found for this lesson');
}
// Verify lesson belongs to the course // Verify lesson belongs to the course
if (lesson.chapter.course_id !== course_id) throw new NotFoundError('Lesson not found in this course'); if (lesson.chapter.course_id !== course_id) {
throw new NotFoundError('Lesson not found in this course');
}
// Get all quiz attempts for this user // Get all quiz attempts for this user
const attempts = await prisma.quizAttempt.findMany({ const attempts = await prisma.quizAttempt.findMany({
@ -1539,21 +1438,6 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
const decoded = jwt.decode(input.token) as { id: number } | null;
if (decoded?.id) {
await auditService.logSync({
userId: decoded.id,
action: AuditAction.ERROR,
entityType: 'QuizAttempt',
entityId: 0,
metadata: {
operation: 'get_quiz_attempts',
course_id: input.course_id,
lesson_id: input.lesson_id,
error: error instanceof Error ? error.message : String(error)
}
});
}
throw error; throw error;
} }
} }

View file

@ -8,8 +8,7 @@ import {
ListApprovedCoursesResponse, ListApprovedCoursesResponse,
GetCourseByIdResponse, GetCourseByIdResponse,
ToggleRecommendedResponse, ToggleRecommendedResponse,
RecommendedCourseData, RecommendedCourseData
RecommendedCourseDetailData
} from '../types/RecommendedCourses.types'; } from '../types/RecommendedCourses.types';
import { auditService } from './audit.service'; import { auditService } from './audit.service';
import { AuditAction } from '@prisma/client'; import { AuditAction } from '@prisma/client';
@ -19,24 +18,10 @@ export class RecommendedCoursesService {
/** /**
* List all approved courses (for admin to manage recommendations) * List all approved courses (for admin to manage recommendations)
*/ */
static async listApprovedCourses( static async listApprovedCourses(): Promise<ListApprovedCoursesResponse> {
token: string,
filters?: { search?: string; categoryId?: number }
): Promise<ListApprovedCoursesResponse> {
try { try {
const { search, categoryId } = filters ?? {};
const courses = await prisma.course.findMany({ const courses = await prisma.course.findMany({
where: { where: { status: 'APPROVED' },
status: 'APPROVED',
...(categoryId ? { category_id: categoryId } : {}),
...(search ? {
OR: [
{ title: { path: ['th'], string_contains: search } },
{ title: { path: ['en'], string_contains: search } }
]
} : {})
},
orderBy: [ orderBy: [
{ is_recommended: 'desc' }, { is_recommended: 'desc' },
{ updated_at: 'desc' } { updated_at: 'desc' }
@ -55,9 +40,9 @@ export class RecommendedCoursesService {
} }
} }
}, },
_count: { chapters: {
select: { include: {
chapters: true lessons: true
} }
} }
} }
@ -96,7 +81,8 @@ export class RecommendedCoursesService {
is_primary: i.is_primary, is_primary: i.is_primary,
user: i.user user: i.user
})), })),
chapters_count: course._count.chapters, chapters_count: course.chapters.length,
lessons_count: course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0)
} as RecommendedCourseData; } as RecommendedCourseData;
})); }));
@ -108,19 +94,6 @@ export class RecommendedCoursesService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to list approved courses', { error }); logger.error('Failed to list approved courses', { error });
const decoded = jwt.decode(token) as { id: number } | null;
if (decoded?.id) {
await auditService.logSync({
userId: decoded.id,
action: AuditAction.ERROR,
entityType: 'RecommendedCourses',
entityId: 0,
metadata: {
operation: 'list_approved_courses',
error: error instanceof Error ? error.message : String(error)
}
});
}
throw error; throw error;
} }
} }
@ -128,7 +101,7 @@ export class RecommendedCoursesService {
/** /**
* Get course by ID (for admin to view details) * Get course by ID (for admin to view details)
*/ */
static async getCourseById(token: string, courseId: number): Promise<GetCourseByIdResponse> { static async getCourseById(courseId: number): Promise<GetCourseByIdResponse> {
try { try {
const course = await prisma.course.findUnique({ const course = await prisma.course.findUnique({
where: { id: courseId }, where: { id: courseId },
@ -172,7 +145,7 @@ export class RecommendedCoursesService {
} }
} }
const data: RecommendedCourseDetailData = { const data: RecommendedCourseData = {
id: course.id, id: course.id,
title: course.title as { th: string; en: string }, title: course.title as { th: string; en: string },
slug: course.slug, slug: course.slug,
@ -195,15 +168,8 @@ export class RecommendedCoursesService {
is_primary: i.is_primary, is_primary: i.is_primary,
user: i.user user: i.user
})), })),
chapters: course.chapters.map(ch => ({ chapters_count: course.chapters.length,
id: ch.id, lessons_count: course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0)
title: ch.title as { th: string; en: string },
sort_order: ch.sort_order,
lessons: ch.lessons.map(l => ({
id: l.id,
title: l.title as { th: string; en: string }
}))
}))
}; };
return { return {
@ -213,19 +179,6 @@ export class RecommendedCoursesService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to get course by ID', { error }); logger.error('Failed to get course by ID', { error });
const decoded = jwt.decode(token) as { id: number } | null;
if (decoded?.id) {
await auditService.logSync({
userId: decoded.id,
action: AuditAction.ERROR,
entityType: 'RecommendedCourses',
entityId: 0,
metadata: {
operation: 'get_course_by_id',
error: error instanceof Error ? error.message : String(error)
}
});
}
throw error; throw error;
} }
} }
@ -276,17 +229,6 @@ export class RecommendedCoursesService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to toggle recommended status', { error }); logger.error('Failed to toggle recommended status', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'RecommendedCourses',
entityId: courseId,
metadata: {
operation: 'toggle_recommended',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }

View file

@ -20,8 +20,6 @@ import {
} from '../types/announcements.types'; } from '../types/announcements.types';
import { CoursesInstructorService } from './CoursesInstructor.service'; import { CoursesInstructorService } from './CoursesInstructor.service';
import { uploadFile, deleteFile, getPresignedUrl } from '../config/minio'; import { uploadFile, deleteFile, getPresignedUrl } from '../config/minio';
import { auditService } from './audit.service';
import { AuditAction } from '@prisma/client';
export class AnnouncementsService { export class AnnouncementsService {
@ -39,7 +37,9 @@ export class AnnouncementsService {
where: { id: decoded.id }, where: { id: decoded.id },
include: { role: true }, include: { role: true },
}); });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) {
throw new UnauthorizedError('Invalid token');
}
// Admin can access all courses // Admin can access all courses
const isAdmin = user.role.code === 'ADMIN'; const isAdmin = user.role.code === 'ADMIN';
@ -130,16 +130,6 @@ export class AnnouncementsService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error listing announcements: ${error}`); logger.error(`Error listing announcements: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Announcement',
entityId: 0,
metadata: {
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -236,16 +226,6 @@ export class AnnouncementsService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error creating announcement: ${error}`); logger.error(`Error creating announcement: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Announcement',
entityId: 0,
metadata: {
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -320,16 +300,6 @@ export class AnnouncementsService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error updating announcement: ${error}`); logger.error(`Error updating announcement: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Announcement',
entityId: 0,
metadata: {
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -376,16 +346,6 @@ export class AnnouncementsService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error deleting announcement: ${error}`); logger.error(`Error deleting announcement: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Announcement',
entityId: 0,
metadata: {
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -451,16 +411,6 @@ export class AnnouncementsService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error uploading attachment: ${error}`); logger.error(`Error uploading attachment: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Announcement',
entityId: 0,
metadata: {
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -508,16 +458,6 @@ export class AnnouncementsService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error deleting attachment: ${error}`); logger.error(`Error deleting attachment: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Announcement',
entityId: 0,
metadata: {
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }

View file

@ -37,7 +37,6 @@ export class AuditService {
* Log await ( critical actions) * Log await ( critical actions)
*/ */
async logSync(params: CreateAuditLogParams): Promise<void> { async logSync(params: CreateAuditLogParams): Promise<void> {
try {
await prisma.auditLog.create({ await prisma.auditLog.create({
data: { data: {
user_id: params.userId, user_id: params.userId,
@ -51,9 +50,6 @@ export class AuditService {
metadata: params.metadata, metadata: params.metadata,
}, },
}); });
} catch (error) {
logger.error('Failed to create audit log (sync)', { error, params });
}
} }
/** /**

View file

@ -83,7 +83,6 @@ export class AuthService {
* User registration * User registration
*/ */
async register(data: RegisterRequest): Promise<RegisterResponse> { async register(data: RegisterRequest): Promise<RegisterResponse> {
try {
const { username, email, password, first_name, last_name, prefix, phone } = data; const { username, email, password, first_name, last_name, prefix, phone } = data;
// Check if username already exists // Check if username already exists
@ -163,26 +162,9 @@ export class AuthService {
user: this.formatUserResponseSync(user), user: this.formatUserResponseSync(user),
message: 'Registration successful' message: 'Registration successful'
}; };
} catch (error) {
logger.error('Failed to register user', { error });
await auditService.logSync({
userId: 0,
action: AuditAction.ERROR,
entityType: 'User',
entityId: 0,
metadata: {
operation: 'register_user',
email: data.email,
username: data.username,
error: error instanceof Error ? error.message : String(error)
}
});
throw error;
}
} }
async registerInstructor(data: RegisterRequest): Promise<RegisterResponse> { async registerInstructor(data: RegisterRequest): Promise<RegisterResponse> {
try {
const { username, email, password, first_name, last_name, prefix, phone } = data; const { username, email, password, first_name, last_name, prefix, phone } = data;
// Check if username already exists // Check if username already exists
@ -262,22 +244,6 @@ export class AuthService {
user: this.formatUserResponseSync(user), user: this.formatUserResponseSync(user),
message: 'Registration successful' message: 'Registration successful'
}; };
} catch (error) {
logger.error('Failed to register instructor', { error });
await auditService.logSync({
userId: 0,
action: AuditAction.ERROR,
entityType: 'User',
entityId: 0,
metadata: {
operation: 'register_instructor',
email: data.email,
username: data.username,
error: error instanceof Error ? error.message : String(error)
}
});
throw error;
}
} }
/** /**

View file

@ -5,8 +5,6 @@ import { logger } from '../config/logger';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { createCategory, createCategoryResponse, deleteCategoryResponse, updateCategory, updateCategoryResponse, ListCategoriesResponse, Category } from '../types/categories.type'; import { createCategory, createCategoryResponse, deleteCategoryResponse, updateCategory, updateCategoryResponse, ListCategoriesResponse, Category } from '../types/categories.type';
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler'; import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
import { auditService } from './audit.service';
import { AuditAction } from '@prisma/client';
export class CategoryService { export class CategoryService {
async listCategories(): Promise<ListCategoriesResponse> { async listCategories(): Promise<ListCategoriesResponse> {
@ -32,13 +30,6 @@ export class CategoryService {
const newCategory = await prisma.category.create({ const newCategory = await prisma.category.create({
data: category data: category
}); });
auditService.log({
userId: decoded.id,
action: AuditAction.CREATE,
entityType: 'Category',
entityId: newCategory.id,
newValue: { name: newCategory.name as { th: string; en: string }, slug: newCategory.slug, description: newCategory.description as { th: string; en: string } },
});
return { return {
code: 200, code: 200,
message: 'Category created successfully', message: 'Category created successfully',
@ -52,16 +43,6 @@ export class CategoryService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to create category', { error }); logger.error('Failed to create category', { error });
await auditService.logSync({
userId: 0,
action: AuditAction.ERROR,
entityType: 'Category',
entityId: 0,
metadata: {
operation: 'create_category',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -73,13 +54,6 @@ export class CategoryService {
where: { id }, where: { id },
data: category data: category
}); });
auditService.log({
userId: decoded.id,
action: AuditAction.UPDATE,
entityType: 'Category',
entityId: id,
newValue: { name: updatedCategory.name as { th: string; en: string }, slug: updatedCategory.slug, description: updatedCategory.description as { th: string; en: string } },
});
return { return {
code: 200, code: 200,
message: 'Category updated successfully', message: 'Category updated successfully',
@ -93,49 +67,21 @@ export class CategoryService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to update category', { error }); logger.error('Failed to update category', { error });
await auditService.logSync({
userId: 0,
action: AuditAction.ERROR,
entityType: 'Category',
entityId: 0,
metadata: {
operation: 'update_category',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
async deleteCategory(token: string, id: number): Promise<deleteCategoryResponse> { async deleteCategory(id: number): Promise<deleteCategoryResponse> {
try { try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string };
const deletedCategory = await prisma.category.delete({ const deletedCategory = await prisma.category.delete({
where: { id } where: { id }
}); });
auditService.log({
userId: decoded.id,
action: AuditAction.DELETE,
entityType: 'Category',
entityId: id,
newValue: { name: deletedCategory.name as { th: string; en: string }, slug: deletedCategory.slug, description: deletedCategory.description as { th: string; en: string } },
});
return { return {
code: 200, code: 200,
message: 'Category deleted successfully', message: 'Category deleted successfully',
}; };
} catch (error) { } catch (error) {
logger.error('Failed to delete category', { error }); logger.error('Failed to delete category', { error });
await auditService.logSync({
userId: 0,
action: AuditAction.ERROR,
entityType: 'Category',
entityId: 0,
metadata: {
operation: 'delete_category',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }

View file

@ -16,8 +16,6 @@ import {
ListMyCertificatesInput, ListMyCertificatesInput,
ListMyCertificatesResponse, ListMyCertificatesResponse,
} from '../types/certificate.types'; } from '../types/certificate.types';
import { auditService } from './audit.service';
import { AuditAction } from '@prisma/client';
export class CertificateService { export class CertificateService {
private static TEMPLATE_PATH = path.join(__dirname, '../../assets/templates/Certificate.pdf'); private static TEMPLATE_PATH = path.join(__dirname, '../../assets/templates/Certificate.pdf');
@ -56,11 +54,17 @@ export class CertificateService {
}, },
}); });
if (!enrollment) throw new NotFoundError('Enrollment not found'); if (!enrollment) {
throw new NotFoundError('Enrollment not found');
}
if (enrollment.status !== 'COMPLETED') throw new ForbiddenError('Course not completed yet'); if (enrollment.status !== 'COMPLETED') {
throw new ForbiddenError('Course not completed yet');
}
if (!enrollment.course.have_certificate) throw new ValidationError('This course does not offer certificates'); if (!enrollment.course.have_certificate) {
throw new ValidationError('This course does not offer certificates');
}
// Check if certificate already exists // Check if certificate already exists
const existingCertificate = await prisma.certificate.findFirst({ const existingCertificate = await prisma.certificate.findFirst({
@ -117,14 +121,6 @@ export class CertificateService {
}, },
}); });
auditService.log({
userId: decoded.id,
action: AuditAction.CREATE,
entityType: 'Certificate',
entityId: certificate.id,
newValue: { file_path: certificate.file_path, issued_at: certificate.issued_at },
});
const downloadUrl = await getPresignedUrl(filePath, 3600); const downloadUrl = await getPresignedUrl(filePath, 3600);
return { return {
@ -139,18 +135,6 @@ export class CertificateService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to generate certificate', { error }); logger.error('Failed to generate certificate', { error });
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id,
action: AuditAction.ERROR,
entityType: 'Certificate',
entityId: 0,
metadata: {
operation: 'generate_certificate',
course_id: input.course_id,
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -202,18 +186,6 @@ export class CertificateService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to get certificate', { error }); logger.error('Failed to get certificate', { error });
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id,
action: AuditAction.ERROR,
entityType: 'Certificate',
entityId: 0,
metadata: {
operation: 'get_certificate',
course_id: input.course_id,
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -267,17 +239,6 @@ export class CertificateService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to list certificates', { error }); logger.error('Failed to list certificates', { error });
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id,
action: AuditAction.ERROR,
entityType: 'Certificate',
entityId: 0,
metadata: {
operation: 'list_my_certificates',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }

View file

@ -5,8 +5,6 @@ import { logger } from '../config/logger';
import { listCourseResponse, getCourseResponse, ListCoursesInput } from '../types/courses.types'; import { listCourseResponse, getCourseResponse, ListCoursesInput } from '../types/courses.types';
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler'; import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
import { getPresignedUrl } from '../config/minio'; import { getPresignedUrl } from '../config/minio';
import { auditService } from './audit.service';
import { AuditAction } from '@prisma/client';
export class CoursesService { export class CoursesService {
async ListCourses(input: ListCoursesInput): Promise<listCourseResponse> { async ListCourses(input: ListCoursesInput): Promise<listCourseResponse> {
@ -84,16 +82,6 @@ export class CoursesService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to fetch courses', { error }); logger.error('Failed to fetch courses', { error });
await auditService.logSync({
userId: 0,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: 0,
metadata: {
operation: 'list_courses',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -103,56 +91,7 @@ export class CoursesService {
const course = await prisma.course.findFirst({ const course = await prisma.course.findFirst({
where: { where: {
id, id,
status: 'APPROVED' status: 'APPROVED' // Only show approved courses to students
},
include: {
creator: {
select: {
id: true,
username: true,
email: true,
profile: {
select: {
first_name: true,
last_name: true,
avatar_url: true
}
}
}
},
instructors: {
include: {
user: {
select: {
id: true,
username: true,
email: true,
profile: {
select: {
first_name: true,
last_name: true,
avatar_url: true
}
}
}
}
}
},
category: {
select: { id: true, name: true }
},
chapters: {
orderBy: { sort_order: 'asc' },
select: {
id: true,
title: true,
sort_order: true,
lessons: {
orderBy: { sort_order: 'asc' },
select: { id: true, title: true }
}
}
}
} }
}); });
@ -173,83 +112,16 @@ export class CoursesService {
logger.warn(`Failed to generate presigned URL for thumbnail: ${err}`); logger.warn(`Failed to generate presigned URL for thumbnail: ${err}`);
} }
} }
// Generate presigned URL for creator avatar
let creator_avatar_url: string | null = null;
if (course.creator.profile?.avatar_url) {
try {
creator_avatar_url = await getPresignedUrl(course.creator.profile.avatar_url, 3600);
} catch (err) {
logger.warn(`Failed to generate presigned URL for creator avatar: ${err}`);
}
}
// Generate presigned URLs for instructor avatars
const instructorsWithAvatar = await Promise.all(course.instructors.map(async (i) => {
let avatar_url: string | null = null;
if (i.user.profile?.avatar_url) {
try {
avatar_url = await getPresignedUrl(i.user.profile.avatar_url, 3600);
} catch (err) {
logger.warn(`Failed to generate presigned URL for instructor avatar: ${err}`);
}
}
return {
user_id: i.user_id,
is_primary: i.is_primary,
user: {
...i.user,
profile: i.user.profile ? {
...i.user.profile,
avatar_url
} : null
}
};
}));
return { return {
code: 200, code: 200,
message: 'Course fetched successfully', message: 'Course fetched successfully',
data: { data: {
...course, ...course,
title: course.title as { th: string; en: string },
description: course.description as { th: string; en: string },
thumbnail_url: thumbnail_presigned_url, thumbnail_url: thumbnail_presigned_url,
creator: {
...course.creator,
profile: course.creator.profile ? {
...course.creator.profile,
avatar_url: creator_avatar_url
} : null
},
instructors: instructorsWithAvatar,
category: course.category ? {
id: course.category.id,
name: course.category.name as { th: string; en: string }
} : null,
chapters: course.chapters.map(ch => ({
id: ch.id,
title: ch.title as { th: string; en: string },
sort_order: ch.sort_order,
lessons: ch.lessons.map(l => ({
id: l.id,
title: l.title as { th: string; en: string }
}))
}))
}, },
}; };
} catch (error) { } catch (error) {
logger.error('Failed to fetch course', { error }); logger.error('Failed to fetch course', { error });
await auditService.logSync({
userId: 0,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: id,
metadata: {
operation: 'get_course',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }

View file

@ -14,8 +14,7 @@ import {
updateAvatarRequest, updateAvatarRequest,
updateAvatarResponse, updateAvatarResponse,
SendVerifyEmailResponse, SendVerifyEmailResponse,
VerifyEmailResponse, VerifyEmailResponse
rolesResponse
} from '../types/user.types'; } from '../types/user.types';
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler'; import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
@ -136,17 +135,6 @@ export class UserService {
throw new UnauthorizedError('Token expired'); throw new UnauthorizedError('Token expired');
} }
logger.error('Failed to change password', { error }); logger.error('Failed to change password', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'User',
entityId: decoded?.id || 0,
metadata: {
operation: 'change_password',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -198,41 +186,6 @@ export class UserService {
throw new UnauthorizedError('Token expired'); throw new UnauthorizedError('Token expired');
} }
logger.error('Failed to update profile', { error }); logger.error('Failed to update profile', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.UPDATE,
entityType: 'UserProfile',
entityId: decoded?.id || 0,
metadata: {
operation: 'update_profile',
error: error instanceof Error ? error.message : String(error)
}
});
throw error;
}
}
async getRoles(token: string): Promise<rolesResponse> {
try {
jwt.verify(token, config.jwt.secret);
const roles = await prisma.role.findMany({
select: {
id: true,
code: true
}
});
return { roles };
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
logger.error('JWT token expired:', error);
throw new UnauthorizedError('Token expired');
}
if (error instanceof jwt.JsonWebTokenError) {
logger.error('Invalid JWT token:', error);
throw new UnauthorizedError('Invalid token');
}
logger.error('Failed to get roles', { error });
throw error; throw error;
} }
} }
@ -299,18 +252,6 @@ export class UserService {
}); });
} }
// Audit log - UPLOAD_AVATAR
await auditService.logSync({
userId: decoded.id,
action: AuditAction.UPLOAD_FILE,
entityType: 'User',
entityId: decoded.id,
metadata: {
operation: 'upload_avatar',
filePath
}
});
// Generate presigned URL for response // Generate presigned URL for response
const presignedUrl = await this.getAvatarPresignedUrl(filePath); const presignedUrl = await this.getAvatarPresignedUrl(filePath);
@ -332,18 +273,6 @@ export class UserService {
throw new UnauthorizedError('Token expired'); throw new UnauthorizedError('Token expired');
} }
logger.error('Failed to upload avatar', { error }); logger.error('Failed to upload avatar', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.UPLOAD_FILE,
entityType: 'UserProfile',
entityId: decoded?.id || 0,
metadata: {
operation: 'upload_avatar',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -456,17 +385,6 @@ export class UserService {
if (error instanceof jwt.JsonWebTokenError) throw new UnauthorizedError('Invalid token'); if (error instanceof jwt.JsonWebTokenError) throw new UnauthorizedError('Invalid token');
if (error instanceof jwt.TokenExpiredError) throw new UnauthorizedError('Token expired'); if (error instanceof jwt.TokenExpiredError) throw new UnauthorizedError('Token expired');
logger.error('Failed to send verification email', { error }); logger.error('Failed to send verification email', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'UserProfile',
entityId: decoded?.id || 0,
metadata: {
operation: 'send_verification_email',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -497,15 +415,6 @@ export class UserService {
}); });
logger.info('Email verified successfully', { userId: user.id, email: user.email }); logger.info('Email verified successfully', { userId: user.id, email: user.email });
await auditService.logSync({
userId: user.id,
action: AuditAction.VERIFY_EMAIL,
entityType: 'UserProfile',
entityId: user.id,
metadata: {
operation: 'verify_email'
}
});
return { return {
code: 200, code: 200,
message: 'Email verified successfully' message: 'Email verified successfully'
@ -514,17 +423,6 @@ export class UserService {
if (error instanceof jwt.JsonWebTokenError) throw new UnauthorizedError('Invalid verification token'); if (error instanceof jwt.JsonWebTokenError) throw new UnauthorizedError('Invalid verification token');
if (error instanceof jwt.TokenExpiredError) throw new UnauthorizedError('Verification link has expired'); if (error instanceof jwt.TokenExpiredError) throw new UnauthorizedError('Verification link has expired');
logger.error('Failed to verify email', { error }); logger.error('Failed to verify email', { error });
const decoded = jwt.decode(verifyToken) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'UserProfile',
entityId: decoded?.id || 0,
metadata: {
operation: 'verify_email',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }

View file

@ -39,16 +39,6 @@ export class UserManagementService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to fetch users', { error }); logger.error('Failed to fetch users', { error });
await auditService.logSync({
userId: 0,
action: AuditAction.ERROR,
entityType: 'User',
entityId: 0,
metadata: {
operation: 'list_users',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -71,16 +61,6 @@ export class UserManagementService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to fetch user by ID', { error }); logger.error('Failed to fetch user by ID', { error });
await auditService.logSync({
userId: id,
action: AuditAction.ERROR,
entityType: 'User',
entityId: id,
metadata: {
operation: 'get_user_by_id',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -115,17 +95,6 @@ export class UserManagementService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to update user role', { error }); logger.error('Failed to update user role', { error });
await auditService.logSync({
userId: id,
action: AuditAction.ERROR,
entityType: 'User',
entityId: id,
metadata: {
operation: 'update_user_role',
target_role_id: role_id,
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -145,16 +114,6 @@ export class UserManagementService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to deactivate user', { error }); logger.error('Failed to deactivate user', { error });
await auditService.logSync({
userId: id,
action: AuditAction.ERROR,
entityType: 'User',
entityId: id,
metadata: {
operation: 'delete_user',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -201,16 +160,6 @@ export class UserManagementService {
throw new UnauthorizedError('Token expired'); throw new UnauthorizedError('Token expired');
} }
logger.error('Failed to deactivate account', { error }); logger.error('Failed to deactivate account', { error });
await auditService.logSync({
userId: id,
action: AuditAction.ERROR,
entityType: 'User',
entityId: id,
metadata: {
operation: 'deactivate_account',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }
@ -258,16 +207,6 @@ export class UserManagementService {
throw new UnauthorizedError('Token expired'); throw new UnauthorizedError('Token expired');
} }
logger.error('Failed to activate account', { error }); logger.error('Failed to activate account', { error });
await auditService.logSync({
userId: id,
action: AuditAction.ERROR,
entityType: 'User',
entityId: id,
metadata: {
operation: 'activate_account',
error: error instanceof Error ? error.message : String(error)
}
});
throw error; throw error;
} }
} }

View file

@ -117,6 +117,10 @@ export interface GetCourseDetailForAdminResponse {
data: CourseDetailForAdmin; data: CourseDetailForAdmin;
} }
export interface ApproveCourseBody {
comment?: string;
}
export interface ApproveCourseResponse { export interface ApproveCourseResponse {
code: number; code: number;
message: string; message: string;

View file

@ -23,11 +23,6 @@ export interface createCourseResponse {
data: Course; data: Course;
} }
export interface ListMyCoursesInput {
token: string;
status?: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'ARCHIVED';
}
export interface ListMyCourseResponse { export interface ListMyCourseResponse {
code: number; code: number;
message: string; message: string;
@ -433,18 +428,3 @@ export interface GetCourseApprovalHistoryResponse {
approval_history: ApprovalHistoryItem[]; approval_history: ApprovalHistoryItem[];
}; };
} }
export interface CloneCourseInput {
token: string;
course_id: number;
title: MultiLanguageText;
}
export interface CloneCourseResponse {
code: number;
message: string;
data: {
id: number;
title: MultiLanguageText;
};
}

View file

@ -1,10 +1,14 @@
import { MultiLanguageText } from './index'; import { MultiLanguageText } from './index';
// ============================================
// Request Types
// ============================================
// ============================================ // ============================================
// Response Types // Response Types
// ============================================ // ============================================
/** ใช้ใน listApprovedCourses — มีแค่ chapters_count */
export interface RecommendedCourseData { export interface RecommendedCourseData {
id: number; id: number;
title: MultiLanguageText; title: MultiLanguageText;
@ -37,19 +41,7 @@ export interface RecommendedCourseData {
}; };
}>; }>;
chapters_count: number; chapters_count: number;
} lessons_count: number;
/** ใช้ใน getCourseById — มี chapters + lessons พร้อมชื่อ */
export interface RecommendedCourseDetailData extends Omit<RecommendedCourseData, 'chapters_count'> {
chapters: {
id: number;
title: MultiLanguageText;
sort_order: number;
lessons: {
id: number;
title: MultiLanguageText;
}[];
}[];
} }
export interface ListApprovedCoursesResponse { export interface ListApprovedCoursesResponse {
@ -62,7 +54,7 @@ export interface ListApprovedCoursesResponse {
export interface GetCourseByIdResponse { export interface GetCourseByIdResponse {
code: number; code: number;
message: string; message: string;
data: RecommendedCourseDetailData; data: RecommendedCourseData;
} }
export interface ToggleRecommendedResponse { export interface ToggleRecommendedResponse {

View file

@ -1,5 +1,4 @@
import { Course } from '@prisma/client'; import { Course } from '@prisma/client';
import { MultiLanguageText } from './index';
export interface ListCoursesInput { export interface ListCoursesInput {
category_id?: number; category_id?: number;
@ -19,47 +18,8 @@ export interface listCourseResponse {
totalPages: number; totalPages: number;
} }
export interface CourseDetail extends Omit<Course, 'title' | 'description'> {
title: MultiLanguageText;
description: MultiLanguageText;
creator: {
id: number;
username: string;
email: string;
profile: {
first_name: string;
last_name: string;
avatar_url: string | null;
} | null;
};
instructors: {
user_id: number;
is_primary: boolean;
user: {
id: number;
username: string;
email: string;
profile: {
first_name: string;
last_name: string;
avatar_url: string | null;
} | null;
};
}[];
category: { id: number; name: MultiLanguageText } | null;
chapters: {
id: number;
title: MultiLanguageText;
sort_order: number;
lessons: {
id: number;
title: MultiLanguageText;
}[];
}[];
}
export interface getCourseResponse { export interface getCourseResponse {
code: number; code: number;
message: string; message: string;
data: CourseDetail | null; data: Course | null;
} }

View file

@ -59,14 +59,6 @@ export interface ProfileUpdateResponse {
}; };
}; };
export interface role {
id: number;
code: string;
}
export interface rolesResponse {
roles: role[];
}
export interface ChangePasswordRequest { export interface ChangePasswordRequest {
old_password: string; old_password: string;

View file

@ -1,30 +0,0 @@
import Joi from 'joi';
/**
* Validator for approving a course
* Comment is optional
*/
export const ApproveCourseValidator = Joi.object({
comment: Joi.string()
.max(1000)
.optional()
.messages({
'string.max': 'Comment must not exceed 1000 characters'
})
});
/**
* Validator for rejecting a course
* Comment is required when rejecting
*/
export const RejectCourseValidator = Joi.object({
comment: Joi.string()
.min(10)
.max(1000)
.required()
.messages({
'string.min': 'Comment must be at least 10 characters when rejecting a course',
'string.max': 'Comment must not exceed 1000 characters',
'any.required': 'Comment is required when rejecting a course'
})
});

View file

@ -1,186 +0,0 @@
import Joi from 'joi';
// Multi-language validation schema
const multiLangSchema = Joi.object({
th: Joi.string().required().messages({
'any.required': 'Thai text is required'
}),
en: Joi.string().required().messages({
'any.required': 'English text is required'
})
}).required();
const multiLangOptionalSchema = Joi.object({
th: Joi.string().optional(),
en: Joi.string().optional()
}).optional();
// ============================================
// Chapter Validators
// ============================================
/**
* Validator for creating a chapter
*/
export const CreateChapterValidator = Joi.object({
title: multiLangSchema.messages({
'any.required': 'Title is required'
}),
description: multiLangOptionalSchema,
sort_order: Joi.number().integer().min(0).optional()
});
/**
* Validator for updating a chapter
*/
export const UpdateChapterValidator = Joi.object({
title: multiLangOptionalSchema,
description: multiLangOptionalSchema,
sort_order: Joi.number().integer().min(0).optional(),
is_published: Joi.boolean().optional()
});
/**
* Validator for reordering a chapter
*/
export const ReorderChapterValidator = Joi.object({
sort_order: Joi.number().integer().min(0).required().messages({
'any.required': 'Sort order is required',
'number.min': 'Sort order must be at least 0'
})
});
// ============================================
// Lesson Validators
// ============================================
/**
* Validator for creating a lesson
*/
export const CreateLessonValidator = Joi.object({
title: multiLangSchema.messages({
'any.required': 'Title is required'
}),
content: multiLangOptionalSchema,
type: Joi.string().valid('VIDEO', 'QUIZ').required().messages({
'any.only': 'Type must be either VIDEO or QUIZ',
'any.required': 'Type is required'
}),
sort_order: Joi.number().integer().min(0).optional()
});
/**
* Validator for updating a lesson
*/
export const UpdateLessonValidator = Joi.object({
title: multiLangOptionalSchema,
content: multiLangOptionalSchema,
duration_minutes: Joi.number().min(0).optional().messages({
'number.min': 'Duration must be at least 0'
}),
sort_order: Joi.number().integer().min(0).optional(),
prerequisite_lesson_ids: Joi.array().items(Joi.number().integer().positive()).optional(),
is_published: Joi.boolean().optional()
});
/**
* Validator for reordering lessons
*/
export const ReorderLessonsValidator = Joi.object({
lesson_id: Joi.number().integer().positive().required().messages({
'any.required': 'Lesson ID is required'
}),
sort_order: Joi.number().integer().min(0).required().messages({
'any.required': 'Sort order is required'
})
});
// ============================================
// Quiz Question Validators
// ============================================
/**
* Validator for quiz choice
*/
const QuizChoiceValidator = Joi.object({
text: multiLangSchema.messages({
'any.required': 'Choice text is required'
}),
is_correct: Joi.boolean().required().messages({
'any.required': 'is_correct is required'
}),
sort_order: Joi.number().integer().min(0).optional()
});
/**
* Validator for adding a question to a quiz
*/
export const AddQuestionValidator = Joi.object({
question: multiLangSchema.messages({
'any.required': 'Question is required'
}),
explanation: multiLangOptionalSchema,
question_type: Joi.string()
.valid('MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER')
.required()
.messages({
'any.only': 'Question type must be MULTIPLE_CHOICE, TRUE_FALSE, or SHORT_ANSWER',
'any.required': 'Question type is required'
}),
sort_order: Joi.number().integer().min(0).optional(),
choices: Joi.array().items(QuizChoiceValidator).min(1).optional().messages({
'array.min': 'At least one choice is required for multiple choice questions'
})
});
/**
* Validator for updating a question
*/
export const UpdateQuestionValidator = Joi.object({
question: multiLangOptionalSchema,
explanation: multiLangOptionalSchema,
question_type: Joi.string()
.valid('MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER')
.optional()
.messages({
'any.only': 'Question type must be MULTIPLE_CHOICE, TRUE_FALSE, or SHORT_ANSWER'
}),
sort_order: Joi.number().integer().min(0).optional(),
choices: Joi.array().items(QuizChoiceValidator).min(1).optional().messages({
'array.min': 'At least one choice is required'
})
});
/**
* Validator for reordering a question
*/
export const ReorderQuestionValidator = Joi.object({
sort_order: Joi.number().integer().min(0).required().messages({
'any.required': 'Sort order is required',
'number.min': 'Sort order must be at least 0'
})
});
// ============================================
// Quiz Settings Validator
// ============================================
/**
* Validator for updating quiz settings
*/
export const UpdateQuizValidator = Joi.object({
title: multiLangOptionalSchema,
description: multiLangOptionalSchema,
passing_score: Joi.number().min(0).max(100).optional().messages({
'number.min': 'Passing score must be at least 0',
'number.max': 'Passing score must not exceed 100'
}),
time_limit: Joi.number().min(0).optional().messages({
'number.min': 'Time limit must be at least 0'
}),
shuffle_questions: Joi.boolean().optional(),
shuffle_choices: Joi.boolean().optional(),
show_answers_after_completion: Joi.boolean().optional(),
is_skippable: Joi.boolean().optional(),
allow_multiple_attempts: Joi.boolean().optional()
});

View file

@ -20,38 +20,3 @@ export const CreateCourseValidator = Joi.object({
is_free: Joi.boolean().required(), is_free: Joi.boolean().required(),
have_certificate: Joi.boolean().required(), have_certificate: Joi.boolean().required(),
}); });
/**
* Validator for updating a course
*/
export const UpdateCourseValidator = Joi.object({
category_id: Joi.number().optional(),
title: Joi.object({
th: Joi.string().optional(),
en: Joi.string().optional(),
}).optional(),
slug: Joi.string().optional(),
description: Joi.object({
th: Joi.string().optional(),
en: Joi.string().optional(),
}).optional(),
price: Joi.number().optional(),
is_free: Joi.boolean().optional(),
have_certificate: Joi.boolean().optional(),
});
/**
* Validator for cloning a course
*/
export const CloneCourseValidator = Joi.object({
title: Joi.object({
th: Joi.string().required().messages({
'any.required': 'Thai title is required'
}),
en: Joi.string().required().messages({
'any.required': 'English title is required'
})
}).required().messages({
'any.required': 'Title is required'
})
});

View file

@ -1,38 +0,0 @@
import Joi from 'joi';
/**
* Validator for saving video progress
*/
export const SaveVideoProgressValidator = Joi.object({
video_progress_seconds: Joi.number().min(0).required().messages({
'any.required': 'Video progress seconds is required',
'number.min': 'Video progress must be at least 0'
}),
video_duration_seconds: Joi.number().min(0).optional().messages({
'number.min': 'Video duration must be at least 0'
})
});
/**
* Validator for quiz answer
*/
const QuizAnswerValidator = Joi.object({
question_id: Joi.number().integer().positive().required().messages({
'any.required': 'Question ID is required',
'number.positive': 'Question ID must be positive'
}),
choice_id: Joi.number().integer().positive().required().messages({
'any.required': 'Choice ID is required',
'number.positive': 'Choice ID must be positive'
})
});
/**
* Validator for submitting quiz answers
*/
export const SubmitQuizValidator = Joi.object({
answers: Joi.array().items(QuizAnswerValidator).min(1).required().messages({
'any.required': 'Answers are required',
'array.min': 'At least one answer is required'
})
});

View file

@ -1,15 +0,0 @@
import Joi from 'joi';
/**
* Validator for setting YouTube video
*/
export const SetYouTubeVideoValidator = Joi.object({
youtube_video_id: Joi.string().required().messages({
'any.required': 'YouTube video ID is required',
'string.empty': 'YouTube video ID cannot be empty'
}),
video_title: Joi.string().required().messages({
'any.required': 'Video title is required',
'string.empty': 'Video title cannot be empty'
})
});

View file

@ -1,72 +0,0 @@
import Joi from 'joi';
/**
* Validator for creating an announcement
*/
export const CreateAnnouncementValidator = Joi.object({
title: Joi.object({
th: Joi.string().required().messages({
'any.required': 'Thai title is required'
}),
en: Joi.string().required().messages({
'any.required': 'English title is required'
})
}).required().messages({
'any.required': 'Title is required'
}),
content: Joi.object({
th: Joi.string().required().messages({
'any.required': 'Thai content is required'
}),
en: Joi.string().required().messages({
'any.required': 'English content is required'
})
}).required().messages({
'any.required': 'Content is required'
}),
status: Joi.string()
.valid('DRAFT', 'PUBLISHED', 'ARCHIVED')
.required()
.messages({
'any.only': 'Status must be one of: DRAFT, PUBLISHED, ARCHIVED',
'any.required': 'Status is required'
}),
is_pinned: Joi.boolean()
.required()
.messages({
'any.required': 'is_pinned is required'
}),
published_at: Joi.string()
.isoDate()
.optional()
.messages({
'string.isoDate': 'published_at must be a valid ISO date string'
})
});
/**
* Validator for updating an announcement
*/
export const UpdateAnnouncementValidator = Joi.object({
title: Joi.object({
th: Joi.string().optional(),
en: Joi.string().optional()
}).optional(),
content: Joi.object({
th: Joi.string().optional(),
en: Joi.string().optional()
}).optional(),
status: Joi.string()
.valid('DRAFT', 'PUBLISHED', 'ARCHIVED')
.optional()
.messages({
'any.only': 'Status must be one of: DRAFT, PUBLISHED, ARCHIVED'
}),
is_pinned: Joi.boolean().optional(),
published_at: Joi.string()
.isoDate()
.optional()
.messages({
'string.isoDate': 'published_at must be a valid ISO date string'
})
});

View file

@ -1,58 +0,0 @@
import Joi from 'joi';
/**
* Validator for creating a category
*/
export const CreateCategoryValidator = Joi.object({
name: Joi.object({
th: Joi.string().required().messages({
'any.required': 'Thai name is required'
}),
en: Joi.string().required().messages({
'any.required': 'English name is required'
})
}).required().messages({
'any.required': 'Name is required'
}),
slug: Joi.string()
.pattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
.required()
.messages({
'string.pattern.base': 'Slug must be lowercase with hyphens (e.g., web-development)',
'any.required': 'Slug is required'
}),
description: Joi.object({
th: Joi.string().required().messages({
'any.required': 'Thai description is required'
}),
en: Joi.string().required().messages({
'any.required': 'English description is required'
})
}).required().messages({
'any.required': 'Description is required'
}),
created_by: Joi.number().optional()
});
/**
* Validator for updating a category
*/
export const UpdateCategoryValidator = Joi.object({
id: Joi.number().required().messages({
'any.required': 'Category ID is required'
}),
name: Joi.object({
th: Joi.string().optional(),
en: Joi.string().optional()
}).optional(),
slug: Joi.string()
.pattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
.optional()
.messages({
'string.pattern.base': 'Slug must be lowercase with hyphens (e.g., web-development)'
}),
description: Joi.object({
th: Joi.string().optional(),
en: Joi.string().optional()
}).optional()
});

View file

@ -1,160 +0,0 @@
// Backend/tests/k6/enroll-load-test.js
//
// จำลองนักเรียนหลายคน login แล้ว enroll คอร์สพร้อมกัน
//
// Flow:
// 1. Login
// 2. Enroll คอร์ส
// 3. ตรวจสอบ enrolled courses
//
// Usage:
// k6 run -e APP_URL=http://192.168.1.137:4000 -e COURSE_ID=1 tests/k6/enroll-load-test.js
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Rate, Trend, Counter } from 'k6/metrics';
import { SharedArray } from 'k6/data';
// ─── Custom Metrics ───────────────────────────────────────────────────────────
const errorRate = new Rate('errors');
const loginTime = new Trend('login_duration', true);
const enrollTime = new Trend('enroll_duration', true);
const enrolledCount = new Counter('successful_enrollments');
// ─── Load student credentials ─────────────────────────────────────────────────
const students = new SharedArray('students', function () {
return JSON.parse(open('./test-credentials.json')).students;
});
// ─── Config ───────────────────────────────────────────────────────────────────
const BASE_URL = __ENV.APP_URL || 'http://192.168.1.137:4000';
const COURSE_ID = __ENV.COURSE_ID || '1';
// ─── Test Options ─────────────────────────────────────────────────────────────
export const options = {
stages: [
{ duration: '20s', target: 10 }, // Ramp up
{ duration: '1m', target: 30 }, // Increase
{ duration: '30s', target: 50 }, // Peak: 50 คน enroll พร้อมกัน
{ duration: '30s', target: 0 }, // Ramp down
],
thresholds: {
'login_duration': ['p(95)<2000'], // Login < 2s
'enroll_duration': ['p(95)<1000'], // Enroll < 1s
'errors': ['rate<0.05'],
'http_req_failed': ['rate<0.05'],
},
};
// ─── Helper ───────────────────────────────────────────────────────────────────
function jsonHeaders(token) {
const h = { 'Content-Type': 'application/json' };
if (token) h['Authorization'] = `Bearer ${token}`;
return h;
}
// ─── Main ─────────────────────────────────────────────────────────────────────
export default function () {
const student = students[__VU % students.length];
let token = null;
// ── Step 1: Login ──────────────────────────────────────────────────────────
group('1. Login', () => {
const res = http.post(
`${BASE_URL}/api/auth/login`,
JSON.stringify({ email: student.email, password: student.password }),
{ headers: jsonHeaders(null) }
);
loginTime.add(res.timings.duration);
errorRate.add(res.status !== 200);
check(res, {
'login: status 200': (r) => r.status === 200,
'login: has token': (r) => { try { return !!r.json('data.token'); } catch { return false; } },
});
if (res.status === 200) {
try { token = res.json('data.token'); } catch {}
}
});
if (!token) {
console.warn(`[VU ${__VU}] Login failed for ${student.email} — skipping`);
sleep(1);
return;
}
sleep(0.5);
// ── Step 2: Enroll ─────────────────────────────────────────────────────────
group('2. Enroll Course', () => {
const res = http.post(
`${BASE_URL}/api/students/courses/${COURSE_ID}/enroll`,
null,
{ headers: jsonHeaders(token) }
);
enrollTime.add(res.timings.duration);
// 200 = enrolled, 409 = already enrolled (ถือว่าโอเค)
const ok = res.status === 200 || res.status === 409;
errorRate.add(!ok);
if (res.status === 200) enrolledCount.add(1);
check(res, {
'enroll: 200 or 409': (r) => r.status === 200 || r.status === 409,
'enroll: fast response': (r) => r.timings.duration < 1000,
});
});
sleep(0.5);
// ── Step 3: Verify — ดึงรายการคอร์สที่ลงทะเบียน ─────────────────────────
group('3. Get Enrolled Courses', () => {
const res = http.get(
`${BASE_URL}/api/students/courses`,
{ headers: jsonHeaders(token) }
);
errorRate.add(res.status !== 200);
check(res, {
'enrolled courses: status 200': (r) => r.status === 200,
});
});
sleep(1);
}
// ─── Summary ──────────────────────────────────────────────────────────────────
export function handleSummary(data) {
const m = data.metrics;
const avg = (k) => m[k]?.values?.avg?.toFixed(0) ?? 'N/A';
const p95 = (k) => m[k]?.values?.['p(95)']?.toFixed(0) ?? 'N/A';
const rate = (k) => ((m[k]?.values?.rate ?? 0) * 100).toFixed(2);
const cnt = (k) => m[k]?.values?.count ?? 0;
return {
stdout: `
Course Enroll Load Test
Course ID : ${String(COURSE_ID).padEnd(43)}
RESPONSE TIMES (avg / p95)
Login : ${avg('login_duration')}ms / ${p95('login_duration')}ms
Enroll : ${avg('enroll_duration')}ms / ${p95('enroll_duration')}ms
COUNTS
Total Requests : ${String(cnt('http_reqs')).padEnd(33)}
New Enrollments : ${String(cnt('successful_enrollments')).padEnd(33)}
ERROR RATES
HTTP Failed : ${(rate('http_req_failed') + '%').padEnd(39)}
Custom Errors : ${(rate('errors') + '%').padEnd(39)}
`,
};
}

View file

@ -31,7 +31,7 @@ export const options = {
thresholds: { thresholds: {
http_req_duration: ['p(95)<2000'], // 95% of requests < 2s http_req_duration: ['p(95)<2000'], // 95% of requests < 2s
errors: ['rate<0.1'], // Error rate < 10% errors: ['rate<0.1'], // Error rate < 10%
login_duration: ['p(95)<2000'], // 95% pof logins < 2s login_duration: ['p(95)<2000'], // 95% of logins < 2s
}, },
}; };

View file

@ -1,269 +0,0 @@
// Backend/tests/k6/video-watching-load-test.js
//
// จำลองนักเรียนหลายคนดูวีดีโอพร้อมกัน (Concurrent Video Watching)
//
// Flow จริงที่ simulate:
// 1. Login ด้วย account ของ student แต่ละคน
// 2. Load หน้าเรียนคอร์ส (getCourseLearning)
// 3. เปิดบทเรียนวีดีโอ (getLessonContent)
// 4. Save progress ทุก 5 วินาที (จำลองการ watch)
// 5. เมื่อดูครบ (≥90%) → mark lesson complete
//
// Usage:
// k6 run -e APP_URL=http://localhost:4000 -e COURSE_ID=1 -e LESSON_ID=1 tests/k6/video-watching-load-test.js
//
// ปรับจำนวน VUs และ duration ได้ด้วย:
// k6 run -e APP_URL=http://localhost:4000 -e COURSE_ID=1 -e LESSON_ID=1 --vus 30 --duration 2m tests/k6/video-watching-load-test.js
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Rate, Trend, Counter } from 'k6/metrics';
import { SharedArray } from 'k6/data';
// ─── Custom Metrics ───────────────────────────────────────────────────────────
const errorRate = new Rate('errors');
const loginTime = new Trend('login_duration', true);
const courseLearningTime = new Trend('course_learning_duration', true);
const lessonLoadTime = new Trend('lesson_load_duration', true);
const progressSaveTime = new Trend('progress_save_duration', true);
const completeLessonTime = new Trend('complete_lesson_duration', true);
const completedCount = new Counter('completed_lessons');
const progressSaveCount = new Counter('progress_saves');
const videoLoadTime = new Trend('video_load_duration', true);
// ─── Load student credentials ────────────────────────────────────────────────
// อ่านจาก test-credentials.json (50 accounts)
const students = new SharedArray('students', function () {
return JSON.parse(open('./test-credentials.json')).students;
});
// ─── Config ───────────────────────────────────────────────────────────────────
const BASE_URL = __ENV.APP_URL || 'http://192.168.1.137:4000';
const COURSE_ID = __ENV.COURSE_ID || '1';
const LESSON_ID = __ENV.LESSON_ID || '1';
// วีดีโอความยาว (วินาที) — ปรับตามจริง
const VIDEO_DURATION_SECONDS = parseInt(__ENV.VIDEO_DURATION || '98'); // default 5 นาที
// save progress interval: ทุก 5 วินาที (เหมือน client จริง)
// แต่ในการ test เราจะ simulate เร็วขึ้นโดยใช้ sleep น้อยลง
const PROGRESS_INTERVAL_SECONDS = parseInt(__ENV.PROGRESS_INTERVAL || '15');
// ─── Test Options ─────────────────────────────────────────────────────────────
export const options = {
stages: [
{ duration: '30s', target: 10 }, // Ramp up: 10 คนเริ่มดูวีดีโอ
{ duration: '1m', target: 30 }, // Ramp up: เพิ่มเป็น 30 คน
{ duration: '2m', target: 30 }, // Steady: 30 คนดูพร้อมกัน
{ duration: '30s', target: 50 }, // Peak: เพิ่มเป็น 50 คน
{ duration: '1m', target: 50 }, // Steady Peak: 50 คนพร้อมกัน
{ duration: '30s', target: 0 }, // Ramp down
],
thresholds: {
// Response times
'login_duration': ['p(95)<2000'], // Login < 2s
'course_learning_duration': ['p(95)<1000'], // Load course page < 1s
'lesson_load_duration': ['p(95)<1000'], // Load lesson < 1s
'video_load_duration': ['p(95)<3000'], // Fetch video from MinIO < 3s
'progress_save_duration': ['p(95)<500'], // Save progress < 500ms (critical — บ่อย)
'complete_lesson_duration': ['p(95)<1000'], // Complete lesson < 1s
// Error rate
'errors': ['rate<0.05'], // Error < 5%
'http_req_failed': ['rate<0.05'], // HTTP error < 5%
},
};
// ─── Helper ───────────────────────────────────────────────────────────────────
function jsonHeaders(token) {
const h = { 'Content-Type': 'application/json' };
if (token) h['Authorization'] = `Bearer ${token}`;
return h;
}
// ─── Per-VU persistent state (จำข้ามรอบ iteration) ──────────────────────────
// ตัวแปรนี้อยู่ระดับ module → k6 สร้างแยกต่างหากสำหรับแต่ละ VU
// ค่าจะถูกจำไว้ตลอดอายุของ VU (ข้ามหลายรอบ iteration)
let vuToken = null; // token ที่ login ไว้แล้ว
let vuSetupDone = false; // เคย load course+lesson แล้วหรือยัง
let vuProgress = 0; // ตำแหน่งวีดีโอปัจจุบัน (วินาที)
let vuCompleted = false; // lesson complete แล้วหรือยัง
// ─── Main ─────────────────────────────────────────────────────────────────────
export default function () {
const student = students[__VU % students.length];
// ── Step 1: Login (ทำครั้งเดียวตอน VU เริ่มต้น หรือถ้า token หาย) ─────────
if (!vuToken) {
group('1. Login', () => {
const res = http.post(
`${BASE_URL}/api/auth/login`,
JSON.stringify({ email: student.email, password: student.password }),
{ headers: jsonHeaders(null) }
);
loginTime.add(res.timings.duration);
const ok = res.status === 200;
errorRate.add(!ok);
check(res, {
'login: status 200': (r) => r.status === 200,
'login: has token': (r) => { try { return !!r.json('data.token'); } catch { return false; } },
});
if (ok) {
try { vuToken = res.json('data.token'); } catch {}
}
});
if (!vuToken) {
console.warn(`[VU ${__VU}] Login failed for ${student.email} — skipping`);
sleep(2);
return;
}
}
// ── Step 2 (removed): Enroll ทำผ่าน enroll-load-test.js แยกต่างหาก ─────────
// ── Step 3+4: Setup — Load course และ open lesson (ทำครั้งเดียวต่อ VU) ─────
if (!vuSetupDone) {
group('3. Load Course Learning Page', () => {
const res = http.get(
`${BASE_URL}/api/students/courses/${COURSE_ID}/learn`,
{ headers: jsonHeaders(vuToken) }
);
courseLearningTime.add(res.timings.duration);
errorRate.add(res.status !== 200);
check(res, { 'course learn: status 200': (r) => r.status === 200 });
});
sleep(1);
let videoUrl = null;
group('4. Open Lesson', () => {
const res = http.get(
`${BASE_URL}/api/students/courses/${COURSE_ID}/lessons/${LESSON_ID}`,
{ headers: jsonHeaders(vuToken) }
);
lessonLoadTime.add(res.timings.duration);
errorRate.add(res.status !== 200);
check(res, { 'lesson: status 200': (r) => r.status === 200 });
if (res.status === 200) {
try { videoUrl = res.json('data.video_url'); } catch {}
}
});
// ── Step 4.5: Fetch video จาก MinIO ──────────────────────────────────────
if (videoUrl) {
group('4.5 Fetch Video from MinIO', () => {
const res = http.get(videoUrl, {
headers: { 'Range': 'bytes=0-1048575' }, // ขอแค่ 1MB แรก
timeout: '10s',
});
videoLoadTime.add(res.timings.duration);
const ok = res.status === 200 || res.status === 206;
errorRate.add(!ok);
check(res, {
'minio video: 200 or 206': (r) => r.status === 200 || r.status === 206,
'minio video: fast': (r) => r.timings.duration < 3000,
});
});
} else {
console.log(`[VU ${__VU}] No video_url returned — skipping MinIO fetch`);
}
sleep(2); // รอ buffer เริ่มต้น
vuSetupDone = true;
}
// ── Step 5: Save Progress ทีละ tick (ต่อจากตำแหน่งเดิม) ────────────────────
// แต่ละ iteration ของ VU = ส่ง progress 1 ครั้ง แล้ว sleep ตาม interval จริง
if (!vuCompleted) {
vuProgress += PROGRESS_INTERVAL_SECONDS;
group('5. Watch Video (Save Progress)', () => {
const res = http.post(
`${BASE_URL}/api/students/lessons/${LESSON_ID}/progress`,
JSON.stringify({
video_progress_seconds: vuProgress,
video_duration_seconds: VIDEO_DURATION_SECONDS,
}),
{ headers: jsonHeaders(vuToken) }
);
progressSaveTime.add(res.timings.duration);
progressSaveCount.add(1);
const ok = res.status === 200;
errorRate.add(!ok);
check(res, {
'progress save: status 200': (r) => r.status === 200,
'progress save: fast': (r) => r.timings.duration < 500,
});
console.log(`[VU ${__VU}] progress: ${vuProgress}s / ${VIDEO_DURATION_SECONDS}s (${Math.round(vuProgress / VIDEO_DURATION_SECONDS * 100)}%)`);
});
// ── Step 6: Mark complete เมื่อดูครบ ≥95% ──────────────────────────────
if (vuProgress >= VIDEO_DURATION_SECONDS * 0.95) {
group('6. Complete Lesson', () => {
const res = http.post(
`${BASE_URL}/api/students/courses/${COURSE_ID}/lessons/${LESSON_ID}/complete`,
null,
{ headers: jsonHeaders(vuToken) }
);
completeLessonTime.add(res.timings.duration);
errorRate.add(res.status !== 200 && res.status !== 409);
if (res.status === 200) completedCount.add(1);
check(res, {
'complete: status 200 or 409': (r) => r.status === 200 || r.status === 409,
});
});
vuCompleted = true;
console.log(`[VU ${__VU}] ✓ Lesson completed`);
}
}
// sleep ตาม interval จริง — ทุก VU ส่ง progress ทุก PROGRESS_INTERVAL_SECONDS วินาที
sleep(PROGRESS_INTERVAL_SECONDS);
}
// ─── Summary ──────────────────────────────────────────────────────────────────
export function handleSummary(data) {
const m = data.metrics;
const avg = (k) => m[k]?.values?.avg?.toFixed(0) ?? 'N/A';
const p95 = (k) => m[k]?.values?.['p(95)']?.toFixed(0) ?? 'N/A';
const rate = (k) => ((m[k]?.values?.rate ?? 0) * 100).toFixed(2);
const count = (k) => m[k]?.values?.count ?? 0;
return {
stdout: `
Concurrent Video Watching Load Test
Course ID : ${COURSE_ID.padEnd(44)}
Lesson ID : ${LESSON_ID.padEnd(44)}
Video : ${String(VIDEO_DURATION_SECONDS + 's').padEnd(44)}
RESPONSE TIMES (avg / p95)
Login : ${avg('login_duration')}ms / ${p95('login_duration')}ms${' '.repeat(Math.max(0, 20 - avg('login_duration').length - p95('login_duration').length))}
Course Learning Page: ${avg('course_learning_duration')}ms / ${p95('course_learning_duration')}ms${' '.repeat(Math.max(0, 20 - avg('course_learning_duration').length - p95('course_learning_duration').length))}
Lesson Load : ${avg('lesson_load_duration')}ms / ${p95('lesson_load_duration')}ms${' '.repeat(Math.max(0, 20 - avg('lesson_load_duration').length - p95('lesson_load_duration').length))}
MinIO Video Fetch : ${avg('video_load_duration')}ms / ${p95('video_load_duration')}ms${' '.repeat(Math.max(0, 20 - avg('video_load_duration').length - p95('video_load_duration').length))}
Save Progress : ${avg('progress_save_duration')}ms / ${p95('progress_save_duration')}ms${' '.repeat(Math.max(0, 20 - avg('progress_save_duration').length - p95('progress_save_duration').length))}
Complete Lesson : ${avg('complete_lesson_duration')}ms / ${p95('complete_lesson_duration')}ms${' '.repeat(Math.max(0, 20 - avg('complete_lesson_duration').length - p95('complete_lesson_duration').length))}
COUNTS
Total Requests : ${String(count('http_reqs')).padEnd(33)}
Progress Saves : ${String(count('progress_saves')).padEnd(33)}
Lessons Completed : ${String(count('completed_lessons')).padEnd(33)}
ERROR RATES
HTTP Failed : ${(rate('http_req_failed') + '%').padEnd(33)}
Custom Errors : ${(rate('errors') + '%').padEnd(33)}
`,
};
}

View file

@ -1,27 +1,20 @@
<script setup lang="ts"> <script setup>
/** // Authentication
* @file app.vue
* @description Root application component.
* Handles initialization of authentication and theme settings.
*/
// Initialize composables
const { fetchUserProfile, isAuthenticated } = useAuth() const { fetchUserProfile, isAuthenticated } = useAuth()
const { isDark, set: setTheme } = useThemeMode()
// App initialization logic // App (Mounted)
onMounted(() => { onMounted(() => {
// 1. Fetch user profile if tokens exist // 1. Login ( Token) Profile
if (isAuthenticated.value) { if (isAuthenticated.value) {
fetchUserProfile() fetchUserProfile()
} }
// 2. Initialize theme from persistent storage or system preference // 2. Theme (Dark/Light) LocalStorage
const savedTheme = localStorage.getItem('theme') const savedTheme = localStorage.getItem('theme')
if (savedTheme) { if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
setTheme(savedTheme === 'dark') document.documentElement.classList.add('dark')
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) { } else {
setTheme(true) document.documentElement.classList.remove('dark')
} }
}) })
</script> </script>

View file

@ -27,7 +27,7 @@
/* Typography */ /* Typography */
/* Typography */ /* Typography */
--font-main: --font-main:
"Prompt", "Inter", "Sarabun", -apple-system, BlinkMacSystemFont, "Segoe UI", "Prompt", "Sarabun", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
"Roboto", "Helvetica Neue", Arial, sans-serif; "Roboto", "Helvetica Neue", Arial, sans-serif;
/* Layout */ /* Layout */
@ -634,7 +634,6 @@ ul {
} }
.font-bold { .font-bold {
font-weight: 700; font-weight: 700;
letter-spacing: normal;
} }
.w-full { .w-full {
width: 100%; width: 100%;

View file

@ -21,40 +21,15 @@ const emit = defineEmits<{
const { locale } = useI18n() const { locale } = useI18n()
// State for expansion items
const chapterOpenState = ref<Record<string, boolean>>({})
// Helper for localization // Helper for localization
const getLocalizedText = (text: any) => { const getLocalizedText = (text: any) => {
if (!text) return '' if (!text) return ''
if (typeof text === 'string') return text if (typeof text === 'string') return text
// Safe locale access const currentLocale = locale.value as 'th' | 'en'
const currentLocale = (locale?.value || 'th') as 'th' | 'en'
return text[currentLocale] || text.th || text.en || '' return text[currentLocale] || text.th || text.en || ''
} }
// Helper: Check if lesson is completed
const isLessonCompleted = (lesson: any) => {
return lesson.is_completed === true || lesson.progress?.is_completed === true
}
// Reactive Chapter Completion Status
// Computes a map of chapterId -> boolean (true if all lessons are completed)
const chapterCompletionStatus = computed(() => {
const status: Record<string, boolean> = {}
if (!props.courseData || !props.courseData.chapters) return status
props.courseData.chapters.forEach((chapter: any) => {
if (chapter.lessons && chapter.lessons.length > 0) {
status[chapter.id] = chapter.lessons.every((l: any) => isLessonCompleted(l))
} else {
status[chapter.id] = false
}
})
return status
})
// Local Progress Calculation // Local Progress Calculation
const progressPercentage = computed(() => { const progressPercentage = computed(() => {
if (!props.courseData || !props.courseData.chapters) return 0 if (!props.courseData || !props.courseData.chapters) return 0
@ -63,34 +38,11 @@ const progressPercentage = computed(() => {
props.courseData.chapters.forEach((c: any) => { props.courseData.chapters.forEach((c: any) => {
c.lessons.forEach((l: any) => { c.lessons.forEach((l: any) => {
total++ total++
if (isLessonCompleted(l)) completed++ if (l.is_completed || l.progress?.is_completed) completed++
}) })
}) })
return total > 0 ? Math.round((completed / total) * 100) : 0 return total > 0 ? Math.round((completed / total) * 100) : 0
}) })
// Auto-expand chapter containing current lesson
watch(() => props.currentLessonId, (newId) => {
if (newId && props.courseData?.chapters) {
props.courseData.chapters.forEach((chapter: any) => {
const hasLesson = chapter.lessons.some((l: any) => l.id === newId)
if (hasLesson) {
chapterOpenState.value[chapter.id] = true
}
})
}
}, { immediate: true })
// Initialize all chapters as open by default on load
watch(() => props.courseData, (newData) => {
if (newData?.chapters) {
newData.chapters.forEach((chapter: any) => {
if (chapterOpenState.value[chapter.id] === undefined) {
chapterOpenState.value[chapter.id] = true
}
})
}
}, { immediate: true })
</script> </script>
<template> <template>
@ -99,110 +51,70 @@ watch(() => props.courseData, (newData) => {
@update:model-value="(val) => emit('update:modelValue', val)" @update:model-value="(val) => emit('update:modelValue', val)"
show-if-above show-if-above
bordered bordered
side="right" side="left"
:width="300" :width="280"
:breakpoint="1024" :breakpoint="1024"
class="bg-slate-50 dark:bg-slate-900 shadow-xl" class="bg-slate-50 dark:bg-slate-900 shadow-xl"
content-class="flex flex-col h-full"
> >
<!-- Main Container: Enforce Column Layout and Full Width --> <div v-if="courseData" class="flex flex-col h-full overflow-hidden">
<div v-if="courseData" class="flex flex-col w-full h-full overflow-hidden text-slate-900 dark:text-white relative"> <!-- Course Progress Header -->
<div class="p-5 border-b border-gray-200 dark:border-white/10 bg-slate-50/50 dark:bg-slate-900/50">
<!-- 1. Header Section (Fixed at Top) --> <div class="flex justify-between items-center mb-2">
<div class="flex-none p-5 border-b border-slate-200 dark:border-white/10 bg-white dark:bg-slate-900 z-10 w-full">
<h2 class="text-sm font-bold mb-4 line-clamp-2 leading-snug block w-full">{{ getLocalizedText(courseData.course.title) }}</h2>
<div class="flex justify-between items-center mb-2 w-full">
<span class="text-xs font-black uppercase tracking-widest text-slate-500 dark:text-slate-400">{{ $t('course.progress') }}</span> <span class="text-xs font-black uppercase tracking-widest text-slate-500 dark:text-slate-400">{{ $t('course.progress') }}</span>
<span class="text-sm font-black text-blue-600 dark:text-blue-400">{{ progressPercentage }}%</span> <span class="text-sm font-black text-blue-600 dark:text-blue-400">{{ progressPercentage }}%</span>
</div> </div>
<div class="h-2 w-full bg-slate-100 dark:bg-slate-800 rounded-full overflow-hidden"> <div class="h-2 w-full bg-slate-200 dark:bg-slate-800 rounded-full overflow-hidden shadow-inner">
<div <div
class="h-full bg-blue-600 dark:bg-blue-500 rounded-full transition-all duration-700 ease-out" class="h-full bg-blue-600 dark:bg-blue-500 rounded-full transition-all duration-700 ease-out shadow-[0_0_12px_rgba(37,99,235,0.3)]"
:style="{ width: `${progressPercentage}%` }" :style="{ width: `${progressPercentage}%` }"
></div> ></div>
</div> </div>
</div> </div>
<!-- 2. Curriculum List (Scrollable Area) --> <div class="flex-grow scroll">
<div class="flex-1 overflow-y-auto bg-slate-50 dark:bg-[#0f1219] w-full p-4 space-y-3"> <q-list padding class="py-2">
<q-list class="block w-full">
<div v-for="(chapter, idx) in courseData.chapters" :key="chapter.id" class="block w-full mb-3">
<!-- Chapter Accordion -->
<q-expansion-item
v-model="chapterOpenState[chapter.id]"
class="bg-white dark:bg-[#1a1e29] rounded-xl overflow-hidden shadow-sm border border-slate-200 dark:border-slate-800 w-full"
header-class="rounded-t-xl w-full"
expand-icon-class="text-slate-400"
>
<template v-slot:header>
<div class="flex items-center w-full py-3 text-slate-900 dark:text-white">
<div class="mr-3 flex-shrink-0">
<!-- Chapter Indicator (Check or Number) -->
<div class="w-7 h-7 rounded-full border-2 flex items-center justify-center transition-colors font-bold"
:class="chapterCompletionStatus[chapter.id]
? 'border-green-500 text-green-500 bg-green-50 dark:bg-green-500/10'
: 'border-slate-300 dark:border-slate-600 text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-slate-800'">
<q-icon v-if="chapterCompletionStatus[chapter.id]" name="check" size="14px" class="font-bold" />
<span v-else class="text-[10px]">{{ Number(idx) + 1 }}</span>
</div>
</div>
<!-- Explicitly handle text overflow -->
<div class="flex-1 min-w-0 pr-2 overflow-hidden">
<div class="font-bold text-sm leading-tight mb-0.5 truncate block w-full">{{ getLocalizedText(chapter.title) }}</div>
<div class="text-[10px] text-slate-500 dark:text-slate-400 font-normal truncate block w-full">
{{ chapter.lessons.length }} {{ $t('course.lessonsUnit') }}
</div>
</div>
</div>
</template>
<!-- Lessons List -->
<div class="bg-slate-50 dark:bg-[#0f1219]/50 border-t border-slate-100 dark:border-slate-800 w-full"> <template v-for="chapter in courseData.chapters" :key="chapter.id">
<div <q-item-label header class="bg-slate-100 dark:bg-slate-800 text-[var(--text-main)] font-bold sticky top-0 z-10 border-b dark:border-white/5 text-sm py-4">
v-for="(lesson, lIdx) in chapter.lessons" {{ getLocalizedText(chapter.title) }}
</q-item-label>
<q-item
v-for="lesson in chapter.lessons"
:key="lesson.id" :key="lesson.id"
class="flex items-center px-4 py-3 cursor-pointer transition-all border-l-4 hover:bg-slate-100 dark:hover:bg-slate-800/50 w-full" clickable
:class="currentLessonId === lesson.id v-ripple
? 'border-blue-600 bg-blue-50 dark:bg-blue-900/10' :active="currentLessonId === lesson.id"
: 'border-transparent'" active-class="bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 active-lesson-indicator"
class="px-5 py-3 transition-all duration-200 group relative border-b border-gray-100/50 dark:border-white/5"
@click="!lesson.is_locked && emit('select-lesson', lesson.id)" @click="!lesson.is_locked && emit('select-lesson', lesson.id)"
:disable="lesson.is_locked"
> >
<!-- Lesson Status Icon --> <q-item-section avatar v-if="lesson.is_locked">
<div class="mr-3 flex-shrink-0"> <q-icon name="lock" size="xs" color="grey" />
<!-- Completed (Takes Precedence) --> </q-item-section>
<q-icon v-if="isLessonCompleted(lesson)"
name="check_circle"
class="text-green-500"
size="20px"
/>
<!-- Active/Playing (If not completed) -->
<q-icon v-else-if="currentLessonId === lesson.id"
name="play_circle_filled"
class="text-blue-600 dark:text-blue-400 animate-pulse"
size="20px"
/>
<!-- Locked -->
<q-icon v-else-if="lesson.is_locked"
name="lock"
class="text-slate-400 opacity-70"
size="18px"
/>
<!-- Not Started -->
<div v-else class="w-[18px] h-[18px] rounded-full border-2 border-slate-300 dark:border-slate-600"></div>
</div>
<div class="flex-1 min-w-0 overflow-hidden"> <q-item-section>
<div class="text-xs font-bold truncate leading-snug block w-full" <q-item-label
:class="currentLessonId === lesson.id ? 'text-blue-700 dark:text-blue-300' : 'text-slate-600 dark:text-slate-300'" class="text-sm font-bold line-clamp-2 transition-colors"
:class="currentLessonId === lesson.id ? 'text-blue-700 dark:text-blue-300' : 'text-slate-700 dark:text-slate-300'"
> >
{{ getLocalizedText(lesson.title) }} {{ getLocalizedText(lesson.title) }}
</div> </q-item-label>
</q-item-section>
<q-item-section side>
<div class="flex items-center">
<q-icon v-if="lesson.is_completed || lesson.progress?.is_completed" name="check_circle" color="positive" size="18px" />
<q-icon v-else-if="currentLessonId === lesson.id" name="play_arrow" color="primary" size="18px" class="animate-pulse" />
<q-icon v-else-if="lesson.is_locked" name="lock" color="grey-4" size="18px" />
<q-icon v-else name="radio_button_unchecked" color="grey-3" size="18px" />
</div> </div>
</div> </q-item-section>
</div> </q-item>
</q-expansion-item> </template>
</div>
</q-list> </q-list>
</div> </div>
</div> </div>
@ -214,18 +126,31 @@ watch(() => props.courseData, (newData) => {
</template> </template>
<style scoped> <style scoped>
/* Custom scrollbar for better aesthetics */ .active-lesson-indicator {
::-webkit-scrollbar { position: relative;
}
.active-lesson-indicator::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: #2563eb; /* blue-600 */
border-radius: 0 4px 4px 0;
}
.scroll::-webkit-scrollbar {
width: 4px; width: 4px;
} }
::-webkit-scrollbar-track { .scroll::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
::-webkit-scrollbar-thumb { .scroll::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1); background: rgba(0, 0, 0, 0.05);
border-radius: 4px; border-radius: 10px;
} }
.dark ::-webkit-scrollbar-thumb { .dark .scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.05);
} }
</style> </style>

View file

@ -25,8 +25,6 @@ interface CourseCardProps {
showContinue?: boolean showContinue?: boolean
showCertificate?: boolean showCertificate?: boolean
showStudyAgain?: boolean showStudyAgain?: boolean
hideProgress?: boolean
hideActions?: boolean
} }
const props = withDefaults(defineProps<CourseCardProps>(), { const props = withDefaults(defineProps<CourseCardProps>(), {
@ -57,7 +55,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</script> </script>
<template> <template>
<div class="group relative flex flex-col bg-white dark:!bg-slate-900 rounded-3xl overflow-hidden border border-slate-200 dark:border-white/5 shadow-sm hover:shadow-xl dark:shadow-none hover:-translate-y-1 transition-all duration-300 h-full"> <div class="group relative flex flex-col bg-white dark:!bg-[#0f172a] rounded-3xl overflow-hidden border border-slate-200 dark:border-slate-800 shadow-sm hover:shadow-xl dark:shadow-none hover:-translate-y-1 transition-all duration-300 h-full">
<!-- Thumbnail Section --> <!-- Thumbnail Section -->
<div class="relative w-full aspect-video overflow-hidden"> <div class="relative w-full aspect-video overflow-hidden">
@ -108,7 +106,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))
<div class="mt-auto pt-4"> <div class="mt-auto pt-4">
<!-- Progress Bar --> <!-- Progress Bar -->
<div v-if="progress !== undefined && !completed && !hideProgress" class="mb-4"> <div v-if="progress !== undefined && !completed" class="mb-4">
<div class="flex justify-between text-[10px] font-bold uppercase mb-1"> <div class="flex justify-between text-[10px] font-bold uppercase mb-1">
<span class="text-slate-500 dark:text-slate-400">{{ $t('course.progress') }}</span> <span class="text-slate-500 dark:text-slate-400">{{ $t('course.progress') }}</span>
<span class="text-blue-600 dark:text-blue-400">{{ progress }}%</span> <span class="text-blue-600 dark:text-blue-400">{{ progress }}%</span>
@ -119,13 +117,12 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</div> </div>
<!-- Action Buttons --> <!-- Action Buttons -->
<div v-if="!hideActions" class="flex flex-col gap-3">
<!-- View Details (Secondary Action) --> <!-- View Details (Secondary Action) -->
<q-btn <q-btn
v-if="showViewDetails && !completed && !progress" v-if="showViewDetails && !completed && !progress"
flat flat
rounded rounded
class="w-full font-bold !text-blue-600 !bg-blue-50 hover:!bg-blue-100 dark:!bg-blue-500/10 dark:!text-blue-400 dark:hover:!bg-blue-500/20" class="w-full font-bold !text-blue-600 !bg-blue-50 hover:!bg-blue-100 dark:!bg-blue-900/40 dark:!text-blue-300 dark:hover:!bg-blue-900/60"
:label="$t('menu.viewDetails')" :label="$t('menu.viewDetails')"
:to="`/course/${id}`" :to="`/course/${id}`"
/> />
@ -139,7 +136,6 @@ const displayCategory = computed(() => getLocalizedText(props.category))
:label="(!progress || progress === 0) ? $t('course.startLearning') : $t('course.continueLearning')" :label="(!progress || progress === 0) ? $t('course.startLearning') : $t('course.continueLearning')"
:to="`/classroom/learning?course_id=${id}`" :to="`/classroom/learning?course_id=${id}`"
/> />
</div>
<div v-if="completed" class="space-y-2"> <div v-if="completed" class="space-y-2">
<!-- Study Again --> <!-- Study Again -->

View file

@ -33,19 +33,6 @@ const formatPrice = (price: number) => {
} }
const enrollmentLoading = ref(false); const enrollmentLoading = ref(false);
const activeTab = ref('curriculum');
const totalLessons = computed(() => {
if (!props.course?.chapters) return 0;
return props.course.chapters.reduce((acc: number, chapter: any) => acc + (chapter.lessons?.length || 0), 0);
});
const totalDuration = computed(() => {
if (!props.course?.chapters) return 0;
return props.course.chapters.reduce((acc: number, chapter: any) => {
return acc + (chapter.lessons?.reduce((lAcc: number, lesson: any) => lAcc + (lesson.duration_minutes || 0), 0) || 0);
}, 0);
});
const handleEnroll = () => { const handleEnroll = () => {
if(!props.course) return; if(!props.course) return;
@ -55,13 +42,7 @@ const handleEnroll = () => {
// In this pattern, we just emit. // In this pattern, we just emit.
setTimeout(() => enrollmentLoading.value = false, 2000); // Safety timeout setTimeout(() => enrollmentLoading.value = false, 2000); // Safety timeout
}; };
const instructorData = computed(() => {
if (props.course?.instructors && props.course.instructors.length > 0) {
const primary = props.course.instructors.find((i: any) => i.is_primary);
return primary ? primary.user : props.course.instructors[0].user;
}
return props.course?.creator || null;
});
</script> </script>
<template> <template>
@ -123,37 +104,19 @@ const instructorData = computed(() => {
</div> </div>
</div> </div>
<!-- Course Detail - Single Page Layout --> <!-- Curriculum Preview -->
<div class="space-y-10"> <div class="bg-slate-50 dark:bg-slate-900 rounded-3xl p-6 md:p-8 border border-slate-200 dark:border-white/5">
<div class="flex items-center justify-between mb-8">
<!-- Instructor Info -->
<div class="flex flex-col sm:flex-row gap-6 items-start sm:items-center pb-8 border-b border-slate-200 dark:border-slate-800">
<q-avatar size="64px">
<img :src="instructorData?.profile?.avatar_url || 'https://cdn.quasar.dev/img/boy-avatar.png'" />
</q-avatar>
<div> <div>
<div class="text-sm text-slate-500 mb-1 font-bold uppercase tracking-wider">{{ $t('course.instructor') }}</div> <h3 class="text-xl font-black text-slate-900 dark:text-white mb-1 flex items-center gap-2">
<div class="font-bold text-xl text-slate-800 dark:text-white">
{{ instructorData?.profile?.first_name || 'Unknown' }} {{ instructorData?.profile?.last_name || 'Instructor' }}
</div>
<div class="text-slate-500 text-sm mt-1">{{ instructorData?.email || 'No contact info' }}</div>
</div>
</div>
<!-- Curriculum / Lesson Details -->
<div>
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-bold text-slate-900 dark:text-white">
{{ $t('course.courseContent') }} {{ $t('course.courseContent') }}
</h3> </h3>
<div class="text-sm font-bold text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-white/5 px-4 py-2 rounded-full">
{{ totalLessons }} {{ $t('course.lessons') }} {{ totalDuration }} {{ $t('quiz.minutes') }}
</div> </div>
<q-icon name="keyboard_command_key" class="text-slate-200 dark:text-slate-800" size="32px" />
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
<div v-for="(chapter, idx) in course.chapters" :key="chapter.id" class="group"> <div v-for="(chapter, idx) in course.chapters" :key="chapter.id" class="group">
<!-- Chapter Header -->
<div class="px-6 py-4 bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-white/5 font-black text-slate-800 dark:text-white flex justify-between items-center mb-2 shadow-sm"> <div class="px-6 py-4 bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-white/5 font-black text-slate-800 dark:text-white flex justify-between items-center mb-2 shadow-sm">
<span class="flex items-center gap-3"> <span class="flex items-center gap-3">
<span class="w-7 h-7 flex items-center justify-center bg-slate-100 dark:bg-white/10 rounded-lg text-xs font-bold font-mono">{{ Number(idx) + 1 }}</span> <span class="w-7 h-7 flex items-center justify-center bg-slate-100 dark:bg-white/10 rounded-lg text-xs font-bold font-mono">{{ Number(idx) + 1 }}</span>
@ -161,24 +124,20 @@ const instructorData = computed(() => {
</span> </span>
<span class="text-[10px] uppercase font-black tracking-widest text-slate-400 opacity-60">{{ chapter.lessons?.length || 0 }} {{ $t('course.lessonsUnit') }}</span> <span class="text-[10px] uppercase font-black tracking-widest text-slate-400 opacity-60">{{ chapter.lessons?.length || 0 }} {{ $t('course.lessonsUnit') }}</span>
</div> </div>
<!-- Lessons List -->
<div class="ml-4 pl-4 border-l-2 border-slate-100 dark:border-slate-800 space-y-1 mt-3"> <div class="ml-4 pl-4 border-l-2 border-slate-100 dark:border-slate-800 space-y-1 mt-3">
<div v-for="lesson in chapter.lessons" :key="lesson.id" class="px-5 py-3 flex items-center gap-3 text-sm text-slate-600 dark:text-slate-400 hover:bg-white dark:hover:bg-white/5 rounded-xl transition-all hover:translate-x-1"> <div v-for="lesson in chapter.lessons" :key="lesson.id" class="px-5 py-3 flex items-center gap-3 text-sm text-slate-600 dark:text-slate-400 hover:bg-white dark:hover:bg-white/5 rounded-xl transition-all hover:translate-x-1">
<div class="w-8 h-8 rounded-full flex items-center justify-center shrink-0" :class="lesson.type === 'VIDEO' ? 'bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400' : 'bg-orange-50 text-orange-600 dark:bg-orange-500/10 dark:text-orange-400'"> <div class="w-8 h-8 rounded-full flex items-center justify-center" :class="lesson.type === 'VIDEO' ? 'bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400' : 'bg-orange-50 text-orange-600 dark:bg-orange-500/10 dark:text-orange-400'">
<q-icon <q-icon
:name="lesson.type === 'VIDEO' ? 'play_arrow' : 'article'" :name="lesson.type === 'VIDEO' ? 'play_arrow' : 'article'"
size="16px" size="16px"
/> />
</div> </div>
<span class="flex-1 font-bold truncate">{{ getLocalizedText(lesson.title) }}</span> <span class="flex-1 font-bold">{{ getLocalizedText(lesson.title) }}</span>
<span v-if="lesson.duration_minutes" class="text-slate-400 dark:text-slate-500 text-[10px] font-bold shrink-0">{{ lesson.duration_minutes }} {{ $t('quiz.minutes') }}</span> <span v-if="lesson.duration_minutes" class="text-slate-400 dark:text-slate-500 text-[10px] font-bold">{{ lesson.duration_minutes }} {{ $t('quiz.minutes') }}</span>
<q-icon v-if="lesson.is_locked !== false" name="lock" size="14px" class="text-slate-300 dark:text-slate-600 shrink-0" /> <q-icon v-if="lesson.is_locked !== false" name="lock" size="14px" class="text-slate-300 dark:text-slate-600" />
</div> </div>
</div> </div>
</div> </div>
<!-- Empty State -->
<div v-if="!course.chapters || course.chapters.length === 0" class="flex flex-col items-center justify-center py-12 text-slate-400 dark:text-slate-500 bg-white/50 dark:bg-slate-900/50 rounded-2xl border-2 border-dashed border-slate-200 dark:border-slate-800"> <div v-if="!course.chapters || course.chapters.length === 0" class="flex flex-col items-center justify-center py-12 text-slate-400 dark:text-slate-500 bg-white/50 dark:bg-slate-900/50 rounded-2xl border-2 border-dashed border-slate-200 dark:border-slate-800">
<q-icon name="menu_book" size="40px" class="mb-2 opacity-50" /> <q-icon name="menu_book" size="40px" class="mb-2 opacity-50" />
<p class="text-sm font-medium">{{ $t('course.noContent') }}</p> <p class="text-sm font-medium">{{ $t('course.noContent') }}</p>
@ -188,8 +147,6 @@ const instructorData = computed(() => {
</div> </div>
</div>
<!-- Right: Enrollment Card --> <!-- Right: Enrollment Card -->
<div class="lg:col-span-1"> <div class="lg:col-span-1">
<div class="sticky top-24"> <div class="sticky top-24">

View file

@ -5,164 +5,80 @@
* Uses Quasar QToolbar. * Uses Quasar QToolbar.
*/ */
import { ref, computed } from "vue"; defineProps<{
/** Controls visibility of the search bar */
const props = defineProps<{ showSearch?: boolean
/** Controls visibility of the sidebar toggle button */ }>()
showSidebarToggle?: boolean;
/** Type of navigation links to display */
navType?: "public" | "learner";
}>();
const emit = defineEmits<{ const emit = defineEmits<{
/** Emitted when the hamburger menu is clicked */ /** Emitted when the hamburger menu is clicked */
toggleSidebar: []; toggleSidebar: []
/** Emitted when the mobile menu toggle is clicked */ }>()
toggleRightDrawer: [];
}>();
const { t } = useI18n();
const route = useRoute();
// Automatically determine navType based on route if not explicitly passed
const navTypeComputed = computed(() => {
if (props.navType) return props.navType;
// Show learner nav for dashboard, browse, classroom, and course details
const learnerRoutes = ["/dashboard", "/browse", "/classroom", "/course"];
return learnerRoutes.some((r) => route.path.startsWith(r))
? "learner"
: "public";
});
const searchText = ref('')
</script> </script>
<template> <template>
<q-toolbar class="bg-white dark:!bg-[#0f172a] text-slate-900 dark:!text-white h-16 border-none p-0 overflow-visible"> <q-toolbar class="bg-transparent text-slate-800 dark:text-white h-16 px-4">
<div class="w-full px-4 md:px-12 flex items-center h-full no-wrap relative"> <!-- Menu Toggle (Always Visible) -->
<!-- Mobile Sidebar Toggle (For non-learner routes) -->
<q-btn <q-btn
v-if="showSidebarToggle !== false && navTypeComputed !== 'learner' && $q.screen.lt.md"
flat flat
round round
dense dense
icon="menu" icon="menu"
class="mr-2 text-gray-500" @click="emit('toggleSidebar')"
@click="$emit('toggleSidebar')" class="mr-2 text-slate-900 dark:text-white bg-slate-100 dark:bg-slate-700/50 hover:bg-slate-200 dark:hover:bg-slate-600"
aria-label="Menu"
/> />
<!-- Branding: Logo + Name --> <!-- Branding -->
<div <div class="flex items-center gap-3 cursor-pointer group" @click="navigateTo('/dashboard')">
class="flex items-center gap-3 cursor-pointer group flex-shrink-0" <div class="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center text-white font-black shadow-lg shadow-blue-600/30 group-hover:scale-110 transition-transform">
@click="navigateTo('/dashboard')"
>
<div class="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center text-white font-black shadow-lg shadow-blue-600/30 group-hover:scale-110 transition-transform flex-shrink-0">
E E
</div> </div>
<div class="flex flex-col text-left"> <div class="flex flex-col">
<span class="font-black text-[15px] md:text-lg leading-none tracking-tight text-slate-900 dark:text-white group-hover:text-blue-600 transition-colors">E-Learning</span> <span class="font-black text-lg leading-none tracking-tight text-slate-900 dark:text-white group-hover:text-blue-600 transition-colors">E-Learning</span>
<span class="text-[9px] md:text-[10px] font-bold uppercase tracking-[0.2em] leading-none mt-1 text-slate-500">Platform</span> <span class="text-[10px] font-bold uppercase tracking-[0.2em] leading-none mt-1 text-slate-500 dark:text-slate-400">Platform</span>
</div> </div>
</div> </div>
<!-- Desktop Navigation -->
<nav class="header-desktop items-center gap-6 lg:gap-8 text-[14px] font-bold ml-12 flex-shrink-0 h-full">
<NuxtLink to="/dashboard" class="nav-link" exact-active-class="active">{{ $t("sidebar.overview") }}</NuxtLink>
<NuxtLink to="/browse/discovery" class="nav-link" active-class="active">{{ $t("landing.allCourses") }}</NuxtLink>
<NuxtLink to="/dashboard/my-courses" class="nav-link" exact-active-class="active">{{ $t("sidebar.myCourses") || 'คอร์สเรียนของฉัน' }}</NuxtLink>
</nav>
<q-space /> <q-space />
<!-- Right Section: Tools --> <!-- Center Search (Optional) -->
<div class="flex items-center gap-2 flex-shrink-0 no-wrap"> <div v-if="showSearch !== false" class="hidden md:block w-1/3 max-w-md mx-4">
<q-input
<!-- Desktop Only Tools -->
<div class="header-desktop items-center gap-4 flex-shrink-0">
<LanguageSwitcher />
<UserMenu />
</div>
<!-- Mobile/Tablet Tools Hidden (Moved to Drawer) -->
<!-- Just keep space between logo and hamburger -->
<!-- Mobile/Tablet Hamburger -->
<q-btn
flat
round
dense dense
icon="menu" outlined
class="header-mobile text-slate-700 dark:text-white bg-slate-100 dark:bg-slate-800 flex-shrink-0" rounded
style="width: 40px; height: 40px; min-width: 40px;" v-model="searchText"
@click="$emit('toggleRightDrawer')" :placeholder="$t('menu.searchCourses')"
/> class="bg-slate-50 dark:bg-slate-700/50 search-input"
bg-color="transparent"
>
<template v-slot:prepend>
<q-icon name="search" class="text-slate-400" />
</template>
</q-input>
</div> </div>
<q-space />
<!-- Right Actions -->
<div class="flex items-center gap-2">
<!-- Language Switcher -->
<LanguageSwitcher />
<!-- User Profile Dropdown -->
<UserMenu />
</div> </div>
</q-toolbar> </q-toolbar>
</template> </template>
<style scoped> <style scoped>
/* High Priority Visibility Logic */ .search-input :deep(.q-field__control) {
@media (max-width: 1023px) { border-radius: 9999px; /* Full rounded */
.header-desktop {
display: none !important;
} }
.header-mobile { .search-input :deep(.q-field__control:before) {
display: flex !important; border-color: #e2e8f0; /* slate-200 */
}
}
@media (min-width: 1024px) {
.header-mobile {
display: none !important;
}
.header-desktop {
display: flex !important;
}
}
.nav-link {
color: #64748b; /* slate-500 */
text-decoration: none;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
position: relative;
height: 100%;
display: flex;
align-items: center;
padding: 0 4px;
}
.nav-link:hover {
color: #2563eb; /* blue-600 */
}
.nav-link.active {
color: #2563eb; /* blue-600 */
}
.nav-link::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
width: 0;
height: 3px;
background-color: #2563eb;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
transition: all 0.3s ease;
transform: translateX(-50%);
}
.nav-link.active::after {
width: 100%;
}
.router-link-active {
color: #2563eb !important;
} }
</style> </style>

View file

@ -5,10 +5,28 @@
* Uses Quasar QList for structure. * Uses Quasar QList for structure.
*/ */
const { sidebarItems } = useNavItems()
const { t } = useI18n() const { t } = useI18n()
const navItems = sidebarItems
const navItems = computed(() => [
{
to: "/dashboard",
label: t('sidebar.overview'),
icon: "dashboard", // Using Material Icons names where possible or SVG paths
isSvg: false
},
{
to: "/browse/discovery",
label: t('sidebar.browseCourses'),
icon: "explore",
isSvg: false
},
{
to: "/dashboard/my-courses",
label: t('sidebar.myCourses'),
icon: "school",
isSvg: false
}
]);
const handleNavigate = (path: string) => { const handleNavigate = (path: string) => {
if (import.meta.client) { if (import.meta.client) {
@ -37,7 +55,7 @@ const handleNavigate = (path: string) => {
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label class="font-bold text-sm">{{ $t(item.labelKey) }}</q-item-label> <q-item-label class="font-bold text-sm">{{ item.label }}</q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list> </q-list>

View file

@ -1,98 +0,0 @@
<script setup lang="ts">
/**
* @file LandingFooter.vue
* @description Footer component for the landing page - Adjusted to Image 2 (E-Learning Platform Branding)
*/
</script>
<template>
<footer class="bg-slate-50 pt-16 pb-8 border-t border-slate-200">
<div class="container mx-auto px-6 md:px-12">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8 mb-12 text-left">
<!-- Brand -->
<div class="space-y-6">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-xl bg-blue-600 flex items-center justify-center text-white font-black shadow-lg shadow-blue-600/30 shrink-0">
E
</div>
<div class="flex flex-col">
<span class="font-black text-lg leading-none tracking-tight text-slate-900">E-Learning</span>
<span class="text-[10px] font-bold uppercase tracking-[0.2em] leading-none mt-1 text-slate-500">Platform</span>
</div>
</div>
<p class="text-slate-500 text-sm leading-relaxed max-w-xs">
แพลตฟอรมการเรยนรออนไลนงเนนการพฒนาทกษะดลสำหรบคนรนใหม เรยนรไดกท กเวลา บผเชยวชาญตวจร
</p>
</div>
<!-- Links -->
<div class="lg:pl-8">
<h4 class="font-bold text-slate-900 mb-6 text-base">คอรสเรยน</h4>
<ul class="space-y-3 text-sm text-slate-500">
<li><NuxtLink to="/browse" class="hover:text-blue-600 transition-colors inline-block">คอรสทงหมด</NuxtLink></li>
<li><NuxtLink to="/browse/recommended" class="hover:text-blue-600 transition-colors inline-block">คอรสแนะนำ</NuxtLink></li>
<li><a href="#" class="hover:text-blue-600 transition-colors inline-block">โปรโมช</a></li>
<li><a href="#" class="hover:text-blue-600 transition-colors inline-block">สำหรบองคกร</a></li>
</ul>
</div>
<!-- Support -->
<div>
<h4 class="font-bold text-slate-900 mb-6 text-base">วยเหล</h4>
<ul class="space-y-3 text-sm text-slate-500">
<li><a href="#" class="hover:text-blue-600 transition-colors inline-block">คำถามทพบบอย (FAQ)</a></li>
<li><a href="#" class="hover:text-blue-600 transition-colors inline-block">การใชงาน</a></li>
<li><a href="#" class="hover:text-blue-600 transition-colors inline-block">เงอนไขการใชงาน</a></li>
<li><a href="#" class="hover:text-blue-600 transition-colors inline-block">นโยบายความเปนสวนต</a></li>
</ul>
</div>
<!-- Contact (Bronco Hourse Data) -->
<div class="space-y-6">
<h4 class="font-bold text-slate-900 text-base">ดตอเรา</h4>
<div class="flex flex-col gap-5">
<!-- Location -->
<div class="flex flex-row items-start gap-4 flex-nowrap">
<div class="w-10 h-10 rounded-xl bg-blue-50 flex items-center justify-center shrink-0 text-blue-600 shadow-sm">
<q-icon name="location_on" size="20px" />
</div>
<div class="flex flex-col gap-1 min-w-0">
<span class="font-bold text-slate-900 text-sm leading-tight pt-1">Bronco Hourse</span>
<p class="text-slate-500 text-[11px] leading-relaxed">
74/2 Wiang Kaew Road, Tambon Si Phum, Amphoe Mueang Chiang Mai, Chang Wat Chiang Mai 50200
</p>
</div>
</div>
<!-- Phone -->
<div class="flex flex-row items-center gap-4 flex-nowrap">
<div class="w-10 h-10 rounded-xl bg-blue-50 flex items-center justify-center shrink-0 text-blue-600 shadow-sm">
<q-icon name="phone" size="18px" />
</div>
<a href="tel:052-076-025" class="text-slate-600 hover:text-blue-600 font-semibold text-sm transition-colors truncate">
052-076-025
</a>
</div>
<!-- Email -->
<div class="flex flex-row items-center gap-4 flex-nowrap">
<div class="w-10 h-10 rounded-xl bg-blue-50 flex items-center justify-center shrink-0 text-blue-600 shadow-sm">
<q-icon name="email" size="18px" />
</div>
<a href="mailto:info@chamomind.com" class="text-slate-600 hover:text-blue-600 font-semibold text-sm transition-colors truncate">
info@chamomind.com
</a>
</div>
</div>
</div>
</div>
<!-- Bottom Bar (Centered Copyright) -->
<div class="pt-8 border-t border-slate-200 text-center">
<p class="text-sm text-slate-400 font-medium tracking-wide">
Copyright © CHAMOMIND CO., LTD. 2023
</p>
</div>
</div>
</footer>
</template>

View file

@ -3,35 +3,17 @@
* @file LandingHeader.vue * @file LandingHeader.vue
* @description The main header for the public landing pages. * @description The main header for the public landing pages.
* Features a transparent background that becomes solid/glass upon scrolling. * Features a transparent background that becomes solid/glass upon scrolling.
* Responsive: Collapses into a drawer on mobile (md breakpoint).
*/ */
// Track scrolling state to adjust header styling // Track scrolling state to adjust header styling
const isScrolled = ref(false) const isScrolled = ref(false)
const { isAuthenticated } = useAuth() const { isAuthenticated } = useAuth()
// Mobile Drawer State
const mobileMenuOpen = ref(false)
const handleScroll = () => {
isScrolled.value = window.scrollY > 20
}
// Lock body scroll when mobile menu is open
watch(mobileMenuOpen, (isOpen) => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
})
onMounted(() => { onMounted(() => {
window.addEventListener('scroll', handleScroll) // Add scroll listener to toggle 'isScrolled' class
window.addEventListener('scroll', () => {
isScrolled.value = window.scrollY > 20
}) })
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
document.body.style.overflow = '' // Cleanup
}) })
</script> </script>
@ -41,13 +23,17 @@ onUnmounted(() => {
- Transitions between transparent and glass effect based on scroll. - Transitions between transparent and glass effect based on scroll.
--> -->
<header <header
class="fixed top-0 left-0 right-0 z-[100] transition-all duration-300" class="landing-header transition-all duration-300"
:class="[isScrolled ? 'h-16 glass-nav shadow-lg' : 'h-24 bg-transparent']" :class="[isScrolled ? 'h-16 glass-nav shadow-lg' : 'h-24 bg-transparent']"
> >
<div class="container mx-auto px-6 md:px-12 h-full flex items-center justify-start"> <div class="container h-full flex items-center justify-between">
<!-- Left Section: Logo --> <!--
Left Section: Logo & Desktop Navigation
-->
<div class="flex items-center gap-12">
<!-- Logo -->
<NuxtLink to="/" class="flex items-center gap-3 group"> <NuxtLink to="/" class="flex items-center gap-3 group">
<div class="bg-blue-600 text-white font-black rounded-xl w-10 h-10 flex items-center justify-center shadow-lg shadow-blue-600/30 group-hover:scale-110 transition-transform"> <div class="logo-box bg-blue-600 text-white font-black rounded-xl w-10 h-10 flex items-center justify-center shadow-lg shadow-blue-600/30 group-hover:scale-110 transition-transform">
E E
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
@ -66,162 +52,80 @@ onUnmounted(() => {
</div> </div>
</NuxtLink> </NuxtLink>
<!-- Desktop Navigation (Visible by default, hidden on mobile via CSS 'desktop-nav') --> <!-- Desktop Links -->
<nav class="flex desktop-nav items-center gap-8 text-sm font-bold ml-12"> <nav class="hidden md:block">
<ul class="flex items-center gap-8 text-sm font-bold">
<li>
<NuxtLink <NuxtLink
to="/browse" to="/browse"
class="transition-colors relative group py-2" class="transition-colors relative group"
:class="[isScrolled ? 'text-slate-300 hover:text-white' : 'text-slate-600 hover:text-blue-600']" :class="[isScrolled ? 'text-slate-400 hover:text-white' : 'text-slate-600 hover:text-blue-600']"
> >
{{ $t('sidebar.onlineCourses') }} {{ $t('landing.allCourses') }}
<span class="absolute bottom-0 left-0 w-0 h-0.5 bg-blue-600 transition-all group-hover:w-full"/> <span class="absolute -bottom-1 left-0 w-0 h-0.5 bg-blue-600 transition-all group-hover:w-full"/>
</NuxtLink> </NuxtLink>
</li>
<li>
<NuxtLink <NuxtLink
to="/browse/recommended" to="/browse/discovery"
class="transition-colors relative group py-2" class="transition-colors relative group"
:class="[isScrolled ? 'text-slate-300 hover:text-white' : 'text-slate-600 hover:text-blue-600']" :class="[isScrolled ? 'text-slate-400 hover:text-white' : 'text-slate-600 hover:text-blue-600']"
> >
{{ $t('sidebar.recommendedCourses') }} {{ $t('landing.discovery') }}
<span class="absolute bottom-0 left-0 w-0 h-0.5 bg-blue-600 transition-all group-hover:w-full"/> <span class="absolute -bottom-1 left-0 w-0 h-0.5 bg-blue-600 transition-all group-hover:w-full"/>
</NuxtLink> </NuxtLink>
</li>
</ul>
</nav> </nav>
</div>
<!-- Desktop Action Buttons (Visible by default, hidden on mobile via CSS 'desktop-nav') --> <!--
<div class="flex desktop-nav items-center gap-4 ml-auto"> Right Section: Action Buttons (Login/Register or Dashboard)
-->
<div class="flex items-center gap-4">
<template v-if="!isAuthenticated"> <template v-if="!isAuthenticated">
<!-- Login Button -->
<NuxtLink <NuxtLink
to="/auth/login" to="/auth/login"
class="px-6 py-2.5 rounded-xl font-bold text-sm border-2 transition-all hover:-translate-y-0.5" class="btn-secondary-premium shadow-sm"
:class="[isScrolled ? 'border-white/20 text-white hover:bg-white/10' : 'border-blue-100 text-blue-600 bg-blue-50 hover:bg-blue-100 hover:border-blue-200']" :class="[isScrolled ? 'border-white/20 text-white hover:bg-white/10' : 'bg-blue-50 border-blue-100 text-blue-600 hover:bg-blue-100 hover:border-blue-200']"
> >
{{ $t('auth.login') }} {{ $t('auth.login') }}
</NuxtLink> </NuxtLink>
<NuxtLink to="/auth/register" class="btn-primary-premium shadow-lg shadow-blue-600/20">
<!-- Register Button -->
<NuxtLink
to="/auth/register"
class="px-6 py-2.5 rounded-xl font-bold text-sm text-white bg-gradient-to-br from-blue-600 to-indigo-600 shadow-lg shadow-blue-600/20 hover:shadow-blue-600/40 hover:-translate-y-0.5 transition-all"
>
{{ $t('auth.getStarted') }} {{ $t('auth.getStarted') }}
</NuxtLink> </NuxtLink>
</template> </template>
<template v-else> <template v-else>
<NuxtLink <NuxtLink to="/dashboard" class="btn-primary-premium shadow-lg shadow-blue-600/20">
to="/dashboard"
class="px-6 py-2.5 rounded-xl font-bold text-sm text-white bg-gradient-to-br from-blue-600 to-indigo-600 shadow-lg shadow-blue-600/20 hover:shadow-blue-600/40 hover:-translate-y-0.5 transition-all"
>
{{ $t('landing.goToDashboard') }} {{ $t('landing.goToDashboard') }}
</NuxtLink> </NuxtLink>
</template> </template>
</div> </div>
<!-- Mobile Menu Button (Visible on Mobile) -->
<button
class="md:hidden mobile-toggle ml-auto relative z-[120] w-10 h-10 flex items-center justify-center rounded-full transition-colors"
:class="[isScrolled ? 'text-white hover:bg-white/10' : 'text-slate-900 hover:bg-slate-100', mobileMenuOpen ? 'text-slate-900 z-[120]' : '']"
@click="mobileMenuOpen = !mobileMenuOpen"
>
<q-icon :name="mobileMenuOpen ? 'close' : 'menu'" size="24px" />
</button>
</div> </div>
</header> </header>
<!-- Mobile Navigation Drawer (Teleported to body to avoid z-index/clipping issues with Header) -->
<Teleport to="body">
<div v-if="mobileMenuOpen">
<div
class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm z-[2000] transition-opacity duration-300 md:hidden"
:class="[mobileMenuOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none']"
@click="mobileMenuOpen = false"
/>
<div
class="fixed top-0 right-0 h-full w-4/5 max-w-sm bg-white shadow-2xl z-[2001] transform transition-transform duration-300 ease-out md:hidden flex flex-col"
:class="[mobileMenuOpen ? 'translate-x-0' : 'translate-x-full']"
>
<div class="p-6 pt-8 flex flex-col gap-6 h-full overflow-y-auto relative">
<!-- Close Button -->
<button
class="absolute top-6 right-6 w-8 h-8 flex items-center justify-center rounded-full bg-slate-100 text-slate-500 hover:bg-slate-200 transition-colors"
@click="mobileMenuOpen = false"
>
<q-icon name="close" size="20px" />
</button>
<!-- Mobile Links -->
<nav class="flex flex-col gap-2 mt-8">
<NuxtLink
to="/"
class="flex items-center justify-between p-4 rounded-xl hover:bg-slate-50 text-slate-700 font-bold transition-colors"
@click="mobileMenuOpen = false"
>
{{ $t('sidebar.overview') }}
<q-icon name="chevron_right" size="20px" class="text-slate-400" />
</NuxtLink>
<NuxtLink
to="/browse"
class="flex items-center justify-between p-4 rounded-xl hover:bg-slate-50 text-slate-700 font-bold transition-colors"
@click="mobileMenuOpen = false"
>
{{ $t('sidebar.onlineCourses') }}
<q-icon name="chevron_right" size="20px" class="text-slate-400" />
</NuxtLink>
<NuxtLink
to="/browse/recommended"
class="flex items-center justify-between p-4 rounded-xl hover:bg-slate-50 text-slate-700 font-bold transition-colors"
@click="mobileMenuOpen = false"
>
{{ $t('sidebar.recommendedCourses') }}
<q-icon name="chevron_right" size="20px" class="text-slate-400" />
</NuxtLink>
</nav>
<div class="mt-auto pb-8 border-t border-slate-100 pt-8">
<template v-if="!isAuthenticated">
<div class="flex flex-col gap-4">
<NuxtLink
to="/auth/login"
class="w-full py-3 rounded-xl font-bold text-center border-2 border-slate-200 text-slate-700 hover:bg-slate-50 transition-colors"
@click="mobileMenuOpen = false"
>
{{ $t('auth.login') }}
</NuxtLink>
<NuxtLink
to="/auth/register"
class="w-full py-3 rounded-xl font-bold text-center text-white bg-blue-600 shadow-lg hover:bg-blue-700 transition-colors"
@click="mobileMenuOpen = false"
>
{{ $t('auth.getStarted') }}
</NuxtLink>
</div>
</template>
<template v-else>
<NuxtLink
to="/dashboard"
class="flex items-center justify-center w-full py-3 rounded-xl font-bold text-white bg-gradient-to-r from-blue-600 to-indigo-600 shadow-lg shadow-blue-500/30"
@click="mobileMenuOpen = false"
>
{{ $t('landing.goToDashboard') }} <q-icon name="arrow_forward" class="ml-2" />
</NuxtLink>
</template>
</div>
</div>
</div>
</div>
</Teleport>
</template> </template>
<style scoped> <style scoped>
/* Header content */
.landing-header {
width: 100%;
z-index: 100;
transition: all 0.3s ease;
}
/* Glassmorphism Effect for Scrolled Header */ /* Glassmorphism Effect for Scrolled Header */
.glass-nav { .glass-nav {
background: rgba(15, 23, 42, 0.95); /* Darker background for legibility */ background: rgba(15, 23, 42, 0.8);
backdrop-filter: blur(16px); backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255, 255, 255, 0.05); border-bottom: 1px solid rgba(255, 255, 255, 0.05);
} }
.container {
max-width: 1440px;
margin: 0 auto;
padding: 0 24px;
}
/* Premium Primary Button Styling */ /* Premium Primary Button Styling */
.btn-primary-premium { .btn-primary-premium {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
@ -263,21 +167,4 @@ onUnmounted(() => {
padding: 0 16px; padding: 0 16px;
} }
} }
/*
Force Visibility Logic to bypass potential Tailwind Build issues
Ensures Desktop and Mobile parts are strictly separated
*/
.desktop-nav {
display: flex; /* Default to visible */
}
@media (max-width: 767.98px) {
.desktop-nav {
display: none !important;
}
.mobile-toggle {
display: flex !important;
}
}
</style> </style>

View file

@ -1,6 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
const { mobileItems } = useNavItems() const { t } = useI18n()
const navItems = mobileItems
const navItems = computed(() => [
{ to: '/dashboard', icon: 'dashboard', label: t('sidebar.overview') },
{ to: '/browse/discovery', icon: 'explore', label: t('sidebar.browseCourses') },
{ to: '/dashboard/my-courses', icon: 'school', label: t('sidebar.myCourses') }
])
const handleNavigate = (path: string) => { const handleNavigate = (path: string) => {
if (import.meta.client) { if (import.meta.client) {
@ -22,7 +27,7 @@ const handleNavigate = (path: string) => {
:key="item.to" :key="item.to"
@click="handleNavigate(item.to)" @click="handleNavigate(item.to)"
:icon="item.icon" :icon="item.icon"
:label="$t(item.labelKey)" :label="item.label"
no-caps no-caps
class="py-2" class="py-2"
:class="{ 'q-tab--active text-primary': $route.path === item.to }" :class="{ 'q-tab--active text-primary': $route.path === item.to }"

View file

@ -7,7 +7,6 @@
const props = defineProps<{ const props = defineProps<{
modelValue: any; // passwordForm (currentPassword, newPassword, confirmPassword) modelValue: any; // passwordForm (currentPassword, newPassword, confirmPassword)
loading: boolean; loading: boolean;
flat?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@ -34,15 +33,11 @@ const showConfirmPassword = ref(false);
</script> </script>
<template> <template>
<div :class="[!flat ? 'card-premium p-6 md:p-8' : '']" class="h-fit"> <div class="card-premium p-8 h-fit">
<div v-if="!flat" class="flex items-center gap-3 mb-8"> <h2 class="text-xl font-bold flex items-center gap-3 text-slate-900 dark:text-white mb-6">
<div class="w-10 h-10 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center"> <q-icon name="lock" class="text-amber-500 text-2xl" />
<q-icon name="lock" class="text-blue-600 dark:text-blue-400 text-xl" />
</div>
<h2 class="text-xl font-black text-slate-900 dark:text-white">
{{ $t('profile.security') }} {{ $t('profile.security') }}
</h2> </h2>
</div>
<q-form @submit="emit('submit')" class="flex flex-col gap-6"> <q-form @submit="emit('submit')" class="flex flex-col gap-6">
<div class="text-sm text-slate-500 dark:text-slate-400 mb-2"> <div class="text-sm text-slate-500 dark:text-slate-400 mb-2">
@ -118,8 +113,8 @@ const showConfirmPassword = ref(false);
type="submit" type="submit"
unelevated unelevated
rounded rounded
class="w-full py-3 font-bold text-base shadow-lg shadow-blue-500/20" class="w-full py-3 font-bold text-base shadow-lg shadow-amber-500/20"
style="background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white;" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: white;"
:label="$t('profile.changePasswordBtn')" :label="$t('profile.changePasswordBtn')"
:loading="loading" :loading="loading"
/> />

View file

@ -8,7 +8,6 @@ const props = defineProps<{
modelValue: any; // userData (firstName, lastName, phone, etc.) modelValue: any; // userData (firstName, lastName, phone, etc.)
loading: boolean; loading: boolean;
verifying?: boolean; verifying?: boolean;
flat?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@ -68,15 +67,11 @@ const onPhoneKeydown = (e: KeyboardEvent) => {
</script> </script>
<template> <template>
<div :class="[!flat ? 'card-premium p-6 md:p-8' : '']" class="h-fit"> <div class="card-premium p-8 h-fit">
<div v-if="!flat" class="flex items-center gap-3 mb-8"> <h2 class="text-xl font-bold flex items-center gap-3 text-slate-900 dark:text-white mb-6">
<div class="w-10 h-10 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center"> <q-icon name="person" class="text-blue-500 text-2xl" />
<q-icon name="person" class="text-blue-600 dark:text-blue-400 text-xl" />
</div>
<h2 class="text-xl font-black text-slate-900 dark:text-white">
{{ $t('profile.editPersonalDesc') }} {{ $t('profile.editPersonalDesc') }}
</h2> </h2>
</div>
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">

View file

@ -29,9 +29,12 @@ const userInitials = computed(() => {
return f + l return f + l
}) })
const { userMenuItems } = useNavItems() const menuItems = computed(() => [
const menuItems = userMenuItems { label: t('userMenu.home'), to: '/dashboard' },
{ label: t('userMenu.courseList'), to: '/browse/discovery' },
{ label: t('userMenu.myCourses'), to: '/dashboard/my-courses' },
{ label: t('userMenu.settings'), to: '/dashboard/profile' }
])
const handleLogout = async () => { const handleLogout = async () => {
await logout() await logout()
@ -60,14 +63,14 @@ const handleLogout = async () => {
<q-list class="py-2"> <q-list class="py-2">
<q-item <q-item
v-for="item in menuItems" v-for="item in menuItems"
:key="item.labelKey" :key="item.label"
clickable clickable
v-close-popup v-close-popup
@click="navigateTo(item.to)" @click="navigateTo(item.to)"
class="hover:bg-slate-100 dark:hover:bg-white/5 transition-colors" class="hover:bg-slate-100 dark:hover:bg-white/5 transition-colors"
> >
<q-item-section> <q-item-section>
<q-item-label class="font-bold text-sm text-slate-800 dark:text-slate-100">{{ $t(item.labelKey) }}</q-item-label> <q-item-label class="font-bold text-sm text-slate-800 dark:text-slate-100">{{ item.label }}</q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>

View file

@ -1,4 +1,45 @@
import type { User, LoginResponse, RegisterPayload } from '@/types/auth'
// Interface สำหรับข้อมูลผู้ใช้งาน (User)
interface User {
id: number
username: string
email: string
email_verified_at?: string | null
created_at?: string
updated_at?: string
role: {
code: string // เช่น 'STUDENT', 'INSTRUCTOR', 'ADMIN'
name: { th: string; en: string }
}
profile?: {
prefix: { th: string; en: string }
first_name: string
last_name: string
phone: string | null
avatar_url: string | null
}
}
// Interface สำหรับข้อมูลตอบกลับตอน Login
interface loginResponse {
token: string
refreshToken: string
user: User
profile: User['profile']
}
// Interface สำหรับข้อมูลที่ใช้ลงทะเบียน
interface RegisterPayload {
username: string
email: string
password: string
first_name: string
last_name: string
prefix: { th: string; en: string }
phone: string
}
// ========================================== // ==========================================
// Composable: useAuth // Composable: useAuth

View file

@ -1,14 +1,125 @@
import type { // Interface สำหรับข้อมูลคอร์สเรียน (Public Course Data)
Course, export interface Course {
CourseResponse, id: number
SingleCourseResponse, title: string | { th: string; en: string } // รองรับ 2 ภาษา
EnrolledCourse, slug: string
EnrolledCourseResponse, description: string | { th: string; en: string }
QuizAnswerSubmission, thumbnail_url: string
QuizSubmitRequest, price: string
QuizResult, is_free: boolean
Certificate original_price?: string
} from '@/types/course' have_certificate: boolean
status: string // DRAFT, PUBLISHED
category_id: number
created_at?: string
updated_at?: string
created_by?: number
updated_by?: number
approved_at?: string
approved_by?: number
rejection_reason?: string
enrolled?: boolean
total_lessons?: number
rating?: string
lessons?: number | string
levelType?: 'neutral' | 'warning' | 'success' // ใช้สำหรับการแสดงผล Badge ระดับความยาก (Frontend Logic)
// โครงสร้างบทเรียน (Chapters & Lessons)
chapters?: {
id: number
title: string | { th: string; en: string }
lessons: {
id: number
title: string | { th: string; en: string }
duration_minutes: number
video_url?: string
}[]
}[]
}
interface CourseResponse {
code: number
message: string
data: Course[]
total: number
page?: number
limit?: number
totalPages?: number
}
interface SingleCourseResponse {
code: number
message: string
data: Course
}
// Interface สำหรับคอร์สที่ผู้ใช้ลงทะเบียนเรียนแล้ว (My Course)
export interface EnrolledCourse {
id: number
course_id: number
course: Course
status: 'ENROLLED' | 'IN_PROGRESS' | 'COMPLETED' | 'DROPPED'
progress_percentage: number
enrolled_at: string
started_at?: string
completed_at?: string
last_accessed_at?: string
}
interface EnrolledCourseResponse {
code: number
message: string
data: EnrolledCourse[]
total: number
page: number
limit: number
}
// Interface สำหรับการส่งคำตอบแบบทดสอบ (Quiz Submission)
export interface QuizAnswerSubmission {
question_id: number
choice_id: number
}
export interface QuizSubmitRequest {
answers: QuizAnswerSubmission[]
}
// Interface สำหรับผลลัพธ์การสอบ (Quiz Result)
export interface QuizResult {
answers_review: {
score: number
is_correct: boolean
correct_choice_id: number
selected_choice_id: number
question_id: number
}[]
completed_at: string
started_at: string
attempt_number: number
passing_score: number
is_passed: boolean
correct_answers: number
total_questions: number
total_score: number
score: number
quiz_id: number
attempt_id: number
}
// Interface สำหรับ Certificate
export interface Certificate {
certificate_id: number
course_id: number
course_title: {
en: string
th: string
}
issued_at: string
download_url: string
}
// ========================================== // ==========================================
// Composable: useCourse // Composable: useCourse
@ -34,7 +145,6 @@ export const useCourse = () => {
category_id?: number; category_id?: number;
page?: number; page?: number;
limit?: number; limit?: number;
search?: string;
random?: boolean; random?: boolean;
is_recommended?: boolean; is_recommended?: boolean;
forceRefresh?: boolean forceRefresh?: boolean
@ -57,7 +167,6 @@ export const useCourse = () => {
if (apiParams.category_id) queryParams.append('category_id', apiParams.category_id.toString()) if (apiParams.category_id) queryParams.append('category_id', apiParams.category_id.toString())
if (apiParams.page) queryParams.append('page', apiParams.page.toString()) if (apiParams.page) queryParams.append('page', apiParams.page.toString())
if (apiParams.limit) queryParams.append('limit', apiParams.limit.toString()) if (apiParams.limit) queryParams.append('limit', apiParams.limit.toString())
if (apiParams.search) queryParams.append('search', apiParams.search)
if (apiParams.random !== undefined) queryParams.append('random', apiParams.random.toString()) if (apiParams.random !== undefined) queryParams.append('random', apiParams.random.toString())
if (apiParams.is_recommended !== undefined) queryParams.append('is_recommended', apiParams.is_recommended.toString()) if (apiParams.is_recommended !== undefined) queryParams.append('is_recommended', apiParams.is_recommended.toString())

View file

@ -1,76 +0,0 @@
/**
* @file useNavItems.ts
* @description Single Source of Truth for navigation items used across the app (Sidebar, Mobile Nav, User Menu).
*/
export interface NavItem {
to: string
labelKey: string
icon: string
showOn: ('sidebar' | 'mobile' | 'userMenu')[]
roles?: string[]
}
export const useNavItems = () => {
const allNavItems: NavItem[] = [
{
to: '/dashboard',
labelKey: 'sidebar.overview',
icon: 'dashboard',
showOn: ['sidebar', 'mobile', 'userMenu']
},
{
to: '/browse',
labelKey: 'sidebar.onlineCourses',
icon: 'video_library',
showOn: ['mobile']
},
{
to: '/browse/discovery',
labelKey: 'sidebar.recommendedCourses',
icon: 'auto_awesome',
showOn: ['mobile']
},
{
to: '/browse/discovery',
labelKey: 'sidebar.browseCourses',
icon: 'explore',
showOn: ['sidebar', 'mobile', 'userMenu']
},
{
to: '/dashboard/my-courses',
labelKey: 'sidebar.myCourses',
icon: 'school',
showOn: ['sidebar', 'mobile', 'userMenu']
},
{
to: '/dashboard/announcements',
labelKey: 'sidebar.announcements',
icon: 'campaign',
showOn: ['mobile']
},
{
to: '/dashboard/profile',
labelKey: 'sidebar.profile',
icon: 'person',
showOn: [] // Was ['sidebar']
},
{
to: '/dashboard/profile',
labelKey: 'userMenu.settings',
icon: 'settings',
showOn: ['userMenu']
}
]
const sidebarItems = computed(() => allNavItems.filter(item => item.showOn.includes('sidebar')))
const mobileItems = computed(() => allNavItems.filter(item => item.showOn.includes('mobile')))
const userMenuItems = computed(() => allNavItems.filter(item => item.showOn.includes('userMenu')))
return {
allNavItems,
sidebarItems,
mobileItems,
userMenuItems
}
}

View file

@ -1,55 +0,0 @@
/**
* @file landing.ts
* @description Static data for the landing page.
*/
export const CATEGORY_CARDS = [
{
title: 'โปรแกรมมิ่ง',
desc: 'เชี่ยวชาญการเขียนโค้ดและพัฒนาซอฟต์แวร์',
icon: 'code',
slug: 'programming',
iconColor: 'text-blue-600',
iconBg: 'bg-blue-600/5'
},
{
title: 'การออกแบบ',
desc: 'ทักษะ UI/UX และการออกแบบระดับมือโปร',
icon: 'palette',
slug: 'design',
iconColor: 'text-indigo-600',
iconBg: 'bg-indigo-600/5'
},
{
title: 'ธุรกิจ',
desc: 'ทักษะการจัดการและความเป็นผู้นำสากล',
icon: 'business_center',
slug: 'business',
iconColor: 'text-blue-700',
iconBg: 'bg-blue-700/5'
}
]
export const WHY_CHOOSE_US = [
{
title: 'ผู้สอนเชี่ยวชาญ',
desc: 'เรียนรู้จากผู้นำในอุตสาหกรรมที่มีประสบการณ์การทำงานหลายปีในบริษัทเทคโนโลยีชั้นนำระดับโลก',
icon: 'groups',
iconBg: 'bg-blue-600/10',
iconColor: 'text-blue-600'
},
{
title: 'การเรียนรู้ที่ยืดหยุ่น',
desc: 'เรียนตามจังหวะของคุณเอง ได้ทุกที่ทุกเวลา เข้าถึงเนื้อหาคอร์สที่สมัครเรียนได้ตลอดชีพ',
icon: 'schedule',
iconBg: 'bg-indigo-600/10',
iconColor: 'text-indigo-600'
},
{
title: 'ประกาศนียบัตรเมื่อเรียนจบ',
desc: 'รับวุฒิบัตรที่เป็นที่ยอมรับเพื่อเสริมพอร์ตโฟลิโอระดับมืออาชีพของคุณและแชร์ลง LinkedIn ได้โดยตรง',
icon: 'verified',
iconBg: 'bg-blue-600/10',
iconColor: 'text-blue-600'
}
]

View file

@ -86,7 +86,7 @@ const handleError = () => {
background-color: var(--bg-body); background-color: var(--bg-body);
color: var(--text-main); color: var(--text-main);
padding: 24px; padding: 24px;
font-family: var(--font-main); font-family: 'Inter', 'Prompt', 'Sarabun', sans-serif;
} }
.error-content { .error-content {

View file

@ -5,26 +5,7 @@
}, },
"dashboard": { "dashboard": {
"welcomeTitle": "Welcome back", "welcomeTitle": "Welcome back",
"welcomeSubtitle": "Today is a great day to learn something new. Let's gain more knowledge!", "welcomeSubtitle": "Today is a great day to learn something new. Let's gain more knowledge!"
"heroTitle": "Continually upskill yourself",
"heroSubtitle": "to achieve your goals",
"heroDesc": "How many minutes have you learned today? Let's build a great learning habit. We have many new recommended courses waiting for you.",
"goToMyCourses": "Go to My Courses",
"searchNewCourses": "Find New Courses",
"continueLearningTitle": "Continue learning with your courses",
"myCourses": "My Courses",
"studyAgain": "Study Again",
"continue": "Continue",
"startNewCourse": "Start new courses to fill this section",
"knowledgeLibrary": "Knowledge Library",
"libraryDesc": "You can choose to learn from courses you own",
"chooseLibrary": "Choose to learn from your knowledge library",
"viewAll": "View All",
"emptyLibraryTitle": "No courses in library yet",
"emptyLibraryDesc": "Start learning new things today. Browse interesting courses to develop your skills.",
"viewAllCourses": "View All Courses",
"recommendedCourses": "Recommended Courses",
"noRecommended": "No recommended courses found"
}, },
"menu": { "menu": {
"continueLearning": "Continue Learning", "continueLearning": "Continue Learning",
@ -63,32 +44,17 @@
"unlimitedQuizzes": "Unlimited quizzes", "unlimitedQuizzes": "Unlimited quizzes",
"satisfactionGuarantee": "Satisfaction guarantee, 7-day refund", "satisfactionGuarantee": "Satisfaction guarantee, 7-day refund",
"noContent": "No content available yet", "noContent": "No content available yet",
"buyNow": "Buy this course",
"enrollFree": "Enroll for free", "enrollFree": "Enroll for free",
"loginToEnroll": "Log in to enroll", "loginToEnroll": "Log in to enroll",
"minutes": "Minutes", "minutes": "Minutes",
"noVideoPreview": "Video preview not available", "noVideoPreview": "Video preview not available",
"videoNotSupported": "Your browser does not support the video tag", "videoNotSupported": "Your browser does not support the video tag"
"aboutCourse": "About Course",
"lessonDetails": "Lesson Details",
"courseStats": {
"level": "Level",
"duration": "Duration",
"lessons": "Lessons",
"students": "Students"
},
"certificatePreview": "Certificate Preview",
"certificateDesc": "Upon completion and passing criteria",
"includes": "This course includes",
"fullLifetimeAccess": "Full lifetime access",
"accessOnMobile": "Access on mobile and tablet",
"buyNow": "Buy Now"
}, },
"sidebar": { "sidebar": {
"overview": "Home", "overview": "Home",
"myCourses": "My Courses", "myCourses": "My Courses",
"browseCourses": "Browse Courses", "browseCourses": "Browse Courses",
"onlineCourses": "All Courses",
"recommendedCourses": "Recommended Courses",
"announcements": "Announcements", "announcements": "Announcements",
"profile": "My Profile" "profile": "My Profile"
}, },
@ -105,16 +71,9 @@
"showAll": "Show All", "showAll": "Show All",
"loadMore": "Load More", "loadMore": "Load More",
"backToCatalog": "Back to Catalog", "backToCatalog": "Back to Catalog",
"selectable": "Selected", "selectable": "Selected"
"foundTotal": "Found Total",
"items": "items",
"subtitle": "Choose to learn new skills from our curated quality courses",
"searchBtn": "Search"
}, },
"myCourses": { "myCourses": {
"title": "My Courses",
"subtitle": "Track your progress and continue learning from where you left off",
"searchPlaceholder": "Search my courses...",
"filterAll": "All", "filterAll": "All",
"filterProgress": "In Progress", "filterProgress": "In Progress",
"filterCompleted": "Completed", "filterCompleted": "Completed",
@ -145,8 +104,6 @@
"email": "Email", "email": "Email",
"phone": "Phone", "phone": "Phone",
"joinedAt": "Joined", "joinedAt": "Joined",
"generalInfo": "General Information",
"accountDetails": "Account Details",
"editPersonalDesc": "Edit Personal Information", "editPersonalDesc": "Edit Personal Information",
"yourAvatar": "Your Profile Photo", "yourAvatar": "Your Profile Photo",
"avatarHint": "PNG, JPG only", "avatarHint": "PNG, JPG only",
@ -296,16 +253,5 @@
"statusNotStarted": "Not Started", "statusNotStarted": "Not Started",
"alertIncomplete": "Please answer all questions", "alertIncomplete": "Please answer all questions",
"yourAnswer": "Your Answer" "yourAnswer": "Your Answer"
},
"footer": {
"location": "LOCATION",
"connectWithUs": "CONNECT WITH US",
"broncoHorse": "Bronco Hourse",
"address": "123 อาคารสยามทาวเวอร์ ชั้น 15 เขตปทุมวัน กรุงเทพฯ 10330",
"emailLabel": "Email",
"emailValue": "info{'@'}chamomind.com",
"telLabel": "Tel",
"telValue": "02-123-4567",
"copyright": "© 2026 E-Learning Platform. All rights reserved."
} }
} }

View file

@ -5,26 +5,7 @@
}, },
"dashboard": { "dashboard": {
"welcomeTitle": "ยินดีต้อนรับกลับ", "welcomeTitle": "ยินดีต้อนรับกลับ",
"welcomeSubtitle": "วันนี้เป็นวันที่ดีสำหรับการเรียนรู้สิ่งใหม่ๆ มาเก็บความรู้เพิ่มกันเถอะ", "welcomeSubtitle": "วันนี้เป็นวันที่ดีสำหรับการเรียนรู้สิ่งใหม่ๆ มาเก็บความรู้เพิ่มกันเถอะ"
"heroTitle": "อัปสกิลของคุณต่อเนื่อง",
"heroSubtitle": "เพื่อเป้าหมายที่วางไว้",
"heroDesc": "วันนี้คุณเรียนไปกี่นาทีแล้ว? มาสร้างนิสัยการเรียนรู้ที่ยอดเยี่ยมกันเถอะ เรามีคอร์สแนะนำใหม่ๆ มากมายรอคุณอยู่",
"goToMyCourses": "ไปที่คอร์สเรียนของฉัน",
"searchNewCourses": "ค้นหาคอร์สใหม่",
"continueLearningTitle": "เรียนต่อกับคอร์สของคุณ",
"myCourses": "คอร์สเรียนของฉัน",
"studyAgain": "เรียนอีกครั้ง",
"continue": "เรียนต่อ",
"startNewCourse": "เริ่มเรียนคอร์สใหม่ๆ เพื่อเติมเต็มส่วนนี้",
"knowledgeLibrary": "คลังความรู้",
"libraryDesc": "คุณสามารถเลือกเรียนคอร์สเรียนที่คุณเป็นเจ้าของ",
"chooseLibrary": "เลือกเรียนคอร์สในคลังความรู้ของคุณ",
"viewAll": "ดูทั้งหมด",
"emptyLibraryTitle": "ยังไม่มีคอร์สเรียนในคลัง",
"emptyLibraryDesc": "เริ่มเรียนรู้สิ่งใหม่ๆ วันนี้ เลือกดูคอร์สเรียนที่น่าสนใจเพื่อพัฒนาทักษะของคุณ",
"viewAllCourses": "ดูคอร์สเรียนทั้งหมด",
"recommendedCourses": "คอร์สแนะนำ",
"noRecommended": "ไม่พบข้อมูลคอร์สแนะนำ"
}, },
"menu": { "menu": {
"continueLearning": "เรียนต่อจากเดิม", "continueLearning": "เรียนต่อจากเดิม",
@ -63,32 +44,17 @@
"unlimitedQuizzes": "ทำแบบทดสอบไม่จำกัด", "unlimitedQuizzes": "ทำแบบทดสอบไม่จำกัด",
"satisfactionGuarantee": "รับประกันความพึงพอใจ คืนเงินภายใน 7 วัน", "satisfactionGuarantee": "รับประกันความพึงพอใจ คืนเงินภายใน 7 วัน",
"noContent": "ยังไม่มีเนื้อหาในขณะนี้", "noContent": "ยังไม่มีเนื้อหาในขณะนี้",
"buyNow": "ซื้อคอร์สเรียนนี้",
"enrollFree": "ลงทะเบียนเรียนฟรี", "enrollFree": "ลงทะเบียนเรียนฟรี",
"loginToEnroll": "เข้าสู่ระบบเพื่อลงทะเบียน", "loginToEnroll": "เข้าสู่ระบบเพื่อลงทะเบียน",
"minutes": "นาที", "minutes": "นาที",
"noVideoPreview": "วิดีโอตัวอย่างยังไม่พร้อมใช้งาน", "noVideoPreview": "วิดีโอตัวอย่างยังไม่พร้อมใช้งาน",
"videoNotSupported": "เบราว์เซอร์ของคุณไม่รองรับการเล่นวิดีโอ", "videoNotSupported": "เบราว์เซอร์ของคุณไม่รองรับการเล่นวิดีโอ"
"aboutCourse": "เกี่ยวกับคอร์ส",
"lessonDetails": "รายละเอียดบทเรียน",
"courseStats": {
"level": "ระดับ",
"duration": "ระยะเวลา",
"lessons": "บทเรียน",
"students": "ผู้เรียน"
},
"certificatePreview": "ตัวอย่างใบประกาศนียบัตร",
"certificateDesc": "เมื่อเรียนจบและสอบผ่านตามเกณฑ์ที่กำหนด",
"includes": "สิ่งที่รวมอยู่ในคอร์ส",
"fullLifetimeAccess": "เข้าเรียนได้ตลอดชีพ",
"accessOnMobile": "เรียนได้บนมือถือและแท็บเล็ต",
"buyNow": "ซื้อคอร์สนี้"
}, },
"sidebar": { "sidebar": {
"overview": "หน้าหลัก", "overview": "หน้าหลัก",
"myCourses": "คอร์สของฉัน", "myCourses": "คอร์สของฉัน",
"browseCourses": "ค้นหาคอร์ส", "browseCourses": "ค้นหาคอร์ส",
"onlineCourses": "คอร์สเรียนทั้งหมด",
"recommendedCourses": "คอร์สเรียนแนะนำ",
"announcements": "ข่าวประกาศ", "announcements": "ข่าวประกาศ",
"profile": "บัญชีผู้ใช้" "profile": "บัญชีผู้ใช้"
}, },
@ -105,16 +71,9 @@
"showAll": "แสดงทั้งหมด", "showAll": "แสดงทั้งหมด",
"loadMore": "โหลดเพิ่มเติม", "loadMore": "โหลดเพิ่มเติม",
"backToCatalog": "กลับหน้ารายการคอร์ส", "backToCatalog": "กลับหน้ารายการคอร์ส",
"selectable": "รายการที่เลือก", "selectable": "รายการที่เลือก"
"foundTotal": "พบทั้งหมด",
"items": "รายการ",
"subtitle": "เลือกเรียนรู้ทักษะใหม่ๆ จากหลักสูตรคุณภาพที่คัดสรรมาเพื่อคุณ",
"searchBtn": "ค้นหา"
}, },
"myCourses": { "myCourses": {
"title": "คอร์สของฉัน",
"subtitle": "ติดตามความคืบหน้าและเรียนรู้ต่อจากจุดที่ค้างไว้",
"searchPlaceholder": "ค้นหาชื่อคอร์สของฉัน...",
"filterAll": "ทั้งหมด", "filterAll": "ทั้งหมด",
"filterProgress": "กำลังเรียน", "filterProgress": "กำลังเรียน",
"filterCompleted": "เรียนจบแล้ว", "filterCompleted": "เรียนจบแล้ว",
@ -145,8 +104,6 @@
"email": "อีเมล", "email": "อีเมล",
"phone": "เบอร์โทรศัพท์", "phone": "เบอร์โทรศัพท์",
"joinedAt": "สมัครสมาชิกเมื่อ", "joinedAt": "สมัครสมาชิกเมื่อ",
"generalInfo": "ข้อมูลทั่วไป",
"accountDetails": "รายละเอียดบัญชี",
"editPersonalDesc": "แก้ไขข้อมูลส่วนตัว", "editPersonalDesc": "แก้ไขข้อมูลส่วนตัว",
"yourAvatar": "รูปโปรไฟล์ของคุณ", "yourAvatar": "รูปโปรไฟล์ของคุณ",
"avatarHint": "เฉพาะไฟล์ png , jpg", "avatarHint": "เฉพาะไฟล์ png , jpg",
@ -191,7 +148,7 @@
"logout": "ออกจากระบบ" "logout": "ออกจากระบบ"
}, },
"landing": { "landing": {
"allCourses": "คอร์สเรียนทั้งหมด", "allCourses": "คอร์สทั้งหมด",
"discovery": "ค้นพบ", "discovery": "ค้นพบ",
"goToDashboard": "เข้าสู่หน้าจัดการเรียน" "goToDashboard": "เข้าสู่หน้าจัดการเรียน"
}, },
@ -296,16 +253,5 @@
"statusNotStarted": "ยังไม่ทำ", "statusNotStarted": "ยังไม่ทำ",
"alertIncomplete": "กรุณาเลือกคำตอบให้ครบทุกข้อ", "alertIncomplete": "กรุณาเลือกคำตอบให้ครบทุกข้อ",
"yourAnswer": "คำตอบของคุณ" "yourAnswer": "คำตอบของคุณ"
},
"footer": {
"location": "สถานที่ตั้ง",
"connectWithUs": "ติดต่อเรา",
"broncoHorse": "Bronco Hourse",
"address": "123 อาคารสยามทาวเวอร์ ชั้น 15 เขตปทุมวัน กรุงเทพฯ 10330",
"emailLabel": "อีเมล",
"emailValue": "info{'@'}chamomind.com",
"telLabel": "เบอร์โทรศัพท์",
"telValue": "02-123-4567",
"copyright": "© 2026 E-Learning Platform. All rights reserved."
} }
} }

View file

@ -1,167 +0,0 @@
<script setup lang="ts">
/**
* @file dashboard-index.vue
* @description Layout for the Dashboard Index page, without the sidebar.
* Uses Quasar QLayout for responsive structure.
*/
// Initialize global theme management
useThemeMode()
const { currentUser, logout } = useAuth()
const { isDark, set: setTheme } = useThemeMode()
const rightDrawerOpen = ref(false)
const toggleRightDrawer = () => {
rightDrawerOpen.value = !rightDrawerOpen.value
}
</script>
<template>
<q-layout view="hHh lpR fFf" class="bg-slate-50 dark:!bg-[#020617] text-slate-900 dark:!text-slate-50">
<!-- Header -->
<q-header
class="bg-white/80 dark:!bg-[#0f172a]/80 backdrop-blur-md text-slate-900 dark:!text-white"
>
<AppHeader
@toggleRightDrawer="toggleRightDrawer"
:showSidebarToggle="false"
navType="learner"
/>
</q-header>
<!-- Master Mobile Drawer (The Everything Hub) -->
<q-drawer
v-model="rightDrawerOpen"
side="right"
overlay
bordered
class="bg-white dark:!bg-[#0f172a]"
:width="300"
>
<div class="flex flex-col h-full bg-white dark:bg-[#0f172a]">
<!-- 1. Account Section (Premium Look) -->
<div class="p-6 bg-slate-50/50 dark:bg-slate-800/30 border-b border-slate-100 dark:border-slate-800">
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center text-white font-black">E</div>
<span class="font-black text-lg text-slate-900 dark:text-white">E-Learning</span>
</div>
<q-btn flat round dense icon="close" class="text-slate-400" @click="rightDrawerOpen = false" />
</div>
<div class="flex items-center gap-4 py-2">
<q-avatar size="64px" class="shadow-lg border-2 border-white dark:border-slate-700">
<img :src="currentUser?.photoURL || 'https://cdn.quasar.dev/img/avatar.png'" />
</q-avatar>
<div class="overflow-hidden">
<p class="font-bold text-slate-900 dark:text-white mb-0 truncate text-lg">
{{ currentUser?.firstName || 'Guest' }} {{ currentUser?.lastName || '' }}
</p>
<p class="text-xs text-slate-500 dark:text-slate-400 truncate">{{ currentUser?.email || 'e-learning@platform.com' }}</p>
</div>
</div>
</div>
<!-- 2. Integrated Content Hub -->
<div class="flex-grow overflow-y-auto pt-4">
<q-list padding class="text-slate-600 dark:text-slate-300">
<!-- Navigation -->
<q-item-label header class="text-[11px] font-black tracking-[0.2em] text-slate-400 uppercase px-6 pb-2">เมนหล</q-item-label>
<q-item to="/dashboard" clickable v-ripple class="px-6 py-4" active-class="bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400 font-bold" @click="rightDrawerOpen = false">
<q-item-section avatar><q-icon name="dashboard" size="24px" /></q-item-section>
<q-item-section><span class="text-[15px] font-bold">{{ $t("sidebar.overview") }}</span></q-item-section>
</q-item>
<q-item to="/browse/discovery" clickable v-ripple class="px-6 py-4" active-class="bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400 font-bold" @click="rightDrawerOpen = false">
<q-item-section avatar><q-icon name="explore" size="24px" /></q-item-section>
<q-item-section><span class="text-[15px] font-bold">{{ $t("landing.allCourses") }}</span></q-item-section>
</q-item>
<q-item to="/dashboard/my-courses" clickable v-ripple class="px-6 py-4" active-class="bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400 font-bold" @click="rightDrawerOpen = false">
<q-item-section avatar><q-icon name="school" size="24px" /></q-item-section>
<q-item-section><span class="text-[15px] font-bold">{{ $t("sidebar.myCourses") || 'คอร์สเรียนของฉัน' }}</span></q-item-section>
</q-item>
<q-separator class="my-4 mx-6 opacity-50" />
<!-- Tools & Settings -->
<q-item-label header class="text-[11px] font-black tracking-[0.2em] text-slate-400 uppercase px-6 pb-2">เครองมอและการตงค</q-item-label>
<!-- Language Selection -->
<q-item class="px-6 py-2">
<q-item-section avatar><q-icon name="language" size="22px" /></q-item-section>
<q-item-section>
<div class="flex items-center justify-between">
<span class="font-bold text-[14px]">ภาษา</span>
<LanguageSwitcher dense />
</div>
</q-item-section>
</q-item>
<!-- Dark Mode Toggle -->
<q-item class="px-6 py-2">
<q-item-section avatar><q-icon :name="isDark ? 'dark_mode' : 'light_mode'" size="22px" /></q-item-section>
<q-item-section>
<div class="flex items-center justify-between">
<span class="font-bold text-[14px]">โหมดกลางค</span>
<q-toggle
:model-value="isDark"
@update:model-value="setTheme"
color="blue"
/>
</div>
</q-item-section>
</q-item>
<q-item clickable v-ripple @click="navigateTo('/dashboard/profile'); rightDrawerOpen = false" class="px-6 py-4">
<q-item-section avatar><q-icon name="person_outline" size="24px" /></q-item-section>
<q-item-section><span class="font-bold text-[15px]">ดการโปรไฟล</span></q-item-section>
</q-item>
</q-list>
</div>
<!-- 3. Bottom Actions -->
<div class="p-6 mt-auto border-t border-slate-100 dark:border-slate-800">
<q-btn
unelevated
class="full-width rounded-xl bg-red-50 text-red-600 dark:bg-red-900/20 dark:text-red-400 font-bold py-3 no-caps transition-all active:scale-95"
@click="logout"
>
<q-icon name="logout" size="20px" class="mr-2" />
ออกจากระบบ
</q-btn>
<div class="text-center mt-6">
<span class="text-[10px] font-bold uppercase tracking-[0.2em] text-slate-300 dark:text-slate-600">E-Learning Platform v1.0</span>
</div>
</div>
</div>
</q-drawer>
<!-- Sidebar Removed for this layout -->
<!-- Main Content -->
<q-page-container>
<q-page class="relative">
<slot />
</q-page>
</q-page-container>
<!-- Mobile Bottom Nav - Optional, keeping it consistent with default but maybe not needed if full width?
If we remove sidebar, we might still want mobile nav if it's main navigation.
Let's keep it for now as it doesn't hurt. -->
<q-footer
v-if="$q.screen.lt.md"
class="!bg-white dark:!bg-[#1e293b] text-primary"
>
<MobileNav />
</q-footer>
</q-layout>
</template>
<style>
/* Ensure fonts are applied */
.font-inter {
font-family: var(--font-main);
}
</style>

View file

@ -8,157 +8,33 @@
// Initialize global theme management // Initialize global theme management
useThemeMode() useThemeMode()
const { currentUser, logout } = useAuth()
const { isDark, set: setTheme } = useThemeMode()
const leftDrawerOpen = ref(false) const leftDrawerOpen = ref(false)
const rightDrawerOpen = ref(false)
const toggleLeftDrawer = () => { const toggleLeftDrawer = () => {
leftDrawerOpen.value = !leftDrawerOpen.value leftDrawerOpen.value = !leftDrawerOpen.value
} }
const toggleRightDrawer = () => {
rightDrawerOpen.value = !rightDrawerOpen.value
}
const route = useRoute()
// Automatically hide sidebar for learner routes
const shouldHideSidebar = computed(() => {
const silentRoutes = ['/dashboard', '/browse', '/classroom', '/course']
return silentRoutes.some(r => route.path.startsWith(r))
})
</script> </script>
<template> <template>
<q-layout view="hHh LpR lFf" class="bg-slate-50 dark:!bg-[#020617] text-slate-900 dark:!text-slate-50"> <q-layout view="hHh LpR lFf" class="bg-slate-50 dark:!bg-[#020617] text-slate-900 dark:!text-slate-50 font-sans">
<!-- Header --> <!-- Header -->
<q-header <q-header
class="bg-white/80 dark:!bg-[#0f172a]/80 backdrop-blur-md text-slate-900 dark:!text-white border-none shadow-none" bordered
class="bg-white/80 dark:!bg-[#0f172a]/80 backdrop-blur-md text-slate-900 dark:!text-white border-b border-slate-200 dark:border-slate-800"
> >
<AppHeader <AppHeader @toggleSidebar="toggleLeftDrawer" />
@toggleSidebar="toggleLeftDrawer"
@toggleRightDrawer="toggleRightDrawer"
:showSidebarToggle="!shouldHideSidebar"
/>
</q-header> </q-header>
<!-- Sidebar (Drawer - Desktop Left) --> <!-- Sidebar (Drawer) -->
<q-drawer <q-drawer
v-if="!shouldHideSidebar"
v-model="leftDrawerOpen" v-model="leftDrawerOpen"
show-if-above show-if-above
bordered
:width="280" :width="280"
class="bg-white dark:!bg-[#0f172a]" class="bg-white dark:!bg-[#0f172a] border-r border-slate-200 dark:border-slate-800"
> >
<AppSidebar /> <AppSidebar />
</q-drawer> </q-drawer>
<!-- Master Mobile Drawer (The Everything Hub) -->
<q-drawer
v-model="rightDrawerOpen"
side="right"
overlay
class="bg-white dark:!bg-[#0f172a]"
:width="300"
>
<div class="flex flex-col h-full bg-white dark:!bg-[#0f172a] text-slate-900 dark:!text-slate-100">
<!-- 1. Account Section -->
<div class="p-6 bg-slate-50/50 dark:!bg-slate-800/20">
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center text-white font-black">E</div>
<span class="font-black text-lg text-slate-900 dark:text-white">E-Learning</span>
</div>
<q-btn flat round dense icon="close" class="text-slate-400" @click="rightDrawerOpen = false" />
</div>
<div class="flex items-center gap-4 py-2">
<q-avatar size="64px" class="shadow-lg border-2 border-white dark:border-slate-700">
<img :src="currentUser?.photoURL || 'https://cdn.quasar.dev/img/avatar.png'" />
</q-avatar>
<div class="overflow-hidden">
<p class="font-bold text-slate-900 dark:!text-white mb-0 truncate text-lg">
{{ currentUser?.firstName || 'Guest' }} {{ currentUser?.lastName || '' }}
</p>
<p class="text-xs text-slate-500 dark:!text-slate-400 truncate">{{ currentUser?.email || 'e-learning@platform.com' }}</p>
</div>
</div>
</div>
<!-- 2. Integrated Content Hub -->
<div class="flex-grow overflow-y-auto pt-4">
<q-list padding class="text-slate-600 dark:!text-slate-300">
<!-- Navigation -->
<q-item-label header class="text-[11px] font-black tracking-[0.2em] text-slate-400 uppercase px-6 pb-2">เมนหล</q-item-label>
<q-item to="/dashboard" clickable v-ripple class="px-6 py-4" active-class="bg-blue-50 text-blue-600 dark:!bg-blue-900/30 dark:!text-blue-400 font-bold" @click="rightDrawerOpen = false">
<q-item-section avatar><q-icon name="dashboard" size="24px" /></q-item-section>
<q-item-section><span class="text-[15px] font-bold">{{ $t("sidebar.overview") }}</span></q-item-section>
</q-item>
<q-item to="/browse/discovery" clickable v-ripple class="px-6 py-4" active-class="bg-blue-50 text-blue-600 dark:!bg-blue-900/30 dark:!text-blue-400 font-bold" @click="rightDrawerOpen = false">
<q-item-section avatar><q-icon name="explore" size="24px" /></q-item-section>
<q-item-section><span class="text-[15px] font-bold">{{ $t("landing.allCourses") }}</span></q-item-section>
</q-item>
<q-item to="/dashboard/my-courses" clickable v-ripple class="px-6 py-4" active-class="bg-blue-50 text-blue-600 dark:!bg-blue-900/30 dark:!text-blue-400 font-bold" @click="rightDrawerOpen = false">
<q-item-section avatar><q-icon name="school" size="24px" /></q-item-section>
<q-item-section><span class="text-[15px] font-bold">{{ $t("sidebar.myCourses") || 'คอร์สเรียนของฉัน' }}</span></q-item-section>
</q-item>
<!-- Tools & Settings -->
<q-item-label header class="text-[11px] font-black tracking-[0.2em] text-slate-400 uppercase px-6 pb-2 mt-4">เครองมอและการตงค</q-item-label>
<!-- Language Selection -->
<q-item class="px-6 py-2">
<q-item-section avatar><q-icon name="language" size="22px" /></q-item-section>
<q-item-section>
<div class="flex items-center justify-between">
<span class="font-bold text-[14px]">ภาษา</span>
<LanguageSwitcher dense />
</div>
</q-item-section>
</q-item>
<!-- Dark Mode Toggle -->
<q-item class="px-6 py-2">
<q-item-section avatar><q-icon :name="isDark ? 'dark_mode' : 'light_mode'" size="22px" /></q-item-section>
<q-item-section>
<div class="flex items-center justify-between">
<span class="font-bold text-[14px]">โหมดกลางค</span>
<q-toggle
:model-value="isDark"
@update:model-value="setTheme"
color="blue"
/>
</div>
</q-item-section>
</q-item>
<q-item clickable v-ripple @click="navigateTo('/dashboard/profile'); rightDrawerOpen = false" class="px-6 py-4">
<q-item-section avatar><q-icon name="person_outline" size="24px" /></q-item-section>
<q-item-section><span class="font-bold text-[15px] dark:!text-slate-300">ดการโปรไฟล</span></q-item-section>
</q-item>
</q-list>
</div>
<!-- 3. Bottom Actions -->
<div class="p-6 mt-auto border-t border-slate-100 dark:!border-white/10">
<q-btn
unelevated
class="full-width rounded-xl bg-red-50 text-red-600 dark:!bg-red-900/20 dark:!text-red-400 font-bold py-3 no-caps transition-all active:scale-95"
@click="logout"
>
<q-icon name="logout" size="20px" class="mr-2" />
ออกจากระบบ
</q-btn>
<div class="text-center mt-6">
<span class="text-[10px] font-bold uppercase tracking-[0.2em] text-slate-300 dark:text-slate-600">E-Learning Platform v1.0</span>
</div>
</div>
</div>
</q-drawer>
<!-- Main Content --> <!-- Main Content -->
<q-page-container> <q-page-container>
<q-page class="relative"> <q-page class="relative">
@ -166,13 +42,20 @@ const shouldHideSidebar = computed(() => {
</q-page> </q-page>
</q-page-container> </q-page-container>
<!-- Mobile Bottom Nav -->
<q-footer
v-if="$q.screen.lt.md"
bordered
class="!bg-white dark:!bg-[#1e293b] text-primary border-t border-slate-200 dark:border-slate-700"
>
<MobileNav />
</q-footer>
</q-layout> </q-layout>
</template> </template>
<style> <style>
/* Ensure fonts are applied */ /* Ensure fonts are applied */
.font-inter { .font-inter {
font-family: var(--font-main); font-family: 'Inter', sans-serif;
} }
</style> </style>

View file

@ -20,7 +20,7 @@ onMounted(() => {
<template> <template>
<q-layout view="lHh LpR lFf" class="bg-white text-slate-900"> <q-layout view="lHh LpR lFf" class="bg-white text-slate-900 font-inter">
<!-- Header (Transparent & Overlay) --> <!-- Header (Transparent & Overlay) -->
@ -36,9 +36,12 @@ onMounted(() => {
</q-page> </q-page>
</q-page-container> </q-page-container>
<!-- Footer -->
<LandingFooter />
</q-layout> </q-layout>
</template> </template>
<style>
.font-inter {
font-family: 'Inter', sans-serif;
}
</style>

View file

@ -66,11 +66,10 @@ export default defineNuxtConfig({
{ name: "viewport", content: "width=device-width, initial-scale=1" }, { name: "viewport", content: "width=device-width, initial-scale=1" },
], ],
link: [ link: [
{ rel: 'icon', type: 'image/png', href: '/img/logo.png' },
{ {
rel: "stylesheet", rel: "stylesheet",
// โหลด Font: Inter, Prompt, Sarabun // โหลด Font: Inter, Prompt, Sarabun
href: "https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Prompt:wght@300;400;500;600;700;800;900&family=Sarabun:wght@300;400;500;600;700;800&family=Poppins:wght@300;400;500;600;700;800;900&display=swap", href: "https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Prompt:wght@300;400;500;600;700;800;900&family=Sarabun:wght@300;400;500;600;700;800&display=swap",
}, },
], ],
}, },

View file

@ -237,24 +237,17 @@ onMounted(() => {
<div v-else class="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div> <div v-else class="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
</button> </button>
<!-- Test Credentials Box -->
<div class="mt-4 p-5 bg-blue-50/50 border border-blue-100 rounded-2xl flex flex-col items-center gap-2 animate-fade-in">
<div class="text-[11px] font-black uppercase tracking-[0.2em] text-blue-600 mb-1">ญชสำหรบทดสอบ (Test Account)</div>
<div class="flex flex-col items-center gap-1">
<div class="text-base font-black text-slate-900 select-all cursor-copy hover:text-blue-600 transition-colors">
studentedtest@example.com
</div>
<div class="flex items-center gap-2">
<span class="text-[11px] font-black uppercase tracking-wider text-slate-600">Password:</span>
<span class="text-base font-black select-all cursor-copy hover:text-blue-600 transition-colors text-slate-900">admin123</span>
</div>
</div>
</div>
</form> </form>
<!-- Divider -->
<div class="my-8 flex items-center gap-4">
<div class="h-px bg-slate-200 flex-1"></div>
<span class="text-slate-400 text-xs font-medium uppercase tracking-wider">หร</span>
<div class="h-px bg-slate-200 flex-1"></div>
</div>
<!-- Register Link --> <!-- Register Link -->
<div class="text-center mt-8"> <div class="text-center">
<p class="text-slate-600 text-sm"> <p class="text-slate-600 text-sm">
งไมญชสมาช? งไมญชสมาช?
<NuxtLink to="/auth/register" class="font-bold text-blue-600 hover:text-blue-700 transition-colors ml-1"> <NuxtLink to="/auth/register" class="font-bold text-blue-600 hover:text-blue-700 transition-colors ml-1">

View file

@ -33,16 +33,16 @@ const currentPage = ref(1);
const totalPages = ref(1); const totalPages = ref(1);
const itemsPerPage = 12; const itemsPerPage = 12;
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const { currentUser } = useAuth(); const { currentUser } = useAuth();
const $q = useQuasar(); const $q = useQuasar();
const { fetchCategories } = useCategory(); const { fetchCategories } = useCategory();
const { fetchCourses, fetchCourseById, enrollCourse, getLocalizedText } = const { fetchCourses, fetchCourseById, enrollCourse, getLocalizedText } = useCourse();
useCourse();
// 2. Computed Properties // 2. Computed Properties
const sortOption = ref(t("discovery.sortRecent")); const sortOption = ref(t('discovery.sortRecent'));
const sortOptions = computed(() => [t("discovery.sortRecent")]); const sortOptions = computed(() => [t('discovery.sortRecent')]);
const filteredCourses = computed(() => { const filteredCourses = computed(() => {
let result = courses.value; let result = courses.value;
@ -50,14 +50,12 @@ const filteredCourses = computed(() => {
// If more than 1 category is selected, we still do client-side filtering // If more than 1 category is selected, we still do client-side filtering
// because the API currently only supports one category_id at a time. // because the API currently only supports one category_id at a time.
if (selectedCategoryIds.value.length > 1) { if (selectedCategoryIds.value.length > 1) {
result = result.filter((c) => result = result.filter(c => selectedCategoryIds.value.includes(c.category_id));
selectedCategoryIds.value.includes(c.category_id),
);
} }
if (searchQuery.value) { if (searchQuery.value) {
const query = searchQuery.value.toLowerCase(); const query = searchQuery.value.toLowerCase();
result = result.filter((c) => { result = result.filter(c => {
const title = getLocalizedText(c.title).toLowerCase(); const title = getLocalizedText(c.title).toLowerCase();
const desc = getLocalizedText(c.description).toLowerCase(); const desc = getLocalizedText(c.description).toLowerCase();
return title.includes(query) || (desc && desc.includes(query)); return title.includes(query) || (desc && desc.includes(query));
@ -68,6 +66,7 @@ const filteredCourses = computed(() => {
// 3. Helper Functions // 3. Helper Functions
// 4. API Actions // 4. API Actions
const loadCategories = async () => { const loadCategories = async () => {
const res = await fetchCategories(); const res = await fetchCategories();
@ -78,17 +77,13 @@ const loadCourses = async (page = 1) => {
isLoading.value = true; isLoading.value = true;
// Use server-side filtering if exactly one category is selected // Use server-side filtering if exactly one category is selected
const categoryId = const categoryId = selectedCategoryIds.value.length === 1 ? selectedCategoryIds.value[0] : undefined;
selectedCategoryIds.value.length === 1
? selectedCategoryIds.value[0]
: undefined;
const res = await fetchCourses({ const res = await fetchCourses({
category_id: categoryId, category_id: categoryId,
search: searchQuery.value,
page: page, page: page,
limit: itemsPerPage, limit: itemsPerPage,
forceRefresh: true, forceRefresh: true
}); });
if (res.success) { if (res.success) {
@ -113,37 +108,33 @@ const handleEnroll = async (id: number) => {
isEnrolling.value = true; isEnrolling.value = true;
const res = await enrollCourse(id); const res = await enrollCourse(id);
if (res.success) { if (res.success) {
return navigateTo("/dashboard/my-courses?enrolled=true"); return navigateTo('/dashboard/my-courses?enrolled=true');
} else { } else {
$q.notify({ $q.notify({
type: "negative", type: 'negative',
message: res.error || t("enrollment.error"), message: res.error || t('enrollment.error'),
position: "top", position: 'top',
timeout: 3000, timeout: 3000,
actions: [{ icon: "close", color: "white" }], actions: [{ icon: 'close', color: 'white' }]
}); })
} }
isEnrolling.value = false; isEnrolling.value = false;
}; };
// Watch for category selection changes to reload courses // Watch for category selection changes to reload courses
watch( watch(selectedCategoryIds, () => {
selectedCategoryIds,
() => {
currentPage.value = 1; currentPage.value = 1;
loadCourses(1); loadCourses(1);
}, }, { deep: true });
{ deep: true },
);
const toggleCategory = (id: number) => { const toggleCategory = (id: number) => {
const index = selectedCategoryIds.value.indexOf(id); const index = selectedCategoryIds.value.indexOf(id)
if (index === -1) { if (index === -1) {
selectedCategoryIds.value.push(id); selectedCategoryIds.value.push(id)
} else { } else {
selectedCategoryIds.value.splice(index, 1); selectedCategoryIds.value.splice(index, 1)
}
} }
};
onMounted(() => { onMounted(() => {
loadCategories(); loadCategories();
@ -153,71 +144,45 @@ onMounted(() => {
<template> <template>
<div class="page-container"> <div class="page-container">
<!-- CATALOG VIEW: Browse courses --> <!-- CATALOG VIEW: Browse courses -->
<div v-if="!showDetail"> <div v-if="!showDetail">
<!-- Top Header Area --> <!-- Top Header Area -->
<!-- New Enhanced Search Section (Image 1 Style) --> <div class="flex flex-col gap-6 mb-10">
<div class="bg-blue-50/50 dark:bg-blue-900/10 rounded-[2.5rem] p-8 md:p-10 mb-8 border border-blue-100/50 dark:border-blue-500/10 transition-colors duration-300"> <div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div class="flex items-center gap-4 mb-2"> <!-- Title -->
<h1 class="text-2xl md:text-3xl font-black text-slate-900 dark:text-white"> <h1 class="text-3xl font-black text-slate-900 dark:text-white flex items-center gap-3">
{{ $t("discovery.title") }} <span class="w-1.5 h-8 bg-blue-600 rounded-full shadow-sm shadow-blue-500/50"></span>
{{ $t('discovery.title') }}
</h1> </h1>
</div>
<p class="text-slate-500 dark:text-slate-400 font-medium mb-8">
{{ $t("discovery.subtitle") }}
</p>
<div class="flex flex-col md:flex-row gap-4"> <!-- Right Side: Search -->
<!-- Search Input --> <div class="flex items-center gap-3 w-full md:w-auto">
<div class="relative flex-1 group"> <q-input
<div class="absolute left-5 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-blue-600 transition-colors">
<q-icon name="search" size="24px" />
</div>
<input
v-model="searchQuery" v-model="searchQuery"
type="text" dense
:placeholder="$t('discovery.searchPlaceholder') || 'ค้นหาคอร์สที่น่าสนใจที่นี่...'" outlined
class="w-full pl-14 pr-6 py-3.5 bg-white dark:!bg-slate-900/80 border-2 border-transparent dark:border-white/5 rounded-2xl text-slate-900 dark:text-white placeholder-slate-400 focus:outline-none focus:border-blue-500/20 focus:ring-4 focus:ring-blue-500/5 transition-all text-base font-medium shadow-sm" rounded
@keyup.enter="loadCourses(1)" :placeholder="$t('discovery.searchPlaceholder')"
/> class="w-full md:w-72 search-input shadow-sm"
</div> bg-color="transparent"
<!-- Search Button -->
<q-btn
unelevated
color="primary"
class="px-8 h-[52px] rounded-2xl font-black shadow-lg shadow-blue-600/20 hover:scale-[1.02] transition-transform"
no-caps
@click="loadCourses(1)"
> >
<div class="flex items-center gap-2"> <template v-slot:prepend>
<q-icon name="search" size="20px" /> <q-icon name="search" class="text-slate-400" />
<span class="text-base">{{ $t("discovery.searchBtn") }}</span> </template>
</div> </q-input>
</q-btn>
</div>
</div>
<div class="flex items-center justify-between mb-8 px-2">
<div class="text-slate-500 dark:text-slate-400 text-sm font-bold uppercase tracking-wider">
{{ $t("discovery.foundTotal") }} <span class="text-blue-600">{{ filteredCourses.length }}</span> {{ $t("discovery.items") }}
</div> </div>
</div> </div>
<!-- Unified Filter Section: Categories --> <!-- Unified Filter Section: Categories -->
<div <div class="flex flex-wrap items-center gap-2">
class="bg-white dark:!bg-slate-900/50 p-2 rounded-2xl border border-slate-100 dark:border-white/5 inline-flex flex-wrap items-center gap-1.5 shadow-sm mb-12"
>
<q-btn <q-btn
flat flat
rounded rounded
dense dense
class="px-5 py-2 font-bold transition-all text-[11px] uppercase tracking-wider" class="px-4 font-bold transition-all text-xs uppercase tracking-widest"
:class=" :class="selectedCategoryIds.length === 0 ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' : 'bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-white/5'"
selectedCategoryIds.length === 0
? 'bg-blue-600 text-white shadow-md shadow-blue-600/20'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:!hover:bg-slate-800/50'
"
@click="selectedCategoryIds = []" @click="selectedCategoryIds = []"
:label="$t('discovery.showAll')" :label="$t('discovery.showAll')"
/> />
@ -227,23 +192,18 @@ onMounted(() => {
flat flat
rounded rounded
dense dense
class="px-5 py-2 font-bold transition-all text-[11px] uppercase tracking-wider" class="px-4 font-bold transition-all text-xs uppercase tracking-widest"
:class=" :class="selectedCategoryIds.includes(cat.id) ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' : 'bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-white/5'"
selectedCategoryIds.includes(cat.id)
? 'bg-blue-600 text-white shadow-md shadow-blue-600/20'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:!hover:bg-slate-800/50'
"
@click="toggleCategory(cat.id)" @click="toggleCategory(cat.id)"
:label="getLocalizedText(cat.name)" :label="getLocalizedText(cat.name)"
/> />
</div> </div>
</div>
<!-- Main Layout: Grid Only --> <!-- Main Layout: Grid Only -->
<div class="w-full"> <div class="w-full">
<div v-if="filteredCourses.length > 0" class="flex flex-col gap-12"> <div v-if="filteredCourses.length > 0" class="flex flex-col gap-12">
<div <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8"
>
<CourseCard <CourseCard
v-for="course in filteredCourses" v-for="course in filteredCourses"
:key="course.id" :key="course.id"
@ -275,28 +235,17 @@ onMounted(() => {
v-else v-else
class="flex flex-col items-center justify-center py-20 bg-white dark:bg-slate-800/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-700 shadow-sm" class="flex flex-col items-center justify-center py-20 bg-white dark:bg-slate-800/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-700 shadow-sm"
> >
<q-icon <q-icon name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" />
name="search_off" <h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ $t('discovery.emptyTitle') }}</h3>
size="64px"
class="text-slate-300 dark:text-slate-600 mb-4"
/>
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">
{{ $t("discovery.emptyTitle") }}
</h3>
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md"> <p class="text-slate-500 dark:text-slate-400 text-center max-w-md">
{{ $t("discovery.emptyDesc") }} {{ $t('discovery.emptyDesc') }}
</p> </p>
<button <button class="mt-6 font-bold text-blue-600 hover:text-blue-700 dark:hover:text-blue-400 transition-colors" @click="searchQuery = ''; selectedCategoryIds = []">
class="mt-6 font-bold text-blue-600 hover:text-blue-700 dark:hover:text-blue-400 transition-colors" {{ $t('discovery.showAll') }}
@click="
searchQuery = '';
selectedCategoryIds = [];
"
>
{{ $t("discovery.showAll") }}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<!-- COURSE DETAIL VIEW: Detailed information about a specific course --> <!-- COURSE DETAIL VIEW: Detailed information about a specific course -->
@ -305,12 +254,8 @@ onMounted(() => {
@click="showDetail = false" @click="showDetail = false"
class="inline-flex items-center gap-2 text-slate-600 dark:text-white hover:text-blue-600 dark:hover:text-blue-300 mb-6 transition-all font-black text-lg md:text-xl group" class="inline-flex items-center gap-2 text-slate-600 dark:text-white hover:text-blue-600 dark:hover:text-blue-300 mb-6 transition-all font-black text-lg md:text-xl group"
> >
<q-icon <q-icon name="arrow_back" size="24px" class="transition-transform group-hover:-translate-x-1" />
name="arrow_back" {{ $t('discovery.backToCatalog') }}
size="24px"
class="transition-transform group-hover:-translate-x-1"
/>
{{ $t("discovery.backToCatalog") }}
</button> </button>
<div v-if="isLoadingDetail" class="flex justify-center py-20"> <div v-if="isLoadingDetail" class="flex justify-center py-20">
@ -353,3 +298,4 @@ onMounted(() => {
box-shadow: none !important; box-shadow: none !important;
} }
</style> </style>

View file

@ -18,7 +18,6 @@ useHead({
// Reactive state for the search input // Reactive state for the search input
const searchQuery = ref('') const searchQuery = ref('')
const { fetchCourses } = useCourse() const { fetchCourses } = useCourse()
const { fetchCategories, categories } = useCategory()
// Helper to handle localized text // Helper to handle localized text
const getLocalizedText = (text: string | { th: string; en: string } | undefined) => { const getLocalizedText = (text: string | { th: string; en: string } | undefined) => {
@ -27,72 +26,8 @@ const getLocalizedText = (text: string | { th: string; en: string } | undefined)
return text.th || text.en || '' return text.th || text.en || ''
} }
const route = useRoute() // Fetch courses from API
const router = useRouter() const { data: coursesResponse, error } = await useAsyncData('courses-list', () => fetchCourses())
// State for selected category
const selectedCategory = ref((route.query.category as string) || 'all')
const selectCategory = (slug: string) => {
if (slug === 'all') {
router.push({ query: { ...route.query, category: 'all' } })
} else {
router.push({ query: { ...route.query, category: slug } })
}
}
// Watch route query to sync state
watch(() => route.query.category, (newCategory) => {
selectedCategory.value = (newCategory as string) || 'all'
})
// Specific labels mapping as requested
const categoryLabels: Record<string, string> = {
all: "ทั้งหมด",
programming: "การเขียนโปรแกรม",
design: "การออกแบบ",
business: "ธุรกิจ"
}
const getCategoryLabel = (category: any) => {
if (categoryLabels[category.slug]) {
return categoryLabels[category.slug]
}
return getLocalizedText(category.name)
}
// Fetch categories on mount
await useAsyncData('categories-list', () => fetchCategories())
// Fetch courses from API (reactive to selectedCategory)
const { data: coursesResponse, error, refresh } = await useAsyncData(
'browse-courses-list',
() => {
const params: any = {}
console.log('Fetching courses. Selected Category:', selectedCategory.value)
if (selectedCategory.value !== 'all') {
const category = categories.value.find(c => c.slug === selectedCategory.value)
console.log('Found Category:', category)
if (category) {
params.category_id = category.id
}
}
console.log('Params being sent to fetchCourses:', params)
return fetchCourses(params)
}
)
// Watch for category changes and refresh data
watch(selectedCategory, (newVal) => {
console.log('Selected Category Changed to:', newVal)
refresh()
})
// Ref for the scroll container
const categoryScroll = ref<HTMLElement | null>(null)
// Computed property for courses list from API response // Computed property for courses list from API response
const courses = computed(() => { const courses = computed(() => {
@ -140,9 +75,13 @@ const filteredCourses = computed(() => {
<section class="relative pt-32 pb-20 px-6 overflow-hidden"> <section class="relative pt-32 pb-20 px-6 overflow-hidden">
<div class="container mx-auto max-w-6xl text-center relative z-10"> <div class="container mx-auto max-w-6xl text-center relative z-10">
<!-- Tagline Badge --> <!-- Tagline Badge -->
<div class="mb-8 slide-up">
<span class="px-5 py-2 rounded-full glass border border-blue-400/20 text-blue-400 text-[11px] font-black tracking-[0.25em] uppercase shadow-[0_0_20px_rgba(59,130,246,0.15)]">
EXPLORE COURSES
</span>
</div>
<!-- Main Title --> <!-- Main Title -->
<h1 class="text-4xl md:text-6xl font-black text-slate-900 mb-6 tracking-tight py-2 slide-up leading-[1.2] overflow-visible" style="animation-delay: 0.1s;"> <h1 class="text-4xl md:text-6xl font-black text-slate-900 mb-8 tracking-normal py-10 slide-up leading-[1.6]" style="animation-delay: 0.1s;">
คอรสเรยน<span class="text-gradient-cyan">งหมด</span> คอรสเรยน<span class="text-gradient-cyan">งหมด</span>
</h1> </h1>
<!-- Subtitle --> <!-- Subtitle -->
@ -158,90 +97,35 @@ const filteredCourses = computed(() => {
<!-- ========================================== <!-- ==========================================
SEARCH & GRID SECTION SEARCH & GRID SECTION
========================================== --> ========================================== -->
<section class="container mx-auto max-w-[1440px] px-6 pb-20"> <section class="container mx-auto max-w-[1440px] px-6 pb-32">
<!-- Content Frame Container --> <!-- Content Frame Container -->
<div class="glass-premium rounded-[3rem] p-8 md:p-12 shadow-xl shadow-blue-900/5"> <div class="glass-premium rounded-[3rem] p-8 md:p-12 shadow-xl shadow-blue-900/5">
<!-- New Enhanced Search Section (Image 2 Style) --> <div class="flex flex-col md:flex-row md:items-center justify-between gap-8 mb-12">
<div class="bg-blue-50/50 rounded-[2.5rem] p-8 md:p-10 mb-6 border border-blue-100/50"> <h2 class="text-2xl font-black text-slate-900 flex items-center gap-3">
<h2 class="text-2xl md:text-3xl font-black text-slate-900 mb-2">คอรสเรยนทงหมด</h2> <span class="w-2 h-8 bg-blue-600 rounded-full"/>
<p class="text-slate-500 font-medium mb-8">ฒนาทกษะใหม บผเชยวชาญจากทวโลก</p> รายการคอรสเรยน
</h2>
<div class="flex flex-col md:flex-row gap-4"> <!-- Search Bar (Compact) -->
<!-- Search Input --> <div class="relative max-w-md w-full">
<div class="relative flex-1 group"> <div class="relative group">
<div class="absolute left-5 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-blue-600 transition-colors">
<q-icon name="search" size="24px" />
</div>
<input <input
v-model="searchQuery" v-model="searchQuery"
type="text" type="text"
placeholder="ค้นหาชื่อคอร์ส..." class="w-full pl-12 pr-6 py-3 bg-slate-100 border border-slate-200 rounded-xl text-slate-900 placeholder-slate-400 focus:outline-none focus:bg-white focus: focus:ring-2 focus:ring-blue-500/50 transition-all font-medium"
class="w-full pl-14 pr-6 py-4 bg-white border-2 border-transparent rounded-2xl text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blue-500/20 focus:ring-4 focus:ring-blue-500/5 transition-all text-lg font-medium shadow-sm" placeholder="ค้นหาบทเรียน..."
/>
</div>
<!-- Search Button -->
<q-btn
unelevated
color="primary"
class="px-4 h-16 rounded-2xl font-black shadow-lg shadow-blue-600/20 hover:scale-[1.02] transition-transform"
no-caps
> >
<div class="flex items-center gap-2"> <div class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400">
<q-icon name="search" size="20px" /> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<span class="text-base">นหา</span> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</div> </svg>
</q-btn>
</div> </div>
</div> </div>
<!-- Category Filter Tabs with Scroll Buttons -->
<div class="relative mb-8">
<!-- Left Scroll Button -->
<button
class="absolute left-0 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-white shadow-md border border-slate-100 flex items-center justify-center text-slate-500 hover:text-blue-600 transition-all md:hidden"
@click="categoryScroll?.scrollBy({ left: -200, behavior: 'smooth' })"
>
<q-icon name="chevron_left" size="24px" />
</button>
<!-- Scrollable Container -->
<div
ref="categoryScroll"
class="flex items-center gap-3 overflow-x-auto pb-2 no-scrollbar px-1 scroll-smooth"
>
<button
class="px-6 py-2.5 rounded-full font-bold text-sm transition-all whitespace-nowrap border-2 flex-shrink-0"
:class="selectedCategory === 'all' ? 'bg-blue-600 text-white border-blue-600 shadow-lg shadow-blue-600/30' : 'bg-white border-slate-100 text-slate-500 hover:border-slate-300'"
@click="selectCategory('all')"
>
{{ categoryLabels.all }}
</button>
<button
v-for="category in categories"
:key="category.id"
class="px-6 py-2.5 rounded-full font-bold text-sm transition-all whitespace-nowrap border-2 flex-shrink-0"
:class="selectedCategory === category.slug ? 'bg-blue-600 text-white border-blue-600 shadow-lg shadow-blue-600/30' : 'bg-white border-slate-100 text-slate-500 hover:border-slate-300'"
@click="selectCategory(category.slug)"
>
{{ getCategoryLabel(category) }}
</button>
</div> </div>
<!-- Right Scroll Button -->
<button
class="absolute right-0 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-white shadow-md border border-slate-100 flex items-center justify-center text-slate-500 hover:text-blue-600 transition-all md:hidden"
@click="categoryScroll?.scrollBy({ left: 200, behavior: 'smooth' })"
>
<q-icon name="chevron_right" size="24px" />
</button>
</div> </div>
<!-- Course Grid (Updated to 4 cols) --> <!-- Course Grid (Updated to 4 cols) -->
<div v-if="filteredCourses.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> <div v-if="filteredCourses.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div <div
@ -313,16 +197,15 @@ const filteredCourses = computed(() => {
CTA SECTION CTA SECTION
Call to action to register Call to action to register
========================================== --> ========================================== -->
<section class="py-24 relative overflow-hidden"> <section class="py-32 relative overflow-hidden">
<!-- Background Decoration --> <!-- Gradient Overlay -->
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-blue-50/80 pointer-events-none -z-10"/> <div class="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent pointer-events-none"/>
<div class="absolute bottom-0 left-1/2 -translate-x-1/2 w-[800px] h-[400px] bg-blue-400/10 blur-[120px] rounded-full -z-10 pointer-events-none"/>
<div class="container mx-auto max-w-4xl text-center relative z-10 px-6"> <div class="container mx-auto max-w-4xl text-center relative z-10 px-6">
<h2 class="text-4xl md:text-5xl font-black text-slate-900 mb-6 tracking-tight"> <h2 class="text-4xl md:text-5xl font-black text-slate-900 mb-8 tracking-tight">
พรอมจะเรมตนแลวหรอย? พรอมจะเรมตนแลวหรอย?
</h2> </h2>
<p class="text-slate-500 text-lg md:text-xl mb-10 max-w-2xl mx-auto leading-relaxed"> <p class="text-slate-400 text-xl mb-12 max-w-2xl mx-auto leading-relaxed">
ลงทะเบยนฟรนนเพอเขาถงบทเรยนพนฐาน และตดตามความคบหนาการเรยนของคณไดนท ไมาใชายแอบแฝง ลงทะเบยนฟรนนเพอเขาถงบทเรยนพนฐาน และตดตามความคบหนาการเรยนของคณไดนท ไมาใชายแอบแฝง
</p> </p>
<NuxtLink <NuxtLink

View file

@ -1,360 +0,0 @@
<script setup lang="ts">
/**
* @file recommended.vue
* @description Page displaying recommended courses.
* Matches the layout of the browse page.
*/
// Define page metadata using the landing layout (dark theme default)
definePageMeta({
layout: 'landing'
})
// Set the HTML head title for SEO
useHead({
title: 'คอร์สเรียนแนะนำ - E-Learning System'
})
// Reactive state for the search input
const searchQuery = ref('')
const { fetchCourses } = useCourse()
const { fetchCategories, categories } = useCategory()
// Helper to handle localized text
const getLocalizedText = (text: string | { th: string; en: string } | undefined) => {
if (!text) return ''
if (typeof text === 'string') return text
return text.th || text.en || ''
}
// State for selected category
const selectedCategory = ref('all')
// Fetch categories on mount
await useAsyncData('categories-list', () => fetchCategories())
// Fetch courses from API (reactive to selectedCategory)
const { data: coursesResponse, error, refresh } = await useAsyncData(
'recommended-courses-list',
() => {
const params: any = {
is_recommended: true // Only fetch recommended courses
}
console.log('Fetching recommended courses. Selected Category:', selectedCategory.value)
if (selectedCategory.value !== 'all') {
const category = categories.value.find(c => c.slug === selectedCategory.value)
console.log('Found Category:', category)
if (category) {
params.category_id = category.id
}
}
console.log('Params being sent to fetchCourses:', params)
return fetchCourses(params)
}
)
// Watch for category changes and refresh data
watch(selectedCategory, (newVal) => {
console.log('Selected Category Changed to:', newVal)
refresh()
})
// Ref for the scroll container
const categoryScroll = ref<HTMLElement | null>(null)
// Computed property for courses list from API response
const courses = computed(() => {
if (coursesResponse.value?.success) {
return coursesResponse.value.data
}
return []
})
/**
* @computed filteredCourses
* @description Filters the courses list based on the search query.
* Checks both the course title and description (case-insensitive).
*/
const filteredCourses = computed(() => {
const list = courses.value || []
if (!searchQuery.value) return list
const query = searchQuery.value.toLowerCase()
return list.filter(c => {
const title = getLocalizedText(c.title).toLowerCase()
const desc = getLocalizedText(c.description).toLowerCase()
return title.includes(query) || desc.includes(query)
})
})
</script>
<template>
<!-- Main Container: Dark Theme Base -->
<div class="relative min-h-screen text-slate-600 bg-slate-50 transition-colors">
<!-- ==========================================
BACKGROUND EFFECTS
Ambient glows matching the index.vue theme
========================================== -->
<div class="fixed inset-0 overflow-hidden pointer-events-none -z-10">
<div class="absolute top-[-20%] right-[-10%] w-[60%] h-[60%] rounded-full bg-blue-600/10 blur-[140px] animate-pulse-slow"/>
<div class="absolute bottom-[-20%] left-[-10%] w-[60%] h-[60%] rounded-full bg-indigo-600/10 blur-[140px] animate-pulse-slow" style="animation-delay: 3s;"/>
</div>
<!-- ==========================================
HERO SECTION
Title and subtitle area
========================================== -->
<section class="relative pt-32 pb-20 px-6 overflow-hidden">
<div class="container mx-auto max-w-6xl text-center relative z-10">
<!-- Tagline Badge -->
<!-- Main Title -->
<h1 class="text-4xl md:text-6xl font-black text-slate-900 mb-6 tracking-tight py-2 slide-up leading-[1.2] overflow-visible" style="animation-delay: 0.1s;">
คอรสเรยน<span class="text-gradient-cyan">แนะนำ</span>
</h1>
<!-- Subtitle -->
<p class="text-slate-400 text-xl max-w-2xl mx-auto leading-relaxed slide-up" style="animation-delay: 0.2s;">
ดสรรคอรสเรยนทดเพอคณโดยเฉพาะ ยกระดบทกษะของคณดวยเนอหาคณภาพสงทเราแนะนำ
</p>
</div>
</section>
<!-- ==========================================
SEARCH & GRID SECTION
========================================== -->
<section class="container mx-auto max-w-[1440px] px-6 pb-20">
<!-- Content Frame Container -->
<div class="glass-premium rounded-[3rem] p-8 md:p-12 shadow-xl shadow-blue-900/5">
<!-- New Enhanced Search Section (Image 2 Style) -->
<div class="bg-blue-50/50 rounded-[2.5rem] p-8 md:p-10 mb-6 border border-blue-100/50">
<h2 class="text-2xl md:text-3xl font-black text-slate-900 mb-2">คอรสเรยนแนะนำ</h2>
<p class="text-slate-500 font-medium mb-8">ดสรรเนอหาคณภาพสงทณไมควรพลาด</p>
<div class="flex flex-col md:flex-row gap-4">
<!-- Search Input -->
<div class="relative flex-1 group">
<div class="absolute left-5 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-blue-600 transition-colors">
<q-icon name="search" size="24px" />
</div>
<input
v-model="searchQuery"
type="text"
placeholder="ค้นหาชื่อคอร์สแนะนำ..."
class="w-full pl-14 pr-6 py-4 bg-white border-2 border-transparent rounded-2xl text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blue-500/20 focus:ring-4 focus:ring-blue-500/5 transition-all text-lg font-medium shadow-sm"
/>
</div>
<!-- Search Button -->
<q-btn
unelevated
color="primary"
class="px-4 h-16 rounded-2xl font-black shadow-lg shadow-blue-600/20 hover:scale-[1.02] transition-transform"
no-caps
>
<div class="flex items-center gap-2">
<q-icon name="search" size="20px" />
<span class="text-base">นหา</span>
</div>
</q-btn>
</div>
</div>
<!-- Category Filter Tabs with Scroll Buttons -->
<div class="relative mb-8">
<!-- Left Scroll Button -->
<button
class="absolute left-0 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-white shadow-md border border-slate-100 flex items-center justify-center text-slate-500 hover:text-blue-600 transition-all md:hidden"
@click="categoryScroll?.scrollBy({ left: -200, behavior: 'smooth' })"
>
<q-icon name="chevron_left" size="24px" />
</button>
<!-- Scrollable Container -->
<div
ref="categoryScroll"
class="flex items-center gap-3 overflow-x-auto pb-2 no-scrollbar px-1 scroll-smooth"
>
<button
class="px-6 py-2.5 rounded-full font-bold text-sm transition-all whitespace-nowrap border-2 flex-shrink-0"
:class="selectedCategory === 'all' ? 'bg-blue-600 text-white border-blue-600 shadow-lg shadow-blue-600/30' : 'bg-white border-slate-100 text-slate-500 hover:border-slate-300'"
@click="selectedCategory = 'all'"
>
All
</button>
<button
v-for="category in categories"
:key="category.id"
class="px-6 py-2.5 rounded-full font-bold text-sm transition-all whitespace-nowrap border-2 flex-shrink-0"
:class="selectedCategory === category.slug ? 'bg-blue-600 text-white border-blue-600 shadow-lg shadow-blue-600/30' : 'bg-white border-slate-100 text-slate-500 hover:border-slate-300'"
@click="selectedCategory = category.slug"
>
{{ getLocalizedText(category.name) }}
</button>
</div>
<!-- Right Scroll Button -->
<button
class="absolute right-0 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-white shadow-md border border-slate-100 flex items-center justify-center text-slate-500 hover:text-blue-600 transition-all md:hidden"
@click="categoryScroll?.scrollBy({ left: 200, behavior: 'smooth' })"
>
<q-icon name="chevron_right" size="24px" />
</button>
</div>
<!-- Course Grid (4 cols) -->
<div v-if="filteredCourses.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div
v-for="(course, index) in filteredCourses"
:key="course.id"
class="glass-card group flex flex-col h-full hover:-translate-y-2 transition-transform duration-500 slide-up"
:style="{ animationDelay: `${0.1 * (index + 1)}s` }"
>
<!-- Card Image -->
<div class="h-48 bg-gradient-to-br from-slate-800 to-slate-900 relative overflow-hidden group-hover:opacity-90 transition-opacity">
<!-- Recommended Badge -->
<img
v-if="course.thumbnail_url"
:src="course.thumbnail_url"
:alt="getLocalizedText(course.title)"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
/>
<div
v-else
class="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-slate-800 to-slate-900"
>
</div>
</div>
<!-- Card Content Body -->
<div class="p-6 flex-1 flex flex-col border-t border-slate-100 ">
<h3 class="text-xl font-bold text-slate-900 mb-2 leading-snug group-hover:text-blue-600 transition-colors line-clamp-2">
{{ getLocalizedText(course.title) }}
</h3>
<p class="text-slate-500 text-xs mb-6 line-clamp-2 leading-relaxed flex-1">
{{ getLocalizedText(course.description) }}
</p>
<!-- Card Footer -->
<div class="pt-4 border-t border-slate-100 flex items-center justify-between mt-auto">
<span class="text-lg font-black text-blue-600 tracking-tight">
{{ course.is_free ? 'ฟรี' : course.price }}
</span>
<NuxtLink
:to="`/course/${course.id}`"
class="px-4 py-2 bg-slate-50 hover:bg-blue-600 text-slate-600 hover:text-white rounded-lg text-xs font-bold transition-all border border-slate-200 hover:border-blue-500/50"
>
รายละเอยด
</NuxtLink>
</div>
</div>
</div>
</div>
<!-- Empty State (No Results) -->
<div v-else class="text-center py-20">
<div class="text-6xl mb-6 opacity-50 animate-bounce">💎</div>
<h2 class="text-2xl font-black text-slate-900 mb-3">ไมพบคอรสแนะนำในขณะน</h2>
<p class="text-slate-400 mb-8 max-w-md mx-auto">
ลองเลอกหมวดหม หรอกลบมาดใหมภายหล
</p>
<button
class="px-6 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition-all shadow-lg shadow-blue-600/20"
@click="searchQuery = ''; selectedCategory = 'all'"
>
างคาการคนหา
</button>
</div>
</div>
</section>
<!-- ==========================================
CTA SECTION
========================================== -->
<section class="py-24 relative overflow-hidden">
<!-- Background Decoration -->
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-blue-50/80 pointer-events-none -z-10"/>
<div class="absolute bottom-0 left-1/2 -translate-x-1/2 w-[800px] h-[400px] bg-blue-400/10 blur-[120px] rounded-full -z-10 pointer-events-none"/>
<div class="container mx-auto max-w-4xl text-center relative z-10 px-6">
<h2 class="text-4xl md:text-5xl font-black text-slate-900 mb-6 tracking-tight">
ปลดลอกศกยภาพของคณวนน
</h2>
<p class="text-slate-500 text-lg md:text-xl mb-10 max-w-2xl mx-auto leading-relaxed">
เรมเรยนรบคอรสทเราแนะนำ และเตบโตไปพรอมกบผเรยนนบหมนคน
</p>
<NuxtLink
to="/auth/register"
class="inline-flex items-center gap-3 px-10 py-5 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white rounded-2xl text-lg font-black shadow-2xl shadow-blue-900/40 hover:scale-105 transition-all duration-300"
>
<span></span>
<span>สมครสมาชกฟร</span>
</NuxtLink>
</div>
</section>
</div>
</template>
<style scoped>
/*
MATCHING INDEX.VUE STYLES
Adjusted to Pink/Rose theme for "Recommended" feel
*/
/* Gradient Text Effect (Cyan to Blue) */
.text-gradient-cyan {
background: linear-gradient(135deg, #22d3ee 0%, #3b82f6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
display: inline-block;
padding: 0.5em 0.2em;
margin: -0.5em -0.2em;
vertical-align: baseline;
}
/* Premium Glass Effect */
.glass-premium {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(40px);
border: 1px solid rgba(255, 255, 255, 0.5);
}
/* Glass Card Style */
.glass-card {
background: white;
border: 1px solid rgba(0, 0, 0, 0.05);
border-radius: 2rem;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
}
/* Animations */
@keyframes pulse-slow {
0%, 100% { opacity: 0.1; transform: scale(1); }
50% { opacity: 0.15; transform: scale(1.15); }
}
.animate-pulse-slow {
animation: pulse-slow 10s linear infinite;
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
.slide-up {
animation: slide-up 0.8s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
opacity: 0;
}
</style>

View file

@ -90,7 +90,6 @@ const isPlaying = ref(false)
const videoProgress = ref(0) const videoProgress = ref(0)
const currentTime = ref(0) const currentTime = ref(0)
const duration = ref(0) const duration = ref(0)
const activeTab = ref('content')
@ -196,9 +195,9 @@ const resetAndNavigate = (path: string) => {
// 2. Clear all localStorage // 2. Clear all localStorage
localStorage.clear() localStorage.clear()
// 3. Restore ONLY whitelisted keys // 3. Restore whitelisted keys
Object.keys(whitelist).forEach(key => { Object.entries(whitelist).forEach(([key, value]) => {
localStorage.setItem(key, whitelist[key]) localStorage.setItem(key, value)
}) })
// 4. Force hard reload to the new path // 4. Force hard reload to the new path
@ -279,12 +278,6 @@ const loadLesson = async (lessonId: number) => {
isPlaying.value = false isPlaying.value = false
videoProgress.value = 0 videoProgress.value = 0
currentTime.value = 0 currentTime.value = 0
initialSeekTime.value = 0
maxWatchedTime.value = 0
lastSavedTime.value = -1
lastSavedTimestamp.value = 0
lastLocalSaveTimestamp.value = 0
currentDuration.value = 0
currentLesson.value = null // This will unmount VideoPlayer and hide content currentLesson.value = null // This will unmount VideoPlayer and hide content
isLessonLoading.value = true isLessonLoading.value = true
@ -339,29 +332,21 @@ const loadLesson = async (lessonId: number) => {
// 2. Fetch Initial Progress (Resume Playback) // 2. Fetch Initial Progress (Resume Playback)
if (currentLesson.value.type === 'VIDEO') { if (currentLesson.value.type === 'VIDEO') {
// If already completed, clear local resume point to allow fresh re-watch // A. Server Progress
const isCompleted = currentLesson.value.progress?.is_completed || false
if (isCompleted) {
const key = getLocalProgressKey(lessonId)
if (key && typeof window !== 'undefined') {
localStorage.removeItem(key)
}
initialSeekTime.value = 0
maxWatchedTime.value = 0
currentTime.value = 0
} else {
// Not completed? Resume from where we left off
const progressRes = await fetchVideoProgress(lessonId) const progressRes = await fetchVideoProgress(lessonId)
let serverProgress = 0 let serverProgress = 0
if (progressRes.success && progressRes.data?.video_progress_seconds) { if (progressRes.success && progressRes.data?.video_progress_seconds) {
serverProgress = progressRes.data.video_progress_seconds serverProgress = progressRes.data.video_progress_seconds
} }
// B. Local Progress (Buffer)
const localProgress = getLocalProgress(lessonId) const localProgress = getLocalProgress(lessonId)
// C. Hybrid Resume (Max Wins)
const resumeTime = Math.max(serverProgress, localProgress) const resumeTime = Math.max(serverProgress, localProgress)
if (resumeTime > 0) { if (resumeTime > 0) {
initialSeekTime.value = resumeTime initialSeekTime.value = resumeTime
maxWatchedTime.value = resumeTime maxWatchedTime.value = resumeTime
currentTime.value = resumeTime currentTime.value = resumeTime
@ -371,7 +356,6 @@ const loadLesson = async (lessonId: number) => {
} }
} }
} }
}
} catch (error) { } catch (error) {
console.error('Error loading lesson:', error) console.error('Error loading lesson:', error)
} finally { } finally {
@ -572,19 +556,13 @@ const videoSrc = computed(() => {
// (Complete) // (Complete)
const onVideoEnded = async () => { const onVideoEnded = async () => {
// Safety check before saving
const lesson = currentLesson.value const lesson = currentLesson.value
if (!lesson) return if (!lesson || !lesson.progress || lesson.progress.is_completed || isCompleting.value) return
// Clear local storage on end since it's completed
const key = getLocalProgressKey(lesson.id)
if (key && typeof window !== 'undefined') {
localStorage.removeItem(key)
}
if (lesson.progress?.is_completed || isCompleting.value) return
isCompleting.value = true isCompleting.value = true
try { try {
// Force save progress at 100% to trigger backend completion
await performSaveProgress(true, false) await performSaveProgress(true, false)
} catch (err) { } catch (err) {
console.error('Failed to save progress on end:', err) console.error('Failed to save progress on end:', err)
@ -605,67 +583,60 @@ onBeforeUnmount(() => {
</script> </script>
<template> <template>
<q-layout view="hHh lpR lFf" class="bg-[var(--bg-body)] text-[var(--text-main)]"> <q-layout view="hHh LpR lFf" class="bg-[var(--bg-body)] text-[var(--text-main)]">
<!-- Header --> <!-- Header -->
<q-header bordered class="bg-[var(--bg-surface)] border-b border-gray-200 dark:border-white/5 text-[var(--text-main)] h-16"> <q-header bordered class="bg-[var(--bg-surface)] border-b border-gray-200 dark:border-white/5 text-[var(--text-main)] h-14">
<q-toolbar class="h-full px-4"> <q-toolbar>
<!-- 1. Left Side: Back & Title --> <!-- Exit/Back Button -->
<div class="flex items-center gap-4 flex-grow overflow-hidden">
<!-- Back Button -->
<q-btn <q-btn
flat flat
round rounded
dense no-caps
color="primary" color="primary"
class="bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors" class="mr-4 bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white font-bold hover:bg-slate-200"
@click="handleExit('/dashboard/my-courses')" @click="handleExit('/dashboard/my-courses')"
> >
<q-icon name="arrow_back" size="20px" /> <q-icon name="close" size="18px" class="mr-1.5" />
<span class="hidden sm:inline">{{ $t('common.close') }}</span>
<q-tooltip>{{ $t('classroom.backToDashboard') }}</q-tooltip> <q-tooltip>{{ $t('classroom.backToDashboard') }}</q-tooltip>
</q-btn> </q-btn>
<!-- Course Title --> <!-- Sidebar Toggle (Clearer for Course Content) -->
<div class="flex flex-col">
<h1 class="text-base md:text-lg font-bold text-slate-900 dark:text-white truncate max-w-[200px] md:max-w-md leading-tight">
{{ courseData ? getLocalizedText(courseData.course.title) : $t('classroom.loadingTitle') }}
</h1>
</div>
</div>
<!-- 2. Right Side: Actions -->
<div class="flex items-center gap-3">
<!-- Sidebar Toggle (Right Side) -->
<q-btn <q-btn
flat flat
round rounded
dense no-caps
class="text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" class="mr-2 text-slate-900 dark:text-white hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors font-bold px-3"
@click="toggleSidebar" @click="toggleSidebar"
> >
<q-icon name="menu_open" size="24px" class="transform rotate-180" /> <q-icon name="format_list_bulleted" size="18px" class="mr-1.5" />
<q-tooltip>{{ $t('classroom.curriculum') }}</q-tooltip> <span class="hidden md:inline">{{ $t('classroom.curriculum') }}</span>
</q-btn> </q-btn>
<!-- Announcements Button (Refined) --> <q-toolbar-title class="text-base font-bold text-left truncate text-slate-900 dark:text-white">
{{ courseData ? getLocalizedText(courseData.course.title) : $t('classroom.loadingTitle') }}
</q-toolbar-title>
<div class="flex items-center gap-2 pr-2">
<!-- Announcements Button -->
<q-btn <q-btn
flat flat
round round
dense dense
class="bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-slate-700 transition-all relative overflow-visible" icon="campaign"
@click="handleOpenAnnouncements" @click="handleOpenAnnouncements"
class="text-slate-600 dark:text-slate-300 hover:text-blue-600 transition-colors"
> >
<q-icon name="campaign" size="22px" /> <q-badge v-if="hasUnreadAnnouncements" color="red" floating rounded />
<!-- Red Dot Notification -->
<span v-if="hasUnreadAnnouncements" class="absolute top-2 right-2 w-2.5 h-2.5 bg-rose-500 border-2 border-white dark:border-slate-900 rounded-full"></span>
<q-tooltip>{{ $t('classroom.announcements') }}</q-tooltip> <q-tooltip>{{ $t('classroom.announcements') }}</q-tooltip>
</q-btn> </q-btn>
</div> </div>
</q-toolbar> </q-toolbar>
</q-header> </q-header>
<!-- Sidebar (Curriculum) - Positioned Right via component prop --> <!-- Sidebar (Curriculum) -->
<!-- Sidebar (Curriculum) -->
<CurriculumSidebar <CurriculumSidebar
v-model="sidebarOpen" v-model="sidebarOpen"
:courseData="courseData" :courseData="courseData"
@ -680,7 +651,7 @@ onBeforeUnmount(() => {
<q-page-container class="bg-white dark:bg-slate-900"> <q-page-container class="bg-white dark:bg-slate-900">
<q-page class="flex flex-col h-full bg-slate-50 dark:bg-[#0B0F1A]"> <q-page class="flex flex-col h-full bg-slate-50 dark:bg-[#0B0F1A]">
<!-- Video Player & Content Area --> <!-- Video Player & Content Area -->
<div class="w-full h-full p-4 md:p-6 flex-grow overflow-y-auto"> <div class="w-full max-w-7xl mx-auto p-4 md:p-6 flex-grow">
<!-- 1. LOADING STATE (Comprehensive Skeleton) --> <!-- 1. LOADING STATE (Comprehensive Skeleton) -->
<div v-if="isLessonLoading" class="animate-fade-in"> <div v-if="isLessonLoading" class="animate-fade-in">
<!-- Video Skeleton --> <!-- Video Skeleton -->

View file

@ -1,387 +1,94 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file index.vue * @file home.vue
* @description Dashboard Home Page matching FutureSkill design * @description หนาแดชบอรดหล (Dashboard)
* แสดงขอความตอนร และคอรสแนะนำสำหรบผเรยน
*/ */
definePageMeta({ definePageMeta({
layout: "default", layout: 'default',
middleware: "auth", middleware: 'auth'
}); })
useHead({ useHead({
title: "Dashboard - FutureSkill Clone", title: 'Dashboard - e-Learning'
}); })
const { currentUser } = useAuth(); const { currentUser } = useAuth()
const { fetchCourses, fetchEnrolledCourses, getLocalizedText } = useCourse(); const { fetchCourses, getLocalizedText } = useCourse() // Import useCourse
const { fetchCategories } = useCategory(); const { fetchCategories } = useCategory() // Import useCategory
const { t } = useI18n();
// State const { t } = useI18n()
const enrolledCourses = ref<any[]>([]);
const recommendedCourses = ref<any[]>([]);
const libraryCourses = ref<any[]>([]);
const categories = ref<any[]>([]);
const isLoading = ref(true);
// Initial Data Fetch
// Recommended Courses State
// ( 3 )
const recommendedCourses = ref<any[]>([])
onMounted(async () => { onMounted(async () => {
isLoading.value = true; // 1. Fetch Categories for mapping
try { const catRes = await fetchCategories()
const [catRes, enrollRes, courseRes] = await Promise.all([ const catMap = new Map()
fetchCategories(),
fetchEnrolledCourses({ limit: 10 }), // Fetch more enrolled courses for library section
fetchCourses({
limit: 3,
random: true,
forceRefresh: true,
is_recommended: true,
}), // Fetch 3 Recommended Courses
]);
if (catRes.success) { if (catRes.success) {
categories.value = catRes.data || []; catRes.data?.forEach((c: any) => catMap.set(c.id, c.name))
} }
const catMap = new Map(); // 2. Fetch 3 Random Courses from Server
categories.value.forEach((c: any) => catMap.set(c.id, c.name)); // Server ( API parameter random limit)
const res = await fetchCourses({ random: true, limit: 3, forceRefresh: true, is_recommended: true })
// Map Enrolled Courses if (res.success && res.data?.length) {
if (enrollRes.success && enrollRes.data) { recommendedCourses.value = res.data.map((c: any) => ({
// Sort by last_accessed_at descending (Newest first)
const sortedEnrollments = [...enrollRes.data].sort((a, b) => {
const dateA = new Date(a.last_accessed_at || a.enrolled_at).getTime();
const dateB = new Date(b.last_accessed_at || b.enrolled_at).getTime();
return dateB - dateA;
});
enrolledCourses.value = sortedEnrollments.map((item: any) => ({
id: item.course_id,
title: item.course.title,
thumbnail_url: item.course.thumbnail_url,
progress: item.progress_percentage || 0,
total_lessons: item.course.total_lessons || 10,
completed_lessons: Math.floor(
(item.progress_percentage / 100) * (item.course.total_lessons || 10),
),
// For CourseCard compatibility in library section
category: catMap.get(item.course.category_id),
lessons: item.course.total_lessons || 0,
image: item.course.thumbnail_url,
enrolled: true,
}));
// Update libraryCourses with only 2 courses
libraryCourses.value = enrolledCourses.value.slice(0, 2);
}
// Map Recommended Courses
if (courseRes.success && courseRes.data) {
recommendedCourses.value = courseRes.data.map((c: any) => ({
id: c.id, id: c.id,
title: c.title, title: c.title,
category: catMap.get(c.category_id), category: catMap.get(c.category_id),
description: c.description, lessons: c.lessons,
lessons: c.total_lessons || 0, image: c.thumbnail_url || '',
image: c.thumbnail_url || "", badge: '',
rating: c.rating, badgeType: ''
price: c.price, }))
is_free: c.is_free,
}));
} }
} catch (err) { })
console.error("Failed to load dashboard data", err);
} finally {
isLoading.value = false;
}
});
// Helper for "Continue Learning" Hero Card
const heroCourse = computed(() => enrolledCourses.value[0] || null);
const sideCourses = computed(() => enrolledCourses.value.slice(1, 3));
</script> </script>
<template> <template>
<div class="bg-[#F8F9FA] dark:bg-[#020617] min-h-screen font-inter pb-20 transition-colors duration-300"> <div class="page-container !pt-4">
<div class="container mx-auto px-6 md:px-12 space-y-16 mt-10"> <!-- Welcome Header Section (Minimalist) -->
<!-- 1. Dashboard Hero Banner (Refined) --> <div class="flex items-center gap-6 mb-10 py-2 animate-fade-in">
<section <!-- Avatar with premium shadow -->
class="relative overflow-hidden bg-gradient-to-br from-white to-slate-50 dark:from-slate-900 dark:to-slate-950 rounded-[2rem] py-10 md:py-14 px-8 md:px-12 shadow-sm border border-slate-100 dark:border-slate-800 flex flex-col items-center text-center transition-colors duration-300" <div class="relative group cursor-pointer" @click="$router.push('/dashboard/profile')">
> <div class="absolute -inset-1 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-full blur opacity-25 group-hover:opacity-50 transition duration-1000 group-hover:duration-200"></div>
<!-- Subtle Decorative Elements --> <q-avatar size="84px" class="relative shadow-2xl ring-4 ring-white dark:ring-slate-900 transition-all duration-500 overflow-hidden">
<div <img
class="absolute top-[-20%] left-[-10%] w-[300px] h-[300px] bg-blue-500/5 dark:bg-blue-500/10 rounded-full blur-3xl -z-10" :src="currentUser?.photoURL || 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y'"
/> class="object-cover"
<div
class="absolute bottom-[-20%] right-[-10%] w-[300px] h-[300px] bg-indigo-500/5 dark:bg-indigo-500/10 rounded-full blur-3xl -z-10"
/> />
</q-avatar>
</div>
<div class="max-w-2xl space-y-6 relative z-10"> <div class="flex flex-col">
<h1 <h1 class="text-2xl md:text-4xl font-black text-slate-900 dark:text-white tracking-tight leading-tight mb-1">
class="text-3xl md:text-4xl lg:text-5xl font-bold text-slate-900 dark:text-white leading-[1.5] tracking-tight" {{ $t('dashboard.welcomeTitle') }}, {{ currentUser?.firstName }}!
>
{{ $t("dashboard.heroTitle") }}
<span class="inline-block text-blue-600 dark:text-blue-400 mt-1 md:mt-2">{{
$t("dashboard.heroSubtitle")
}}</span>
</h1> </h1>
<div class="flex items-center gap-2">
<p <span class="text-slate-500 dark:text-slate-400 text-sm font-medium">{{ $t('dashboard.welcomeSubtitle') }}</span>
class="text-slate-500 dark:text-slate-400 font-medium text-base md:text-lg max-w-xl mx-auto leading-relaxed"
>
{{ $t("dashboard.heroDesc") }}
</p>
<div class="flex flex-wrap justify-center gap-4 pt-4">
<q-btn
unelevated
rounded
color="primary"
:label="$t('dashboard.goToMyCourses')"
class="px-8 h-[48px] font-bold no-caps shadow-lg shadow-blue-500/10 hover:-translate-y-0.5 transition-all text-sm"
to="/dashboard/my-courses"
/>
<q-btn
outline
rounded
color="primary"
:label="$t('dashboard.searchNewCourses')"
class="px-8 h-[48px] font-bold no-caps hover:bg-white dark:hover:bg-slate-800 transition-all border-1 text-sm dark:text-white dark:border-slate-600"
style="border-width: 1.5px"
to="/browse/discovery"
/>
</div>
</div>
</section>
<!-- 2. Continue Learning Section -->
<section v-if="enrolledCourses.length > 0">
<div class="flex justify-between items-end mb-6">
<h2 class="text-xl md:text-2xl font-bold text-[#2D2D2D] dark:text-white transition-colors">
{{ $t("dashboard.continueLearningTitle") }}
</h2>
<NuxtLink
to="/dashboard/my-courses"
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 font-medium text-sm flex items-center gap-1 transition-colors"
>
{{ $t("dashboard.myCourses") }}
<q-icon name="arrow_forward" size="16px" />
</NuxtLink>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
<!-- Hero Card (Left) -->
<div
v-if="heroCourse"
class="relative group cursor-pointer rounded-2xl overflow-hidden bg-white dark:bg-[#1e293b] shadow-sm border border-gray-100 dark:border-slate-700 hover:shadow-md transition-all h-[260px] md:h-[320px]"
@click="
navigateTo(`/classroom/learning?course_id=${heroCourse.id}`)
"
>
<img
:src="heroCourse.thumbnail_url"
class="w-full h-full object-cover brightness-75 group-hover:brightness-90 transition-all duration-500"
/>
<div
class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent p-8 flex flex-col justify-end"
>
<h3
class="text-white text-2xl font-bold mb-4 line-clamp-2 leading-snug shadow-black/50 drop-shadow-sm"
>
{{ getLocalizedText(heroCourse.title) }}
</h3>
<!-- Progress -->
<div class="w-full">
<div class="flex justify-end text-gray-300 text-xs mb-2">
<span>{{ heroCourse.progress }}%</span>
</div>
<div
class="h-1.5 w-full bg-white/20 rounded-full overflow-hidden"
>
<div
class="h-full rounded-full transition-all duration-500"
:class="
heroCourse.progress === 100
? 'bg-emerald-500'
: 'bg-blue-500'
"
:style="{ width: `${heroCourse.progress}%` }"
></div>
</div>
<div class="mt-4 flex justify-end">
<span
class="font-bold text-sm hover:underline transition-colors"
:class="
heroCourse.progress === 100
? 'text-emerald-400'
: 'text-white'
"
>
{{
heroCourse.progress === 100
? $t("dashboard.studyAgain")
: $t("dashboard.continue")
}}
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Side List (Right) --> <!-- Main Content Area -->
<div class="flex flex-col gap-4"> <div>
<div <!-- Section: Recommended Courses -->
v-for="course in sideCourses"
:key="course.id"
class="flex-1 bg-white dark:!bg-slate-900/40 rounded-2xl p-4 border border-slate-100 dark:border-white/5 shadow-sm hover:shadow-md transition-all flex gap-4 items-center"
>
<div class="w-32 h-20 rounded-xl overflow-hidden flex-shrink-0">
<img
:src="course.thumbnail_url"
class="w-full h-full object-cover"
/>
</div>
<div
class="flex-grow min-w-0 flex flex-col justify-between h-full py-1"
>
<h4 class="text-gray-800 dark:text-slate-200 font-bold text-sm line-clamp-2 mb-2 transition-colors">
{{ getLocalizedText(course.title) }}
</h4>
<div class="mt-auto">
<div
class="h-1.5 w-full bg-gray-100 dark:bg-slate-700 rounded-full overflow-hidden mb-2"
>
<div
class="h-full rounded-full transition-all duration-500"
:class="
course.progress === 100
? 'bg-emerald-500'
: 'bg-blue-600'
"
:style="{ width: `${course.progress}%` }"
></div>
</div>
<div class="flex justify-end items-center text-xs">
<span
class="font-bold cursor-pointer hover:underline transition-colors"
:class="
course.progress === 100
? 'text-emerald-600 dark:text-emerald-400'
: 'text-blue-600 dark:text-blue-400'
"
@click="
navigateTo(`/classroom/learning?course_id=${course.id}`)
"
>
{{
course.progress === 100
? $t("dashboard.studyAgain")
: $t("dashboard.continue")
}}
</span>
</div>
</div>
</div>
</div>
<!-- Empty State Placeholder if less than 2 side courses -->
<div
v-if="sideCourses.length < 2"
class="flex-1 bg-slate-50 dark:!bg-slate-900/30 rounded-2xl border border-dashed border-slate-200 dark:border-slate-800 flex items-center justify-center text-slate-400 dark:text-slate-600 text-sm transition-colors"
>
{{ $t("dashboard.startNewCourse") }}
</div>
</div>
</div>
</section>
<!-- 3. Knowledge Library -->
<section>
<div class="mb-6"> <div class="mb-6">
<h2 class="text-xl md:text-2xl font-bold text-[#2D2D2D] dark:text-white mb-1 transition-colors"> <h2 class="text-xl font-black flex items-center gap-3 tracking-tight text-slate-900 dark:text-white">
{{ $t("dashboard.knowledgeLibrary") }} <span class="w-1 h-6 bg-emerald-500 rounded-full shadow-sm shadow-emerald-500/50"/>
</h2> {{ $t('menu.recommendedCourses') }}
<p class="text-gray-500 dark:text-slate-400 text-sm transition-colors">
{{ $t("dashboard.libraryDesc") }}
</p>
</div>
<!-- Content when courses exist -->
<div
v-if="libraryCourses.length > 0"
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6"
>
<!-- Course Cards -->
<CourseCard
v-for="course in libraryCourses"
:key="course.id"
v-bind="course"
:image="course.thumbnail_url"
hide-progress
hide-actions
class="h-full md:col-span-1"
/>
<div
class="bg-white dark:!bg-slate-900/40 rounded-3xl border border-slate-100 dark:border-white/5 shadow-sm p-8 flex flex-col items-center justify-center text-center h-full min-h-[300px] hover:shadow-md transition-all group"
>
<p class="text-gray-600 dark:text-slate-300 font-medium mb-6 mt-4 transition-colors">
{{ $t("dashboard.chooseLibrary") }}
</p>
<q-btn
flat
rounded
no-caps
class="text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/30 px-6 py-2 font-bold group-hover:scale-105 transition-transform"
to="/dashboard/my-courses"
>
{{ $t("dashboard.viewAll") }}
<q-icon name="arrow_forward" size="18px" class="ml-2" />
</q-btn>
</div>
</div>
<div
v-else
class="bg-white dark:!bg-slate-900/40 rounded-3xl border border-dashed border-slate-200 dark:border-slate-800 p-12 flex flex-col items-center justify-center text-center min-h-[300px] transition-colors"
>
<div class="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-full mb-6 transition-colors">
<q-icon name="school" size="48px" class="text-blue-200 dark:text-blue-400" />
</div>
<h3 class="text-xl font-bold text-gray-800 dark:text-white mb-2 transition-colors">
{{ $t("dashboard.emptyLibraryTitle") }}
</h3>
<p class="text-gray-500 dark:text-slate-400 mb-8 max-w-md transition-colors">
{{ $t("dashboard.emptyLibraryDesc") }}
</p>
<q-btn
unelevated
rounded
no-caps
class="bg-blue-600 text-white px-8 py-3 font-bold hover:bg-blue-700 shadow-lg shadow-blue-500/20 transition-all hover:scale-105"
to="/browse/discovery"
>
{{ $t("dashboard.viewAllCourses") }}
</q-btn>
</div>
</section>
<!-- 5. Recommended Courses -->
<section class="pb-20">
<div class="mb-6">
<h2 class="text-xl md:text-2xl font-bold text-[#2D2D2D] dark:text-white text-left transition-colors">
{{ $t("dashboard.recommendedCourses") }}
</h2> </h2>
</div> </div>
<!-- Recommended Grid (3 columns) --> <!-- Recommended Grid -->
<div <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 pb-20">
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 animate-fade-in"
>
<CourseCard <CourseCard
v-for="course in recommendedCourses" v-for="course in recommendedCourses"
:key="course.id" :key="course.id"
@ -389,35 +96,21 @@ const sideCourses = computed(() => enrolledCourses.value.slice(1, 3));
/> />
</div> </div>
<!-- Loading State --> <!-- Loading State for Recommended -->
<div <div v-if="recommendedCourses.length === 0" class="flex justify-center py-10 opacity-50">
v-if="recommendedCourses.length === 0 && !isLoading" <div class="animate-pulse">Loading recommendations...</div>
class="flex justify-center py-10 opacity-50"
>
<div class="text-gray-400 dark:text-slate-500">{{ $t("dashboard.noRecommended") }}</div>
</div> </div>
</section>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
/* Scoped specific styles */ @keyframes fade-in {
.animate-fade-in { from { opacity: 0; transform: translateX(-20px); }
animation: fadeIn 0.5s ease-out; to { opacity: 1; transform: translateX(0); }
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
:deep(.q-btn) { .animate-fade-in {
text-transform: none; /* Prevent uppercase in Q-Btns */ animation: fade-in 0.8s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
} }
</style> </style>

View file

@ -11,10 +11,8 @@ definePageMeta({
middleware: 'auth' middleware: 'auth'
}) })
const { t, locale } = useI18n()
useHead({ useHead({
title: `${t('sidebar.myCourses')} - e-Learning` title: 'คอร์สของฉัน - e-Learning'
}) })
const route = useRoute() const route = useRoute()
@ -30,6 +28,7 @@ onMounted(() => {
} }
}) })
const { locale } = useI18n()
// Helper to get localized text // Helper to get localized text
const getLocalizedText = (text: string | { th: string; en: string } | undefined) => { const getLocalizedText = (text: string | { th: string; en: string } | undefined) => {
@ -152,43 +151,33 @@ const validCourseId = computed(() => {
<!-- Page Header & Filters (Unified Layout) --> <!-- Page Header & Filters (Unified Layout) -->
<!-- New Enhanced Search Section (Image 2 Style) --> <div class="flex flex-col gap-6 mb-10">
<div class="bg-blue-50/50 dark:bg-blue-900/10 rounded-[2.5rem] p-8 md:p-10 mb-6 border border-blue-100/50 dark:border-blue-500/10"> <div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
<h2 class="text-2xl md:text-3xl font-black text-slate-900 dark:text-white mb-2">{{ $t('myCourses.title') }}</h2> <h1 class="text-3xl font-black text-slate-900 dark:text-white flex items-center gap-3">
<p class="text-slate-500 dark:text-slate-400 font-medium mb-8">{{ $t('myCourses.subtitle') }}</p> <span class="w-1.5 h-8 bg-blue-600 rounded-full shadow-sm shadow-blue-500/50"></span>
{{ $t('sidebar.myCourses') }}
</h1>
<div class="flex flex-col md:flex-row gap-4">
<!-- Search Input --> <!-- Search Input -->
<div class="relative flex-1 group"> <div class="flex items-center gap-3 w-full md:w-auto">
<div class="absolute left-5 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-blue-600 transition-colors"> <q-input
<q-icon name="search" size="24px" />
</div>
<input
v-model="searchQuery" v-model="searchQuery"
type="text" dense
:placeholder="$t('myCourses.searchPlaceholder')" outlined
class="w-full pl-14 pr-6 py-3.5 bg-white dark:!bg-slate-900/80 border-2 border-transparent dark:border-white/5 rounded-2xl text-slate-900 dark:text-white placeholder-slate-400 focus:outline-none focus:border-blue-500/20 focus:ring-4 focus:ring-blue-500/5 transition-all text-base font-medium shadow-sm" rounded
/> :placeholder="$t('discovery.searchPlaceholder')"
</div> class="w-full md:w-72 search-input shadow-sm"
bg-color="transparent"
<!-- Search Button -->
<q-btn
unelevated
color="primary"
class="px-8 h-[52px] rounded-2xl font-black shadow-lg shadow-blue-600/20 hover:scale-[1.02] transition-transform"
no-caps
> >
<div class="flex items-center gap-2"> <template v-slot:prepend>
<q-icon name="search" size="20px" /> <q-icon name="search" class="text-slate-400" />
<span class="text-base">{{ $t("discovery.searchBtn") }}</span> </template>
</div> </q-input>
</q-btn>
</div> </div>
</div> </div>
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-12">
<!-- Filter Tabs (Horizontal Bar) --> <!-- Filter Tabs (Horizontal Bar) -->
<div class="bg-white dark:!bg-slate-900/50 p-1.5 rounded-2xl border border-slate-100 dark:border-white/5 inline-flex items-center gap-1 shadow-sm"> <div class="flex flex-wrap items-center gap-2">
<q-btn <q-btn
v-for="filter in ['all', 'progress', 'completed']" v-for="filter in ['all', 'progress', 'completed']"
:key="filter" :key="filter"
@ -196,8 +185,8 @@ const validCourseId = computed(() => {
flat flat
rounded rounded
dense dense
class="px-5 py-2 font-bold transition-all text-[11px] uppercase tracking-wider" class="px-4 font-bold transition-all text-xs uppercase tracking-widest"
:class="activeFilter === filter ? 'bg-blue-600 text-white shadow-md shadow-blue-600/20' : 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:!hover:bg-slate-800/50'" :class="activeFilter === filter ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' : 'bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-white/5'"
:label="$t(`myCourses.filter${filter.charAt(0).toUpperCase() + filter.slice(1)}`)" :label="$t(`myCourses.filter${filter.charAt(0).toUpperCase() + filter.slice(1)}`)"
/> />
</div> </div>
@ -207,7 +196,7 @@ const validCourseId = computed(() => {
<div v-if="isLoading" class="flex justify-center py-20"> <div v-if="isLoading" class="flex justify-center py-20">
<q-spinner size="3rem" color="primary" /> <q-spinner size="3rem" color="primary" />
</div> </div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<template v-for="course in filteredEnrolledCourses" :key="course.id"> <template v-for="course in filteredEnrolledCourses" :key="course.id">
<!-- In Progress Course Card --> <!-- In Progress Course Card -->
<CourseCard <CourseCard
@ -237,7 +226,7 @@ const validCourseId = computed(() => {
</div> </div>
<!-- Empty State --> <!-- Empty State -->
<div v-if="!isLoading && enrolledCourses.length === 0" class="flex flex-col items-center justify-center py-20 bg-white dark:!bg-slate-900/40 rounded-3xl border border-dashed border-slate-200 dark:border-white/5 mt-4"> <div v-if="!isLoading && filteredEnrolledCourses.length === 0" class="flex flex-col items-center justify-center py-20 bg-slate-50 dark:bg-slate-800/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-700 mt-4">
<q-icon v-if="searchQuery" name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" /> <q-icon v-if="searchQuery" name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" />
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2"> <h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">
{{ searchQuery ? $t('discovery.emptyTitle') : $t('myCourses.emptyTitle') }} {{ searchQuery ? $t('discovery.emptyTitle') : $t('myCourses.emptyTitle') }}

View file

@ -15,7 +15,6 @@ const { locale, t } = useI18n()
const isEditing = ref(false) const isEditing = ref(false)
const activeTab = ref<'general' | 'security'>('general')
const isProfileSaving = ref(false) const isProfileSaving = ref(false)
const isPasswordSaving = ref(false) const isPasswordSaving = ref(false)
const isSendingVerify = ref(false) const isSendingVerify = ref(false)
@ -203,26 +202,23 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div class="page-container bg-[#F8F9FA] dark:bg-[#020617] min-h-screen transition-colors duration-300"> <div class="page-container">
<div class="flex items-center justify-between mb-8"> <div class="flex items-center justify-between mb-8 md:mb-10">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<q-btn <q-btn
v-if="isHydrated && isEditing" v-if="isHydrated && isEditing"
flat flat
round round
icon="arrow_back" icon="arrow_back"
class="text-slate-600 dark:text-white hover:bg-slate-100 dark:hover:bg-slate-800" color="slate-700"
class="dark:text-white"
@click="toggleEdit(false)" @click="toggleEdit(false)"
/> />
<div class="flex items-start gap-4"> <h1 class="text-3xl font-black text-slate-900 dark:text-white">
<div>
<h1 class="text-3xl md:text-4xl font-black text-slate-900 dark:text-white leading-tight">
{{ (isHydrated && isEditing) ? $t('profile.editProfile') : $t('profile.myProfile') }} {{ (isHydrated && isEditing) ? $t('profile.editProfile') : $t('profile.myProfile') }}
</h1> </h1>
</div> </div>
</div>
</div>
<div class="min-h-9 flex items-center"> <div class="min-h-9 flex items-center">
<q-btn <q-btn
@ -230,7 +226,7 @@ onMounted(async () => {
unelevated unelevated
rounded rounded
color="primary" color="primary"
class="font-bold shadow-lg shadow-blue-500/20" class="font-bold"
icon="edit" icon="edit"
:label="$t('profile.editProfile')" :label="$t('profile.editProfile')"
@click="toggleEdit(true)" @click="toggleEdit(true)"
@ -246,105 +242,44 @@ onMounted(async () => {
<q-spinner size="3rem" color="primary" /> <q-spinner size="3rem" color="primary" />
</div> </div>
<div v-else class="max-w-4xl mx-auto pb-20"> <div v-else>
<div v-if="!isEditing" class="card-premium overflow-hidden fade-in">
<!-- VIEW MODE: Premium Card with Banner --> <div class="bg-gradient-to-r from-blue-600 to-indigo-600 h-32 w-full"/>
<div v-if="!isEditing" class="bg-white dark:!bg-slate-900/50 border border-slate-200 dark:border-white/5 rounded-3xl shadow-xl dark:shadow-none overflow-hidden fade-in min-h-[500px] flex flex-col transition-colors duration-300"> <div class="px-8 pb-10 -mt-16">
<div class="flex flex-col md:flex-row items-end gap-6 mb-10">
<!-- Identity Header (Banner & Avatar) --> <div class="relative flex-shrink-0">
<div class="relative">
<div class="h-40 bg-gradient-to-r from-blue-700 via-blue-600 to-indigo-700 relative overflow-hidden">
<!-- Abstract Patterns -->
<div class="absolute inset-0 opacity-10">
<div class="absolute -top-10 -right-10 w-64 h-64 rounded-full bg-white blur-3xl"></div>
<div class="absolute -bottom-10 -left-10 w-48 h-48 rounded-full bg-indigo-300 blur-3xl"></div>
</div>
</div>
<div class="px-8 md:px-12 flex flex-col md:flex-row items-center md:items-end gap-8 md:gap-12 -mt-12 pb-8 relative z-10">
<div class="relative group flex-shrink-0">
<UserAvatar <UserAvatar
:photo-u-r-l="userData.photoURL" :photo-u-r-l="userData.photoURL"
:first-name="userData.firstName" :first-name="userData.firstName"
:last-name="userData.lastName" :last-name="userData.lastName"
size="140" size="128"
class="border-[6px] border-white dark:border-slate-900 shadow-2xl rounded-[2.5rem] bg-white dark:bg-slate-800 transition-colors duration-300" class="border-4 border-white dark:border-[#1e293b] shadow-2xl bg-slate-800"
/> />
</div>
<div class="pb-2">
<h2 class="text-3xl font-black text-slate-900 dark:text-white mb-1">{{ userData.firstName }} {{ userData.lastName }}</h2>
<p class="text-slate-500 dark:text-slate-400 font-medium">{{ userData.email }}</p>
</div>
</div> </div>
<div class="text-center md:text-left pt-4 md:pt-0 flex-grow min-w-0"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<h2 class="text-3xl md:text-4xl font-black text-slate-900 dark:text-white mb-2 leading-tight tracking-tight break-words"> <div class="info-group">
{{ userData.firstName }} {{ userData.lastName }} <span class="label">{{ $t('profile.phone') }}</span>
</h2> <p class="value">{{ userData.phone || '-' }}</p>
<div class="flex flex-wrap items-center justify-center md:justify-start gap-4">
<div class="flex items-center gap-2 text-slate-500 dark:text-slate-400 font-bold bg-slate-50 dark:bg-slate-900/50 px-3 py-1.5 rounded-xl border border-slate-100 dark:border-white/5">
<q-icon name="alternate_email" size="xs" class="text-blue-500" />
<span class="text-sm">{{ userData.email }}</span>
</div>
<div class="flex items-center gap-2 text-slate-500 dark:text-slate-400 font-bold bg-slate-50 dark:bg-slate-900/50 px-3 py-1.5 rounded-xl border border-slate-100 dark:border-white/5">
<q-icon name="verified_user" size="xs" :class="userData.emailVerifiedAt ? 'text-green-500' : 'text-amber-500'" />
<span class="text-sm">{{ userData.emailVerifiedAt ? $t('profile.emailVerified') : $t('profile.verifyEmail') }}</span>
</div> </div>
<div class="info-group">
<span class="label">{{ $t('profile.joinedAt') }}</span>
<p class="value">{{ formatDate(userData.createdAt) }}</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- View Details Content -->
<div class="p-8 md:p-12 flex-grow">
<div class="max-w-3xl mx-auto h-full fade-in">
<h3 class="text-sm font-black text-slate-700 dark:text-slate-300 uppercase tracking-widest flex items-center gap-2 mb-8">
<span class="w-2 h-2 bg-blue-600 rounded-full"></span> {{ $t('profile.accountDetails') }}
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-8">
<div class="flex items-center gap-4 group">
<div class="w-12 h-12 rounded-2xl bg-blue-50 dark:bg-blue-900/20 flex items-center justify-center text-blue-600 dark:text-blue-400 group-hover:scale-110 transition-transform">
<q-icon name="smartphone" size="24px" />
</div>
<div>
<div class="text-[10px] font-black text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-0.5">{{ $t('profile.phone') }}</div>
<div class="text-lg font-bold text-slate-900 dark:text-white tracking-tight">{{ userData.phone || '-' }}</div>
</div>
</div>
<div class="flex items-center gap-4 group">
<div class="w-12 h-12 rounded-2xl bg-indigo-50 dark:bg-indigo-900/20 flex items-center justify-center text-indigo-600 dark:text-indigo-400 group-hover:scale-110 transition-transform">
<q-icon name="calendar_today" size="24px" />
</div>
<div>
<div class="text-[10px] font-black text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-0.5">{{ $t('profile.joinedAt') }}</div>
<div class="text-lg font-bold text-slate-900 dark:text-white tracking-tight">{{ formatDate(userData.createdAt) }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- EDIT MODE: Tabs and Forms (Clean Layout) -->
<div v-else class="fade-in">
<!-- Tab Selector -->
<div class="flex justify-center mb-8">
<div class="bg-white dark:!bg-slate-900/50 p-1.5 rounded-2xl flex items-center gap-1 border border-slate-200 dark:border-white/5 shadow-sm">
<button
@click="activeTab = 'general'"
class="px-6 md:px-8 py-3 rounded-xl font-black text-xs uppercase tracking-widest transition-all flex items-center gap-2"
:class="activeTab === 'general' ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 shadow-sm scale-100' : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 scale-95 opacity-70'"
>
<q-icon name="person_outline" size="18px" /> {{ $t('profile.generalInfo') }}
</button>
<button
@click="activeTab = 'security'"
class="px-6 md:px-8 py-3 rounded-xl font-black text-xs uppercase tracking-widest transition-all flex items-center gap-2"
:class="activeTab === 'security' ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 shadow-sm scale-100' : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 scale-95 opacity-70'"
>
<q-icon name="lock_open" size="18px" /> {{ $t('profile.security') }}
</button>
</div>
</div>
<!-- Edit Content --> <div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-8 fade-in">
<div class="max-w-3xl mx-auto">
<div v-if="activeTab === 'general'" class="bg-white dark:!bg-slate-900/50 border border-slate-200 dark:border-white/5 rounded-3xl shadow-xl dark:shadow-none p-6 md:p-10">
<ProfileEditForm <ProfileEditForm
v-model="userData" v-model="userData"
:loading="isProfileSaving" :loading="isProfileSaving"
@ -353,18 +288,16 @@ onMounted(async () => {
@upload="handleFileUpload" @upload="handleFileUpload"
@verify="handleSendVerifyEmail" @verify="handleSendVerifyEmail"
/> />
</div>
<div v-else class="bg-white dark:!bg-slate-900/50 border border-slate-200 dark:border-white/5 rounded-3xl shadow-xl dark:shadow-none p-6 md:p-10">
<PasswordChangeForm <PasswordChangeForm
v-model="passwordForm" v-model="passwordForm"
:loading="isPasswordSaving" :loading="isPasswordSaving"
@submit="handleUpdatePassword" @submit="handleUpdatePassword"
/> />
</div>
</div>
</div>
</div> </div>
</div>
</div> </div>
</template> </template>
@ -374,7 +307,57 @@ onMounted(async () => {
color: white; color: white;
} }
/* Removed card-premium and dark mode overrides as we used utility classes */ .card-premium {
background-color: white;
border-color: #e2e8f0;
border-radius: 1.5rem;
border-width: 1px;
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
}
.dark .card-premium {
background-color: #1e293b;
border-color: rgba(255, 255, 255, 0.05);
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.3);
}
.info-group .label {
font-size: 0.75rem;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #64748b;
display: block;
margin-bottom: 0.5rem;
}
.dark .info-group .label {
color: #94a3b8;
}
.info-group .value {
font-size: 1.125rem;
font-weight: 700;
color: #0f172a;
}
.dark .info-group .value {
color: white;
}
.premium-q-input :deep(.q-field__control) {
border-radius: 12px;
}
.dark .premium-q-input :deep(.q-field__control) {
background: #0f172a;
}
.dark .premium-q-input :deep(.q-field__native) {
color: white;
}
.dark .premium-q-input :deep(.q-field__label) {
color: #94a3b8;
}
.fade-in { .fade-in {
animation: fadeIn 0.4s ease-out forwards; animation: fadeIn 0.4s ease-out forwards;

View file

@ -6,380 +6,369 @@
*/ */
definePageMeta({ definePageMeta({
layout: 'landing', layout: 'landing',
middleware: 'auth' middleware: 'auth' // auth middleware : Login Dashboard
}) })
useHead({ useHead({
title: 'E-Learning System - ระบบการเรียนการสอนออนไลน์' title: 'E-Learning System - ระบบการเรียนการสอนออนไลน์'
}) })
import { CATEGORY_CARDS, WHY_CHOOSE_US } from '@/constants/landing'
const { fetchCategories } = useCategory()
const { fetchCourses, getLocalizedText } = useCourse()
const { user } = useAuth()
const categoryCards = CATEGORY_CARDS
const whyChooseUs = WHY_CHOOSE_US
const categories = ref<any[]>([])
const topCourses = ref<any[]>([])
const selectedCategory = ref('all')
const isLoading = ref(false)
const currentSlide = ref(0)
const courseChunks = computed(() => {
const chunkSize = 4
const chunks = []
if (!topCourses.value) return []
for (let i = 0; i < topCourses.value.length; i += chunkSize) {
chunks.push(topCourses.value.slice(i, i + chunkSize))
}
return chunks
})
const loadData = async () => {
isLoading.value = true
try {
const [catRes, courseRes] = await Promise.all([
fetchCategories(),
fetchCourses({ limit: 8, forceRefresh: true })
])
if (catRes.success) categories.value = catRes.data || []
if (courseRes.success) topCourses.value = courseRes.data || []
} catch (err) {
console.error('Failed to load landing page data:', err)
} finally {
isLoading.value = false
}
}
const goBrowse = (slug: string) => {
navigateTo({ path: '/browse', query: { category: slug } })
}
watch(selectedCategory, async (newVal) => {
isLoading.value = true
try {
const params: any = { limit: 8 }
if (newVal !== 'all') {
const category = categories.value.find(c => c.slug === newVal)
if (category) {
params.category_id = category.id
}
}
const res = await fetchCourses(params)
if (res.success) {
topCourses.value = res.data || []
currentSlide.value = 0 // Reset carousel on filter change
}
} catch (err) {
console.error('Error filtering courses:', err)
} finally {
isLoading.value = false
}
})
onMounted(() => {
loadData()
})
</script> </script>
<template> <template>
<div class="landing-page bg-white min-h-screen"> <div class="relative min-h-screen text-slate-600 bg-slate-50 transition-colors">
<!-- Hero Section --> <!-- Premium Background -->
<header class="relative pt-32 pb-16 md:pt-40 md:pb-20 overflow-hidden bg-white"> <div class="fixed inset-0 overflow-hidden pointer-events-none -z-10">
<!-- Decorative Background --> <!-- Animated Glows -->
<div class="absolute top-0 right-0 w-[45%] h-[105%] bg-blue-50/50 rounded-bl-[12rem] -z-10 animate-fade-in"/> <div class="absolute top-[-20%] right-[-10%] w-[60%] h-[60%] rounded-full bg-blue-600/10 blur-[140px] animate-pulse-slow"/>
<div class="absolute bottom-[-20%] left-[-10%] w-[60%] h-[60%] rounded-full bg-indigo-600/10 blur-[140px] animate-pulse-slow" style="animation-delay: 3s;"/>
<div class="container mx-auto px-6 md:px-12 grid grid-cols-1 md:grid-cols-2 items-center gap-16">
<div class="hero-left slide-up">
<div class="flex items-center gap-3 mb-8 text-blue-600">
<q-icon name="stars" size="28px" />
<span class="text-sm font-black tracking-widest uppercase">E-Learning Platform</span>
</div>
<h1 class="text-4xl md:text-5xl lg:text-7xl font-bold text-slate-900 leading-[1.2] mb-8 tracking-normal">
คอรสเรยนออนไลน<br><span class="text-blue-600">เพมทกษะ</span>คด
</h1>
<p class="text-slate-500 text-lg md:text-xl font-medium mb-12 leading-relaxed max-w-xl slide-up" style="animation-delay: 0.1s;">
แหลงรวมคอรสออนไลนณภาพสงทจะชวยอปสกลใหณทำงานเกงข ฒนาทกษะทตลาดตองการ พรอมใหณกาวไปขางหนาไดอยางมนใจ!
</p>
<!-- Search Bar Pill -->
<div class="flex flex-col sm:flex-row gap-4 mb-10 slide-up" style="animation-delay: 0.2s;">
<q-btn
unelevated
rounded
color="primary"
label="ดูคอร์สเรียนทั้งหมด"
class="px-10 h-16 font-black text-white text-xl shadow-xl shadow-blue-600/20 hover:scale-105 transition-transform"
no-caps
to="/browse"
/>
<q-btn
outline
rounded
color="primary"
label="สมัครสมาชิกฟรี"
class="px-10 h-16 font-black text-xl border-2 hover:bg-blue-50"
no-caps
to="/auth/register"
v-if="!user"
/>
</div>
</div> </div>
<!-- Hero Visual Showcase --> <!-- Hero Section: วนหวของหนาเว แสดงขอความตอนรบและป CTA -->
<div class="hero-right flex justify-center md:justify-end items-center slide-up" style="animation-delay: 0.2s;"> <section class="hero-section min-h-[95vh] flex items-center relative overflow-hidden pt-32 pb-20">
<div class="relative w-full max-w-xl"> <div class="container relative z-10 w-full">
<!-- Main Illustration --> <!-- Hero Card Container -->
<div class="relative z-10 animate-float"> <div class="bg-white/60 backdrop-blur-3xl rounded-[3rem] p-8 md:p-16 shadow-2xl shadow-blue-900/5 border border-white/50 relative overflow-hidden">
<img
src="/img/elearning.png"
alt="E-Learning Illustration"
class="w-full h-auto drop-shadow-2xl"
/>
</div>
<!-- Decorative shapes behind the image --> <!-- Decorative background inside card -->
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[120%] h-[120%] bg-blue-50/50 rounded-full blur-3xl -z-10" /> <div class="absolute top-0 right-0 w-[500px] h-[500px] bg-blue-500/5 rounded-full blur-[100px] -translate-y-1/2 translate-x-1/2 pointer-events-none"/>
<div class="absolute -top-10 -left-10 w-32 h-32 bg-amber-100 rounded-[3rem] -z-10 animate-pulse" />
<div class="absolute -bottom-10 -right-10 w-48 h-48 bg-blue-100 rounded-full -z-10 animate-pulse" style="animation-delay: -2s;" />
</div>
</div>
</div>
</header>
<!-- Why Choose Us Section --> <div class="grid-hero items-center relative z-10">
<section class="pt-20 pb-12 bg-white relative">
<div class="container mx-auto px-6 lg:px-12">
<div class="text-center mb-16 slide-up">
<h2 class="text-3xl md:text-5xl font-black text-slate-900 mb-6">
ทำไมตองเลอกแพลตฟอรมของเรา?
</h2>
<p class="text-slate-500 text-lg md:text-xl font-medium max-w-3xl mx-auto leading-relaxed">
เรามเครองมอและความเชยวชาญทจะชวยใหณประสบความสำเรจในการเปลยนสายอาชพและการสรางทกษะระดบมออาช
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8"> <!-- Left Content -->
<div v-for="(item, i) in whyChooseUs" :key="i" <div class="hero-content">
class="slide-up p-10 rounded-[2.5rem] bg-slate-50/50 border border-slate-100 hover:bg-white hover:shadow-2xl hover:shadow-blue-600/5 transition-all duration-500 group"
:style="`animation-delay: ${i * 0.1}s`"
>
<div class="w-16 h-16 rounded-3xl flex items-center justify-center mb-8 transition-transform group-hover:scale-110 duration-500"
:class="item.iconBg"
>
<q-icon :name="item.icon" size="32px" :class="item.iconColor" />
</div>
<h3 class="text-2xl font-black text-slate-900 mb-4 group-hover:text-blue-600 transition-colors">
{{ item.title }}
</h3>
<p class="text-slate-500 text-lg leading-relaxed font-medium">
{{ item.desc }}
</p>
</div>
</div>
</div>
</section>
<section class="pt-16 pb-12 md:pt-24 md:pb-20 bg-white">
<div class="container mx-auto px-6 lg:px-12">
<div class="mb-12 slide-up"> <div class="mb-12 slide-up">
<h2 class="text-3xl md:text-4xl font-black text-slate-900 px-4"> <span class="px-5 py-2 rounded-full glass border border-blue-400/20 text-blue-400 text-[11px] font-black tracking-[0.25em] uppercase shadow-[0_0_20px_rgba(59,130,246,0.15)] bg-white ">
เลอกเรยนตามเรองทณสนใจ เรมตนเสนทางความสำเรจใหม
</span>
</div>
<h1 class="hero-title leading-[1.6] mb-8 slide-up py-8" style="animation-delay: 0.1s;">
ยกระดบทกษะ <br>
<span class="text-slate-900 ">แหงอนาคต</span> <span class="text-gradient-cyan">ไปพรอมกบเรา</span>
</h1>
<h2 class="hero-subtitle text-slate-600 font-medium mb-12 text-xl leading-relaxed slide-up max-w-[640px]" style="animation-delay: 0.2s;">
แหลงรวมความรออนไลนเขาถงงายท ฒนาโดยผเชยวชาญ <br class="hidden-mobile" >
เพอชวยใหณกาวสเปาหมายทงไวไดอยางมนใจ
</h2> </h2>
</div>
<!-- Horizontal Cards (New Layout - Image 2) --> <div class="hero-actions flex flex-wrap gap-6 mb-20 slide-up" style="animation-delay: 0.3s;">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 px-4"> <NuxtLink to="/auth/register" class="btn-cta-premium group">
<div v-for="(card, i) in categoryCards" :key="i" สมครเรยนฟรนน
class="group cursor-pointer bg-white rounded-[2rem] p-6 border border-slate-100/80 shadow-sm hover:shadow-2xl hover:shadow-blue-600/5 hover:-translate-y-1 transition-all duration-500 relative flex items-center gap-5" <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 ml-2 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@click="goBrowse(card.slug)" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M13 7l5 5m0 0l-5 5m5-5H6" />
> </svg>
<!-- Icon Box --> </NuxtLink>
<div class="flex-shrink-0 w-16 h-16 rounded-[1.5rem] flex items-center justify-center bg-blue-50/50 group-hover:scale-110 transition-transform duration-500" <NuxtLink to="/browse" class="btn-outline-glass">
> เปดดคอรสทงหมด
<q-icon :name="card.icon" size="28px" class="text-blue-600" />
</div>
<!-- Content -->
<div class="flex-grow pr-2">
<h3 class="text-lg md:text-xl font-black text-slate-900 mb-1 group-hover:text-blue-600 transition-colors leading-tight">
{{ card.title }}
</h3>
<p class="text-slate-500 text-xs md:text-sm font-medium leading-relaxed opacity-70">
{{ card.desc }}
</p>
</div>
<!-- Arrow -->
<div class="flex-shrink-0 text-slate-300 group-hover:text-blue-600 transition-colors transform group-hover:translate-x-1 duration-300">
<q-icon name="chevron_right" size="24px" />
</div>
</div>
</div>
</div>
</section>
<!-- Section 4: "คอร์สออนไลน์" -->
<section class="pt-12 pb-24 md:pt-20 md:pb-40 bg-slate-50/50">
<div class="container mx-auto px-6 lg:px-12">
<div class="flex flex-col md:flex-row items-start md:items-end justify-between mb-12 gap-8">
<div class="slide-up">
<h2 class="text-3xl md:text-5xl font-bold text-slate-900 mb-4">คอรสออนไลน</h2>
<p class="text-slate-500 font-bold text-lg">เรมตนเรยนรกษะใหมวยคอรสคณภาพจากผเชยวชาญ</p>
</div>
<NuxtLink to="/browse" class="flex items-center gap-3 px-8 py-3 rounded-full border-2 border-blue-600 text-blue-700 font-bold hover:bg-blue-600 hover:text-white transition-all slide-up">
คอรสออนไลนงหมด <q-icon name="arrow_forward" size="20px" />
</NuxtLink> </NuxtLink>
</div> </div>
<!-- Filter Tabs / Pills -->
<div class="flex items-center gap-4 mb-8 overflow-x-auto pb-6 no-scrollbar slide-up">
<button
class="px-8 py-3 rounded-full font-black text-base transition-all whitespace-nowrap border-2"
:class="selectedCategory === 'all' ? 'bg-blue-600 text-white border-blue-600 shadow-lg shadow-blue-600/30' : 'bg-white border-slate-100 text-slate-500 hover:border-slate-300'"
@click="selectedCategory = 'all'"
>
งหมด
</button>
<button
v-for="category in categories"
:key="category.id"
class="px-8 py-3 rounded-full font-black text-base transition-all whitespace-nowrap border-2"
:class="selectedCategory === category.slug ? 'bg-blue-600 text-white border-blue-600 shadow-lg shadow-blue-600/30' : 'bg-white border-slate-100 text-slate-500 hover:border-slate-300'"
@click="selectedCategory = category.slug"
>
{{ getLocalizedText(category.name) }}
</button>
</div> </div>
<!-- Courses Carousel --> <!-- Right Content (Visual) - Redesigned as a Preview/Showcase -->
<div v-if="isLoading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-10"> <div class="hero-visual flex justify-center items-center relative slide-up" style="animation-delay: 0.2s;">
<div v-for="i in 4" :key="i" class="bg-white rounded-[3rem] h-[480px] animate-pulse" /> <!-- Preview Snapshot Container -->
<div class="platform-preview-card glass-premium rotate-[-1deg] shadow-2xl border border-slate-200 ">
<!-- Browser-like header -->
<div class="preview-header border-b border-slate-200 py-4 bg-slate-100 flex items-center px-6">
<div class="dots flex gap-2">
<span class="w-2.5 h-2.5 rounded-full bg-slate-600"/>
<span class="w-2.5 h-2.5 rounded-full bg-slate-600"/>
<span class="w-2.5 h-2.5 rounded-full bg-slate-600"/>
</div>
<div class="mx-auto bg-slate-200 px-4 py-1 rounded-full text-[10px] text-slate-500 tracking-wider">www.elearning.com</div>
</div> </div>
<div v-else class="relative group/carousel slide-up"> <!-- Showcase Content -->
<q-carousel <div class="preview-body p-8 space-y-8">
v-model="currentSlide" <!-- Video Hero Preview -->
transition-prev="slide-right" <div class="relative aspect-video bg-gradient-to-br from-blue-900/50 to-indigo-900/50 rounded-3xl overflow-hidden group/vid border border-white/5">
transition-next="slide-left" <div class="absolute inset-0 flex items-center justify-center">
swipeable <div class="w-16 h-16 rounded-full bg-blue-600 flex items-center justify-center shadow-2xl group-hover/vid:scale-110 transition-transform">
animated <svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-white fill-white ml-1" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
control-color="primary" </div>
padding </div>
height="auto" <div class="absolute bottom-4 left-6 right-6 h-1.5 bg-white/10 rounded-full overflow-hidden">
class="bg-transparent rounded-none" <div class="h-full bg-blue-500 w-[65%]"/>
> </div>
<q-carousel-slide
v-for="(chunk, pageIndex) in courseChunks"
:key="pageIndex"
:name="pageIndex"
class="p-4"
>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-10">
<CourseCard
v-for="course in chunk"
:key="course.id"
v-bind="{ ...course, image: course.thumbnail_url }"
/>
</div> </div>
</q-carousel-slide>
</q-carousel>
<!-- Custom Carousel Navigation --> <!-- Course List Preview -->
<button <div class="space-y-4">
v-if="courseChunks.length > 1" <div class="grid grid-cols-2 gap-4">
class="absolute -left-4 md:-left-12 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full bg-white shadow-xl border border-slate-100 flex items-center justify-center text-slate-500 hover:text-blue-600 transition-all hover:scale-110" <div class="p-4 bg-slate-50 rounded-2xl border border-slate-200 ">
@click="currentSlide = Math.max(0, currentSlide - 1)" <div class="w-full h-20 bg-blue-400/10 rounded-xl mb-3"/>
:disabled="currentSlide === 0" <div class="h-3 bg-white/10 rounded w-3/4 mb-2"/>
:class="{ 'opacity-50 cursor-not-allowed': currentSlide === 0 }" <div class="h-2 bg-white/5 rounded w-1/2"/>
> </div>
<q-icon name="chevron_left" size="32px" /> <div class="p-4 bg-slate-50 rounded-2xl border border-slate-200 ">
</button> <div class="w-full h-20 bg-indigo-400/10 rounded-xl mb-3"/>
<button <div class="h-3 bg-white/10 rounded w-3/4 mb-2"/>
v-if="courseChunks.length > 1" <div class="h-2 bg-white/5 rounded w-1/2"/>
class="absolute -right-4 md:-right-12 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full bg-white shadow-xl border border-slate-100 flex items-center justify-center text-slate-500 hover:text-blue-600 transition-all hover:scale-110" </div>
@click="currentSlide = Math.min(courseChunks.length - 1, currentSlide + 1)" </div>
:disabled="currentSlide === courseChunks.length - 1" </div>
:class="{ 'opacity-50 cursor-not-allowed': currentSlide === courseChunks.length - 1 }" </div>
> </div>
<q-icon name="chevron_right" size="32px" />
</button>
<!-- Floating Feature Highlights (Marketing focused) -->
<div class="absolute top-[10%] -right-8 glass-tag-premium px-8 py-5 rounded-3xl shadow-[0_20px_50px_rgba(0,0,0,0.1)] animate-float">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-emerald-500 text-white rounded-2xl flex items-center justify-center shadow-lg shadow-emerald-500/30">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<div class="text-slate-900 font-black text-sm mb-0.5">บใบประกาศนยบตร</div>
<div class="text-slate-500 text-[10px] font-bold">เมอเรยนจบหลกสตร</div>
</div>
</div>
</div>
<div class="absolute bottom-[10%] -left-8 glass-tag-premium px-8 py-5 rounded-3xl shadow-[0_20px_50px_rgba(0,0,0,0.1)] animate-float" style="animation-delay: -3s;">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-blue-600 text-white rounded-2xl flex items-center justify-center shadow-lg shadow-blue-600/30">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<div class="text-slate-900 font-black text-sm mb-0.5">เรยนไดกท</div>
<div class="text-slate-500 text-[10px] font-bold">รองรบทกอปกรณ</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Platform Info Section: วนแสดงจดเดนของแพลตฟอร (Features) -->
<section class="info-section py-40 bg-slate-50 relative transition-colors">
<!-- Background detail -->
<div class="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent"/>
<div class="container relative z-10">
<div class="text-center mb-28">
<span class="text-blue-500 font-black tracking-[0.4em] text-[11px] uppercase mb-5 block">Why Choose Us</span>
<h2 class="section-title text-5xl font-black mb-8 text-slate-900 tracking-tight">ออกแบบมาเพอความสำเรจของค</h2>
<p class="section-desc max-w-2xl mx-auto text-slate-500 text-xl leading-relaxed">
เราไมใชแคแพลตฟอรมการเรยนร แตเราคอคจะชวยพาคณไปสดหมายทองการ
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-10">
<!-- Feature 1 -->
<div class="feature-card glass-premium p-12 rounded-[3.5rem] hover:translate-y-[-15px] transition-all duration-700 group">
<div class="w-20 h-20 rounded-[1.75rem] bg-blue-600/10 text-blue-500 flex items-center justify-center mb-10 border border-blue-500/20 group-hover:bg-blue-600 group-hover:text-white transition-colors duration-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</div>
<h3 class="text-2xl font-black mb-5 text-slate-900 ">อการเรยนระดบส</h3>
<p class="text-slate-500 text-base leading-relaxed">โอคณภาพคมช พรอมเอกสารประกอบการเรยนทดสรรมาอยางดเพอความเขาใจงาย</p>
</div>
<!-- Feature 2 -->
<div class="feature-card glass-premium p-12 rounded-[3.5rem] hover:translate-y-[-15px] transition-all duration-700 group">
<div class="w-20 h-20 rounded-[1.75rem] bg-emerald-600/10 text-emerald-500 flex items-center justify-center mb-10 border border-emerald-500/20 group-hover:bg-emerald-600 group-hover:text-white transition-colors duration-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
</div>
<h3 class="text-2xl font-black mb-5 text-slate-900 ">ดผลแบบอจฉรยะ</h3>
<p class="text-slate-500 text-base leading-relaxed">ระบบ Quizz ออนไลนวยประเมนความเขาใจไดนท พรอมวเคราะหดทควรปรบปร</p>
</div>
<!-- Feature 3 -->
<div class="feature-card glass-premium p-12 rounded-[3.5rem] hover:translate-y-[-15px] transition-all duration-700 group">
<div class="w-20 h-20 rounded-[1.75rem] bg-indigo-600/10 text-indigo-500 flex items-center justify-center mb-10 border border-indigo-500/20 group-hover:bg-indigo-600 group-hover:text-white transition-colors duration-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
</svg>
</div>
<h3 class="text-2xl font-black mb-5 text-slate-900 ">ระบบตดตามผล</h3>
<p class="text-slate-500 text-base leading-relaxed">ความกาวหนาของตวเองไดกทกเวลา าน Dashboard สรปภาพรวมไวอยางลงต</p>
</div>
</div> </div>
</div> </div>
</section> </section>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.landing-page { .container {
font-family: var(--font-main); max-width: 1440px;
margin: 0 auto;
padding: 0 48px;
} }
.no-scrollbar::-webkit-scrollbar { .hero-title {
display: none; font-size: clamp(3.5rem, 7vw, 5.5rem);
} font-weight: 900;
.no-scrollbar { color: #0f172a;
-ms-overflow-style: none; }
scrollbar-width: none;
.text-gradient-cyan {
background: linear-gradient(135deg, #22d3ee 0%, #3b82f6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
display: inline-block;
padding: 0.4em 0.2em;
margin: -0.4em -0.2em;
vertical-align: baseline;
}
.glass-premium {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(40px);
border: 1px solid rgba(255, 255, 255, 0.5);
box-shadow: 0 20px 40px rgba(0,0,0,0.05);
}
.glass-tag-premium {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.05);
}
.btn-cta-premium {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
padding: 1.5rem 3.5rem;
border-radius: 1.5rem;
font-weight: 900;
display: flex;
align-items: center;
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
font-size: 1.125rem;
}
.btn-cta-premium:hover {
@apply bg-blue-600;
transform: translateY(-5px);
box-shadow: 0 25px 50px -12px rgba(37, 99, 235, 0.5);
}
.btn-outline-glass {
padding: 1.5rem 3.5rem;
border-radius: 1.5rem;
font-weight: 900;
color: #1e293b; /* Slate 800 for better visibility */
border: 2px solid #cbd5e1; /* Thicker, Slate 300 border */
background: rgba(255, 255, 255, 0.8); /* Whiting background */
transition: all 0.3s ease;
font-size: 1.125rem;
}
.btn-outline-glass:hover {
background: #ffffff;
border-color: #3b82f6; /* Blue border on hover */
color: #2563eb;
transform: translateY(-2px);
box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.1);
}
.platform-preview-card {
width: 100%;
max-width: 620px;
border-radius: 4rem;
overflow: hidden;
}
.grid-hero {
display: grid;
grid-template-columns: 1.3fr 1fr;
gap: 8rem;
} }
/* Animations */
@keyframes slide-up { @keyframes slide-up {
from { opacity: 0; transform: translateY(40px); } from { opacity: 0; transform: translateY(50px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
.slide-up { .slide-up {
animation: slide-up 0.8s cubic-bezier(0.2, 0.8, 0.2, 1) forwards; animation: slide-up 1s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
opacity: 0; opacity: 0;
} }
@keyframes float { @keyframes float {
0%, 100% { transform: translateY(0) rotate(0); } 0%, 100% { transform: translateY(0); }
50% { transform: translateY(-20px) rotate(5deg); } 50% { transform: translateY(-25px); }
} }
.animate-float { .animate-float {
animation: float 6s ease-in-out infinite; animation: float 6s ease-in-out infinite;
} }
@keyframes fade-in { @keyframes pulse-slow {
from { opacity: 0; } 0%, 100% { opacity: 0.1; transform: scale(1); }
to { opacity: 1; } 50% { opacity: 0.15; transform: scale(1.15); }
} }
.animate-fade-in { .animate-pulse-slow {
animation: fade-in 1s ease-out forwards; animation: pulse-slow 10s linear infinite;
} }
/* Typography Overrides */ @media (max-width: 1300px) {
h1, h2, h3 { .grid-hero {
letter-spacing: normal; gap: 4rem;
}
} }
/* Hover effects */ @media (max-width: 1024px) {
.hero-right:hover .animate-float { .grid-hero {
animation-play-state: paused; grid-template-columns: 1fr;
text-align: center;
gap: 5rem;
} }
.hero-content {
/* Responsive Grid Adjustments */ display: flex;
@media (max-width: 1200px) { flex-direction: column;
.career-cards-grid { align-items: center;
grid-template-columns: repeat(4, 1fr); }
.hero-actions {
justify-content: center;
}
.program-stats {
justify-content: center;
}
.stat-divider { display: none; }
.program-stats { gap: 50px; }
.hero-visual {
padding-top: 40px;
} }
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.career-cards-grid { .container {
grid-template-columns: repeat(2, 1fr); padding: 0 24px;
}
.hero-title {
font-size: 3.5rem;
}
.hidden-mobile {
display: none;
}
.btn-cta-premium, .btn-outline-glass {
width: 100%;
padding: 1.25rem 2rem;
} }
} }
</style> </style>

View file

@ -6,7 +6,7 @@
*/ */
definePageMeta({ definePageMeta({
layout: 'auth' layout: 'default'
}) })
const route = useRoute() const route = useRoute()
@ -68,8 +68,8 @@ const navigateToHome = () => {
<!-- Success State --> <!-- Success State -->
<div v-else-if="isSuccess" class="flex flex-col items-center animate-bounce-in"> <div v-else-if="isSuccess" class="flex flex-col items-center animate-bounce-in">
<div class="w-24 h-24 rounded-full bg-green-500 flex items-center justify-center mb-10 shadow-lg shadow-green-500/20"> <div class="w-24 h-24 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center mb-6">
<q-icon name="check" class="text-5xl text-white font-black" /> <q-icon name="check_circle" class="text-6xl text-green-500" />
</div> </div>
<h2 class="text-3xl font-black text-slate-900 dark:text-white mb-2"> <h2 class="text-3xl font-black text-slate-900 dark:text-white mb-2">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

View file

@ -1,41 +0,0 @@
/**
* @file auth.ts
* @description Type definitions for authentication and user profiles.
*/
export interface User {
id: number
username: string
email: string
email_verified_at?: string | null
created_at?: string
updated_at?: string
role: {
code: string // เช่น 'STUDENT', 'INSTRUCTOR', 'ADMIN'
name: { th: string; en: string }
}
profile?: {
prefix: { th: string; en: string }
first_name: string
last_name: string
phone: string | null
avatar_url: string | null
}
}
export interface LoginResponse {
token: string
refreshToken: string
user: User
profile: User['profile']
}
export interface RegisterPayload {
username: string
email: string
password: string
first_name: string
last_name: string
prefix: { th: string; en: string }
phone: string
}

View file

@ -1,142 +0,0 @@
/**
* @file course.ts
* @description Type definitions for courses, enrollments, quizzes, and certificates.
*/
export interface Course {
id: number
title: string | { th: string; en: string }
slug: string
description: string | { th: string; en: string }
thumbnail_url: string
price: string
is_free: boolean
original_price?: string
have_certificate: boolean
status: string
category_id: number
created_at?: string
updated_at?: string
created_by?: number
updated_by?: number
approved_at?: string
approved_by?: number
rejection_reason?: string
enrolled?: boolean
total_lessons?: number
rating?: string
lessons?: number | string
levelType?: 'neutral' | 'warning' | 'success'
chapters?: {
id: number
title: string | { th: string; en: string }
lessons: {
id: number
title: string | { th: string; en: string }
duration_minutes: number
video_url?: string
}[]
}[]
creator?: {
id: number
username: string
email: string
profile: {
first_name: string
last_name: string
avatar_url: string
}
}
instructors?: {
user_id: number
is_primary: boolean
user: {
id: number
username: string
email: string
profile: {
first_name: string
last_name: string
avatar_url: string
}
}
}[]
}
export interface CourseResponse {
code: number
message: string
data: Course[]
total: number
page?: number
limit?: number
totalPages?: number
}
export interface SingleCourseResponse {
code: number
message: string
data: Course
}
export interface EnrolledCourse {
id: number
course_id: number
course: Course
status: 'ENROLLED' | 'IN_PROGRESS' | 'COMPLETED' | 'DROPPED'
progress_percentage: number
enrolled_at: string
started_at?: string
completed_at?: string
last_accessed_at?: string
}
export interface EnrolledCourseResponse {
code: number
message: string
data: EnrolledCourse[]
total: number
page: number
limit: number
}
export interface QuizAnswerSubmission {
question_id: number
choice_id: number
}
export interface QuizSubmitRequest {
answers: QuizAnswerSubmission[]
}
export interface QuizResult {
answers_review: {
score: number
is_correct: boolean
correct_choice_id: number
selected_choice_id: number
question_id: number
}[]
completed_at: string
started_at: string
attempt_number: number
passing_score: number
is_passed: boolean
correct_answers: number
total_questions: number
total_score: number
score: number
quiz_id: number
attempt_id: number
}
export interface Certificate {
certificate_id: number
course_id: number
course_title: {
en: string
th: string
}
issued_at: string
download_url: string
}

View file

@ -1,2 +0,0 @@
export * from './auth'
export * from './course'

View file

@ -1,138 +1,169 @@
# Frontend-Learner (Web) — Technical Documentation # 🛠️ Web Development Documentation: e-Learning Platform (Frontend)
เอกสารฉบับนี้สรุปรายละเอียดทางเทคนิค โครงสร้างโค้ด และกลไกการทำงานของระบบ **Frontend-Learner (ฝั่งผู้เรียน)** เอกสารฉบับนี้สรุปรายละเอียดทางเทคนิค โครงสร้างโค้ด และการทำงานของระบบ **Frontend-Learner** (อัปเดตล่าสุด: กุมภาพันธ์ 2026)
ใช้เป็นคู่มือสำหรับการพัฒนา บำรุงรักษา และขยายระบบต่อไป
> อัปเดตล่าสุด: ปลายเดือนกุมภาพันธ์ 2026
--- ---
## Table of Contents ## 🏗️ 1. Technical Foundation (รากฐานทางเทคนิค)
- [1. Technical Foundation](#1-technical-foundation) รวมข้อมูลเครื่องมือ, ระบบความปลอดภัย และประสิทธิภาพการทำงานไว้ด้วยกัน
- [1.1 Tech Stack](#11-tech-stack)
- [1.2 Security & Authentication](#12-security--authentication)
- [2. Project Architecture](#2-project-architecture)
- [2.1 Directory Structure](#21-directory-structure)
- [2.2 Shared Infrastructure](#22-shared-infrastructure)
- [3. Logic & Data Layer (Composables)](#3-logic--data-layer-composables)
- [4. Branding & UI Policy](#4-branding--ui-policy)
- [4.1 Theme Strategy](#41-theme-strategy)
- [4.2 UI Elements](#42-ui-elements)
- [5. Core Feature Highlights](#5-core-feature-highlights)
- [6. Maintenance & Performance Guidelines](#6-maintenance--performance-guidelines)
---
## 1. Technical Foundation
รากฐานทางเทคนิคที่ขับเคลื่อนระบบ เพื่อให้ได้ประสิทธิภาพและความเสถียรสูงสุด
### 1.1 Tech Stack ### 1.1 Tech Stack
- **Framework:** Nuxt 3 (Vue 3, Vite, SSR/SPA Hybrid) - **Core:** [Nuxt 3](https://nuxt.com) (Vue 3 Composition API), TypeScript `^5.0`
- **UI System:** Quasar Framework + Tailwind CSS (Utility-first) - **UI Framework:** Quasar Framework (via `nuxt-quasar-ui`)
- **Typography:** Google Fonts (**Prompt** เป็น Font หลักเพื่อความทันสมัยและอ่านง่าย) - **Styling:** Tailwind CSS (Utility) + Vanilla CSS Variables (Theming/Dark Mode)
- **Multilingual:** `@nuxtjs/i18n` (รองรับ JSON-based locales ภาษาไทยและอังกฤษ) - **State Management:** `ref`/`reactive` (Local) + `useState` (Global/Shared State)
- **Programming:** TypeScript (Strict Type Checking) - **Localization:** `@nuxtjs/i18n` (Supports JSON locales in `i18n/locales/`)
- **Media Control:** `useMediaPrefs` (Command Pattern for global volume/mute state)
### 1.2 Security & Authentication ### 1.2 Core Systems & Security
- **Token Management:** ใช้ JWT (Access & Refresh Tokens) จัดเก็บผ่าน `useCookie` - **Authentication:**
โดยตั้งค่าความปลอดภัยระดับ **HTTP-only** และ **SameSite** - ใช้ **JWT** (Access Token 1 วัน, Refresh Token 7 วัน)
- **Middleware:** `auth.ts` ตรวจสอบสิทธิ์การเข้าถึงหน้า Dashboard และ Classroom แบบ Real-time - เก็บ Token ใน `useCookie` (Secure, SameSite)
- **Persistence:** ระบบ Remember Me (จดจำอีเมล) ใช้ `localStorage` แยกส่วนจาก Session - Middleware (`middleware/auth.ts`) ป้องกัน Route ตามสถานะ
เพื่อความปลอดภัยและสะดวกสำหรับผู้ใช้ - **Remember Me:** ระบบจดจำอีเมลลงใน `localStorage` (จำแยกจาก session, ไม่ถูกลบเมื่อ Logout)
- **API Handling:**
- ใช้ `runtimeConfig.public.apiBase` เชื่องโยง Backend
- Auto-attach Bearer Token ใน `useAuth` และ `useCourse`
- **Performance:**
- **Hybrid Progress Saving:** บันทึกเวลาเรียนลง LocalStorage (ถี่) และ Server (Throttle 15s) เพื่อความแม่นยำสูงสุด
- **Caching:** ใช้ `useState` จำข้อมูล Profile และ Categories ลด request
- **Code Quality:** ลบ Log, Dead logic และ Redundant comments ทั่วทั้งโปรเจกต์ (Clean Code Phase)
--- ---
## 2. Project Architecture ## 📂 2. Frontend Structure (โครงสร้างหน้าเว็บและ UI)
โครงสร้างโฟลเดอร์ที่จัดระเบียบตามหลัก Clean Architecture เพื่อความคล่องตัวในการขยายระบบ ### 2.1 Application Routes (`pages/`)
### 2.1 Directory Structure | Module | ไฟล์ | Path | หน้าที่ |
| :---------- | :------------------------- | :---------------------- | :-------------------------------------------- |
| **Public** | `index.vue` | `/` | หน้าแรก Landing Page (**Forced Light Mode**) |
| | `browse/discovery.vue` | `/browse/discovery` | **ระบบค้นหาและ Filter คอร์ส** (Catalog) |
| | `course/[id].vue` | `/course/:id` | **หน้ารายละเอียดคอร์ส** (Course Detail) |
| **Auth** | `auth/login.vue` | `/auth/login` | เข้าสู่ระบบ (**Remember Me**, **Light Mode**) |
| | `auth/register.vue` | `/auth/register` | สมัครสมาชิกผู้เรียน (**Light Mode**) |
| | `auth/forgot-password.vue` | `/auth/forgot-password` | กู้คืนรหัสผ่าน (**Light Mode**) |
| **Student** | `dashboard/index.vue` | `/dashboard` | แดชบอร์ดภาพรวมผู้เรียน |
| | `dashboard/my-courses.vue` | `/dashboard/my-courses` | **คอร์สของฉัน** และดาวน์โหลดใบประกาศฯ |
| | `dashboard/profile.vue` | `/dashboard/profile` | จัดการโปรไฟล์, รูปภาพ, เปลี่ยนรหัสผ่าน |
| | `classroom/learning.vue` | `/classroom/learning` | **ห้องเรียน (Video Player)** & Announcements |
| | `classroom/quiz.vue` | `/classroom/quiz` | การสอบวัดผล (**API-Driven Logic**) |
- `pages/` : ระบบ Routing ทั้งหมด (Landing, Auth, Dashboard, Classroom) ### 2.2 Key Components (`components/`)
- `components/` : UI Components แยกตามความรับผิดชอบ (Common, Layout, Course, Classroom, Profile)
- `composables/` : Business Logic ทั้งหมด (Auth, Course, Theme, Quiz, Navigation)
- `types/` : ศูนย์รวม Interface และ Type definitions ของทั้งระบบ
- `constants/` : แหล่งเก็บข้อมูล Static (เช่น Category cards, Why choose us) เพื่อลดความซ้อนในไฟล์ Vue
- `assets/css/` : `main.css` ที่เป็น Single Source of Truth สำหรับสไตล์และ CSS Variables
- `layouts/` : Master templates (Default, Auth, Dashboard)
- `middleware/` : ตัวกรองความปลอดภัยก่อนเข้าถึงแต่ละหน้า
### 2.2 Shared Infrastructure - **Common (`components/common/`):**
- `GlobalLoader.vue`: Loading indicator ทั่วทั้งแอป
- **Types Architecture:** การสกัด Types จาก Composable ออกมาไว้ที่ `@/types` - `LanguageSwitcher.vue`: ปุ่มเปลี่ยนภาษา (TH/EN)
ช่วยลดความซ้ำซ้อนและป้องกัน Error จากการเปลี่ยนโครงสร้างข้อมูล API - `AppHeader.vue`, `MobileNav.vue`: Navigation หลัก
- **Constants System:** การใช้ `@/constants` ช่วยให้การแก้ไขคำโฆษณาหรือข้อมูลหน้าแรกทำได้จากจุดเดียว - `FormInput.vue`: Input field มาตรฐาน
โดยไม่ต้องแก้โค้ด HTML - **Course (`components/course/`):**
- `CourseCard.vue`: การ์ดแสดงผลคอร์ส (ใช้ซ้ำหลายหน้า)
- **Discovery (`components/discovery/`):**
- `CategorySidebar.vue`: Sidebar ตัวกรองหมวดหมู่แบบย่อ/ขยายได้
- `CourseDetailView.vue`: หน้ารายละเอียดคอร์สขนาดใหญ่ (Video Preview + Syllabus)
- **Classroom (`components/classroom/`):**
- `CurriculumSidebar.vue`: Sidebar บทเรียนและสถานะการเรียน
- `AnnouncementModal.vue`: Modal แสดงประกาศของคอร์ส
- `VideoPlayer.vue`: Video Player พร้อม Custom Controls
- **User / Profile (`components/user/`, `components/profile/`):**
- `UserAvatar.vue`: แสดงรูปโปรไฟล์ (รองรับ Fallback)
- `ProfileEditForm.vue`: ฟอร์มแก้ไขข้อมูลส่วนตัว
- `PasswordChangeForm.vue`: ฟอร์มเปลี่ยนรหัสผ่าน
--- ---
## 3. Logic & Data Layer (Composables) ## 🧠 3. Logic & Data Layer (Composables)
การแยก Logic ออกจาก UI เพื่อความสะอาดและ Testable รวบรวม Logic หลักแยกส่วนตามหน้าที่ (Separation of Concerns)
- `useAuth` ### 3.1 `useAuth.ts` (Authentication & User)
จัดการสถานะ Login, การดึงโปรไฟล์ล่วงหน้า (Pre-fetching), และระบบ Token Refresh
- `useCourse` จัดการสถานะผู้ใช้, ล็อกอิน, และความปลอดภัย
หัวใจของระบบ จัดการตั้งแต่ Catalog, การสมัครเรียน (Enroll), ไปจนถึงการส่งผลการเรียน (Progress)
- `useThemeMode` - **Key Functions:** `login`, `register`, `fetchUserProfile`, `uploadAvatar`, `sendVerifyEmail`
ระบบจัดการธีมกลางที่เชื่อมต่อกับ `localStorage` และ CSS Variables อย่างเป็นระบบ - **Features:** Refresh Token อัตโนมัติ, ตรวจสอบ Role, **Logout Logic ที่ไม่ลบข้อมูลจดจำผู้ใช้**
- `useQuizRunner` ### 3.2 `useCourse.ts` (Course & Classroom)
จัดการสถานะการสอบ เปลี่ยนข้อสอบ และส่งคะแนนไปยัง Backend โดยตรง
- `useNavItems` หัวใจหลักของการเรียนการสอน
Single Source of Truth สำหรับเมนูทั้งหมด (Sidebar, Mobile Drawer, User Menu)
- **Catalog:** `fetchCourses`, `fetchCourseById`, `enrollCourse`
- **Classroom:**
- `fetchCourseLearningInfo`: โครงสร้างบทเรียน (Chapters/Lessons)
- `fetchLessonContent`: เนื้อหาวิดีโอ/Quiz/Attachments
- `saveVideoProgress`: บันทึกเวลาเรียน (Sync Server)
- **i18n Support:** `getLocalizedText` ตัวช่วยในการเลือกแสดงผลภาษา (TH/EN) ตาม Locale ปัจจุบันที่ผู้ใช้เลือก อัตโนมัติทั่วทั้งแอป
### 3.3 `useQuizRunner.ts` (Quiz System)
จัดการ Logic การทำข้อสอบ (Production-Ready)
- **Logic:** ควบคุมการเปลี่ยนข้อ, การส่งคำตอบ, และการรับผลลัพธ์จาก API
- **Cleanup:** ลบ Mock delays และ Simulation logic ออกทั้งหมดเพื่อให้ทำงานร่วมกับ API จริงได้ทันที
--- ---
## 4. Branding & UI Policy ## 🎨 4. Design System & Theming
มาตรฐานการออกแบบที่เน้นความ Premium และ Consistent
### 4.1 Theme Strategy ### 4.1 Theme Strategy
- **Public Pages (Landing, Auth, Detail):** บังคับ **Forced Light Mode** - **Framework:** Tailwind CSS + Quasar UI
เพื่อภาพลักษณ์แบรนด์ที่สะอาดและน่าเชื่อถือ - **Light/Dark Mode Policy:**
- **Internal Pages (Dashboard, Learning):** รองรับ **Dark Mode (Oceanic Theme)** - **Public Pages:** บังคับ **Light Mode** (Landing, Course Detail, Auth) เพื่อภาพลักษณ์แบรนด์ที่สะอาดตา
ลดการเมื่อยล้าของสายตาขณะเรียนเป็นเวลานาน - **Dashboard/Learning:** รองรับ **Dark Mode** เต็มรูปแบบ (Oceanic Theme)
- **Transitions:** ใช้ GlobalLoader และ Smooth transitions ทั่วทั้งแอปเพื่อประสบการณ์ที่ลื่นไหล - **Aesthetics:** ปรับปรุงความชัดเจนของ Badge, Icon และสถานะต่างๆ ในหน้าสอบ (Quiz) สำหรับโหมดมืดโดยเฉพาะ ให้มี Contrast สูงและดู Premium
- **Visual Fixes:** แก้ไขปัญหา "Dark Frame" ในหน้า Auth โดยการบังคับสไตล์ระดับ HTML/Body
### 4.2 UI Elements
- **Image 2 Style Categories:** การ์ดหมวดหมู่แบบแนวนอนที่เป็นระเบียบ (Minimalist)
- **Glassmorphism:** พื้นผิวโปร่งแสงใน Dashboard และ Classroom ช่วยให้แอปดูมีมิติ
- **Standardized Icons:** ใช้ Material Icons ผ่าน Quasar ระบบเดียวทั้งหมด
--- ---
## 5. Core Feature Highlights ## 📊 5. Dependency Map (ความสัมพันธ์ไฟล์)
ฟีเจอร์เด่นที่ถูกพัฒนาขึ้นเพื่อผู้เรียนโดยเฉพาะ | หน้าเว็บ (Page) | Components หลัก | Composables หลัก |
| :----------------------- | :--------------------------- | :----------------------------------------------------- |
- **SPA Learning Journey:** การสลับบทเรียนในห้องเรียนเป็นแบบ Single Page App (ไม่มีการ Re-load หน้า) | **Login / Register** | `FormInput` | `useAuth` (Remember Me), `useFormValidation` |
ทำให้การเรียนต่อเนื่อง | **Discovery (Browse)** | `CourseCard` | `useCourse` (Search/Filter), `useCategory` |
- **Hybrid Progress Tracking:** บันทึกเวลาเรียนลง `localStorage` แบบ Real-time และ Sync ขึ้น Server เป็นระยะ | **My Courses** | `CourseCard` (with Progress) | `useCourse` (Certificates) |
เพื่อป้องกันข้อมูลหาย | **Classroom (Learning)** | Video Player, Sidebar | `useCourse` (Progress, Announcements), `useMediaPrefs` |
- **Announcement System:** ระบบแจ้งเตือนในคอร์สพร้อมตัวระบุ "ยังไม่ได้อ่าน" (Unread Badge) | **Quiz** | `QuizHeader`, `QuizContent` | `useQuizRunner` (Real API Integration) |
ที่จำสถานะตามผู้ใช้งาน | **Profile** | `UserAvatar`, `FormInput` | `useAuth` (Upload Avatar, Verify Email) |
- **Interactive Quizzes:** ระบบสอบที่สลับคำถามอัตโนมัติ พร้อมโหมดเฉลย (Answer Review) ที่ชัดเจน
- **Certificate Automation:** ระบบตรวจสอบสิทธิ์ความสำเร็จและออกใบประกาศนียบัตรได้ทันที
--- ---
## 6. Maintenance & Performance Guidelines ## ✅ 6. Project Status (สถานะล่าสุด)
แนวทางสำหรับการพัฒนาต่อยอด ### ✨ Recent Updates (กุมภาพันธ์ 2026)
- **Clean Code:** หลีกเลี่ยงการใช้ `console.log` ในโค้ด Final และลบ Dead Logic ทิ้งทันที 1. **System-Wide Code Cleanup (Phase Final):**
- **Standard Fonts:** ใช้ชุด Font Prompt ผ่านตัวแปร `--font-main` เสมอ - **Refactoring:** ปัดกวาดโค้ดในหน้า `learning`, `quiz`, `discovery`, `dashboard` และ `profile`
- **API Integrity:** ตรวจสอบข้อมูลผ่าน Interface ใน `@/types` ก่อนการใช้งานทุกครั้ง - **Logging:** ลบ `console.log` มหาศาล และ logic ซ้ำซ้อนที่ตกค้างจากการพัฒนา
- **Mobile First:** ทุก Component ต้องรองรับระบบ Master Drawer บนมือถืออย่างสมบูรณ์ - **Structure:** จัดกลุ่มสไตล์และฟังก์ชันให้เป็นระเบียบ อ่านง่ายขึ้นตามมาตรฐาน Clean Code
--- 2. **Authentication & Security Polish:**
- **Remember Me:** พัฒนาระบบจดจำอีเมลในหน้า Login ให้เสถียร (ใช้ `localStorage`)
- **Smart Logout:** ปรับปรุง `useAuth.logout` ให้ลบข้อมูล Session แต่เก็บข้อมูลที่ผู้ใช้สั่งจำไว้ (อีเมล)
3. **UI & Aesthetics (Premium Fixes):**
- **Theme Enforcement:** บังคับหน้าสาธารณะ (Landing/Auth) ให้เป็น Light Mode 100% พร้อมแก้ปัญหากรอบมืด (Dark Frame) ตกค้าง
- **Dark Mode Optimization:** ปรับปรุงสีและ Contrast ในหน้า Dashboard และ Profile ให้สวยงามและอ่านง่ายขึ้นในโหมมืด
4. **Quiz System Productionization:**
- **useQuizRunner:** แปลงร่างจาก Mock system เป็น API-Ready system (ลบ simulation logic ทั้งหมด)
- **Quiz UI:** ปรับปรุงการนำทางและสถานะการทำข้อสอบให้ลื่นไหล
5. **Smooth Navigation & Quiz Experience:**
- **SPA Navigation:** เปลี่ยนการสไลด์บทเรียนจาก Hard Reload เป็น SPA Navigation (`router.push`) ทำให้เรียนได้ต่อเนื่อง ไม่ต้องรอโหลดหน้าใหม่
- **Smart Lesson Loading:** ปรับปรุง Error ที่หน้าเว็บชอบเด้งกลับไปบทเรียนที่ 1 เสมอ โดยเปลี่ยนให้ความสำคัญกับ `lesson_id` จาก URL ก่อน
- **UI Simplification:** ลบทิ้ง "Legend/คำอธิบายสถานะ" ในหน้าสอบเพื่อความสะอาดตา (Minimal UI)
- **Sidebar visibility:** ช่วยให้ผู้ใช้เปิด-ปิด Sidebar บน Desktop ได้อย่างอิสระผ่านปุ่ม Hamburger
6. **Internationalization (i18n) Improvements:**
- **Localized Text Logic:** แก้ไขฟังก์ชัน `getLocalizedText` ให้แสดงภาษาตามที่ผู้ใช้สลับจริง (แก้ปัญหาหน้าเว็บเป็นอังกฤษแต่ชื่อวิชาเป็นไทย)
- **Hardcoded Removal:** ทยอยลบข้อความภาษาไทยที่พิมพ์ค้างไว้ในโค้ด (เช่น ใน Sidebar หมวดหมู่) และแทนที่ด้วย i18n keys
- **Boot Sequence Fix:** แก้ไขปัญหาเว็บค้าง (Error 500) ที่เกิดจากการเรียกใช้ภาษาเร็วเกินไปก่อนที่ระบบจะพร้อม (`initialization error`)
7. **Landing Page & Header Refinement:**
- **Login Button:** อัปเกรดปุ่ม "เข้าสู่ระบบ" จากลิงก์ข้อความธรรมดา ให้เป็นปุ่มแบบ Secondary ที่โดดเด่นและชัดเจนขึ้น เพื่อดึงดูดผู้ใช้งาน
- **Visual Hierarchy:** จัดลำดับความสำคัญของปุ่ม Get Started และ Login ให้สมดุลกันมากขึ้นในโหมดสว่างและโหมดมืด (Scrolled Header)

View file

@ -18,16 +18,7 @@
<div v-else-if="lessonDetail" class="p-6 space-y-6"> <div v-else-if="lessonDetail" class="p-6 space-y-6">
<!-- Video Player --> <!-- Video Player -->
<div v-if="lesson.type === 'VIDEO' && lessonDetail.video_url" class="aspect-video bg-black rounded-lg overflow-hidden"> <div v-if="lesson.type === 'VIDEO' && lessonDetail.video_url" class="aspect-video bg-black rounded-lg overflow-hidden">
<iframe
v-if="isYoutubeUrl(lessonDetail.video_url)"
:src="getYoutubeEmbedUrl(lessonDetail.video_url)"
class="w-full h-full"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
<video <video
v-else
:src="lessonDetail.video_url" :src="lessonDetail.video_url"
controls controls
class="w-full h-full object-contain" class="w-full h-full object-contain"
@ -47,7 +38,7 @@
</h3> </h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<a <a
v-for="file in lessonDetail.attachments.filter(f => !['video/mp4', 'video/youtube'].includes(f.mime_type))" v-for="file in lessonDetail.attachments"
:key="file.id" :key="file.id"
:href="file.file_path" :href="file.file_path"
target="_blank" target="_blank"
@ -83,28 +74,6 @@
</div> </div>
</div> </div>
<!-- Quiz Settings -->
<div class="mt-4 p-4 bg-white rounded-lg border border-blue-100">
<div class="font-semibold text-gray-700 mb-3">การตงคาเพมเต</div>
<div class="flex flex-wrap gap-2">
<q-chip :color="lessonDetail.quiz.shuffle_questions ? 'positive' : 'grey-4'" :text-color="lessonDetail.quiz.shuffle_questions ? 'white' : 'grey-8'" size="m" :icon="lessonDetail.quiz.shuffle_questions ? 'check' : 'close'">
มคำถาม
</q-chip>
<q-chip :color="lessonDetail.quiz.shuffle_choices ? 'positive' : 'grey-4'" :text-color="lessonDetail.quiz.shuffle_choices ? 'white' : 'grey-8'" size="m" :icon="lessonDetail.quiz.shuffle_choices ? 'check' : 'close'">
มตวเลอก
</q-chip>
<q-chip :color="lessonDetail.quiz.show_answers_after_completion ? 'positive' : 'grey-4'" :text-color="lessonDetail.quiz.show_answers_after_completion ? 'white' : 'grey-8'" size="m" :icon="lessonDetail.quiz.show_answers_after_completion ? 'check' : 'close'">
เฉลยหลงทำเสร
</q-chip>
<q-chip :color="lessonDetail.quiz.is_skippable ? 'positive' : 'grey-4'" :text-color="lessonDetail.quiz.is_skippable ? 'white' : 'grey-8'" size="m" :icon="lessonDetail.quiz.is_skippable ? 'check' : 'close'">
ามขอได
</q-chip>
<q-chip :color="lessonDetail.quiz.allow_multiple_attempts ? 'positive' : 'grey-4'" :text-color="lessonDetail.quiz.allow_multiple_attempts ? 'white' : 'grey-8'" size="m" :icon="lessonDetail.quiz.allow_multiple_attempts ? 'check' : 'close'">
ทำซำได
</q-chip>
</div>
</div>
<!-- Questions List --> <!-- Questions List -->
<div v-if="lessonDetail.quiz.questions && lessonDetail.quiz.questions.length > 0" class="mt-6 space-y-6"> <div v-if="lessonDetail.quiz.questions && lessonDetail.quiz.questions.length > 0" class="mt-6 space-y-6">
<!-- ... (questions rendering code unchanged) ... --> <!-- ... (questions rendering code unchanged) ... -->
@ -206,23 +175,6 @@ const formatFileSize = (bytes: number) => {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}; };
const isYoutubeUrl = (url: string) => {
return url.includes('youtube.com') || url.includes('youtu.be');
};
const getYoutubeEmbedUrl = (url: string) => {
let videoId = '';
if (url.includes('youtu.be')) {
videoId = url.split('/').pop()?.split('?')[0] || '';
} else if (url.includes('youtube.com')) {
const params = new URLSearchParams(url.split('?')[1]);
videoId = params.get('v') || '';
}
return `https://www.youtube.com/embed/${videoId}`;
};
const fetchLessonDetail = async () => { const fetchLessonDetail = async () => {
// Always verify lesson and courseId exist // Always verify lesson and courseId exist
if (!props.lesson || !props.courseId) return; if (!props.lesson || !props.courseId) return;

View file

@ -54,16 +54,7 @@
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
to="/admin/recommended-courses" to="/admin/audit-logs"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2"
active-class="bg-primary-500 text-white hover:bg-primary-600"
>
<q-icon name="recommend" size="24px" />
<span>คอรสแนะนำ</span>
</NuxtLink>
<NuxtLink
to="/admin/audit-log"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2" class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2"
active-class="bg-primary-500 text-white hover:bg-primary-600" active-class="bg-primary-500 text-white hover:bg-primary-600"
> >
@ -91,29 +82,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useQuasar } from 'quasar';
const $q = useQuasar();
const authStore = useAuthStore(); const authStore = useAuthStore();
const router = useRouter(); const router = useRouter();
const handleLogout = () => { const handleLogout = () => {
$q.dialog({
title: 'ยืนยันออกจากระบบ',
message: 'คุณต้องการออกจากระบบหรือไม่?',
persistent: true,
ok: {
label: 'ออกจากระบบ',
color: 'negative',
flat: false
},
cancel: {
label: 'ยกเลิก',
flat: true
}
}).onOk(() => {
authStore.logout(); authStore.logout();
router.push('/login'); router.push('/login');
});
}; };
</script> </script>

View file

@ -2,7 +2,7 @@
<div class="min-h-screen bg-gray-50"> <div class="min-h-screen bg-gray-50">
<!-- Sidebar --> <!-- Sidebar -->
<aside class="fixed left-0 top-0 h-screen w-64 bg-white shadow-lg"> <aside class="fixed left-0 top-0 h-screen w-64 bg-white shadow-lg">
<div class="py-6 px-8"> <div class="p-6">
<h2 class="text-xl font-bold text-primary-600">E-Learning</h2> <h2 class="text-xl font-bold text-primary-600">E-Learning</h2>
<p class="text-sm text-gray-500">Instructor Panel</p> <p class="text-sm text-gray-500">Instructor Panel</p>
</div> </div>
@ -46,29 +46,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useQuasar } from 'quasar';
const $q = useQuasar();
const authStore = useAuthStore(); const authStore = useAuthStore();
const router = useRouter(); const router = useRouter();
const handleLogout = () => { const handleLogout = () => {
$q.dialog({
title: 'ยืนยันออกจากระบบ',
message: 'คุณต้องการออกจากระบบหรือไม่?',
persistent: true,
ok: {
label: 'ออกจากระบบ',
color: 'negative',
flat: false
},
cancel: {
label: 'ยกเลิก',
flat: true
}
}).onOk(() => {
authStore.logout(); authStore.logout();
router.push('/login'); router.push('/login');
});
}; };
</script> </script>

View file

@ -35,13 +35,10 @@ export default defineNuxtConfig({
devtools: { enabled: true }, devtools: { enabled: true },
app: { app: {
head: { head: {
title: 'E-Learning-Management', title: 'E-Learning System',
meta: [ meta: [
{ charset: 'utf-8' }, { charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' } { name: 'viewport', content: 'width=device-width, initial-scale=1' }
],
link: [
{ rel: 'icon', type: 'image/png', href: '/icon.png' }
] ]
} }
} }

View file

@ -218,7 +218,7 @@
<p>เลอกระยะเวลาทองการเกบไว (ลบขอมลทเกากวากำหนด):</p> <p>เลอกระยะเวลาทองการเกบไว (ลบขอมลทเกากวากำหนด):</p>
<q-select <q-select
v-model="cleanupDays" v-model="cleanupDays"
:options="[7, 15, 30, 60, 90, 180, 365]" :options="[30, 60, 90, 180, 365]"
label="จำนวนวัน" label="จำนวนวัน"
suffix="วัน" suffix="วัน"
outlined outlined
@ -297,7 +297,7 @@ const columns = [
// Actions options (for filtering) // Actions options (for filtering)
const actionOptionsList = [ const actionOptionsList = [
'CREATE', 'UPDATE', 'DELETE', 'LOGIN', 'LOGOUT', 'ERROR', 'CREATE', 'UPDATE', 'DELETE', 'LOGIN', 'LOGOUT',
'ENROLL', 'UNENROLL', 'SUBMIT_QUIZ', 'ENROLL', 'UNENROLL', 'SUBMIT_QUIZ',
'APPROVE_COURSE', 'REJECT_COURSE', 'APPROVE_COURSE', 'REJECT_COURSE',
'UPLOAD_FILE', 'DELETE_FILE', 'UPLOAD_FILE', 'DELETE_FILE',
@ -423,7 +423,7 @@ const formatDate = (date: string) => {
const getActionColor = (action: string) => { const getActionColor = (action: string) => {
if (!action) return 'grey'; if (!action) return 'grey';
if (action.includes('DELETE') || action.includes('REJECT') || action.includes('DEACTIVATE') || action.includes('ERROR')) return 'negative'; if (action.includes('DELETE') || action.includes('REJECT') || action.includes('DEACTIVATE')) return 'negative';
if (action.includes('UPDATE') || action.includes('CHANGE')) return 'warning'; if (action.includes('UPDATE') || action.includes('CHANGE')) return 'warning';
if (action.includes('CREATE') || action.includes('APPROVE') || action.includes('ACTIVATE')) return 'positive'; if (action.includes('CREATE') || action.includes('APPROVE') || action.includes('ACTIVATE')) return 'positive';
if (action.includes('LOGIN')) return 'info'; if (action.includes('LOGIN')) return 'info';

View file

@ -75,7 +75,7 @@
<div class="space-y-4"> <div class="space-y-4">
<!-- Stats --> <!-- Stats -->
<div class="bg-white rounded-xl shadow-sm p-6"> <div class="bg-white rounded-xl shadow-sm p-6">
<h3 class="font-semibold text-gray-700 mb-4">รายละเอยด</h3> <h3 class="font-semibold text-gray-700 mb-4">สถ</h3>
<div class="space-y-3"> <div class="space-y-3">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-500">จำนวนบท</span> <span class="text-gray-500">จำนวนบท</span>
@ -93,14 +93,6 @@
<span class="text-gray-500">แบบทดสอบ</span> <span class="text-gray-500">แบบทดสอบ</span>
<span class="font-medium">{{ quizCount }}</span> <span class="font-medium">{{ quizCount }}</span>
</div> </div>
<div class="flex justify-between">
<span class="text-gray-500">สรางเม</span>
<span>{{ formatDate(course.created_at) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">พเดทลาส</span>
<span>{{ formatDate(course.updated_at) }}</span>
</div>
</div> </div>
</div> </div>
@ -124,6 +116,21 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Timeline -->
<div class="bg-white rounded-xl shadow-sm p-6">
<h3 class="font-semibold text-gray-700 mb-4">อมลระบบ</h3>
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-gray-500">สรางเม</span>
<span>{{ formatDate(course.created_at) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">พเดทลาส</span>
<span>{{ formatDate(course.updated_at) }}</span>
</div>
</div>
</div>
</div> </div>
</div> </div>
@ -205,16 +212,12 @@
กรณาระบเหตผลในการปฏเสธคอร "{{ course?.title.th }}" กรณาระบเหตผลในการปฏเสธคอร "{{ course?.title.th }}"
</p> </p>
<q-input <q-input
ref="rejectInputRef"
v-model="rejectReason" v-model="rejectReason"
type="textarea" type="textarea"
outlined outlined
rows="4" rows="4"
label="เหตุผล *" label="เหตุผล *"
:rules="[ :rules="[val => !!val || 'กรุณาระบุเหตุผล']"
val => !!val || 'กรุณาระบุเหตุผล',
val => (val && val.length >= 10) || 'ระบุเหตุผลอย่างน้อย 10 ตัวอักษร'
]"
hide-bottom-space hide-bottom-space
lazy-rules="ondemand" lazy-rules="ondemand"
/> />
@ -226,6 +229,7 @@
label="ยืนยันการปฏิเสธ" label="ยืนยันการปฏิเสธ"
color="negative" color="negative"
:loading="actionLoading" :loading="actionLoading"
:disable="!rejectReason.trim()"
@click="confirmReject" @click="confirmReject"
/> />
</q-card-actions> </q-card-actions>
@ -235,7 +239,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useQuasar, QInput } from 'quasar'; import { useQuasar } from 'quasar';
import { adminService, type CourseDetailForReview } from '~/services/admin.service'; import { adminService, type CourseDetailForReview } from '~/services/admin.service';
definePageMeta({ definePageMeta({
@ -254,7 +258,6 @@ const error = ref('');
const actionLoading = ref(false); const actionLoading = ref(false);
const showRejectModal = ref(false); const showRejectModal = ref(false);
const rejectReason = ref(''); const rejectReason = ref('');
const rejectInputRef = ref<QInput | null>(null);
// Computed // Computed
const totalLessons = computed(() => const totalLessons = computed(() =>
@ -412,8 +415,7 @@ const confirmApprove = () => {
}; };
const confirmReject = async () => { const confirmReject = async () => {
rejectInputRef.value?.validate(); if (!course.value || !rejectReason.value.trim()) return;
if (rejectInputRef.value?.hasError || !course.value) return;
actionLoading.value = true; actionLoading.value = true;
try { try {

View file

@ -31,10 +31,8 @@
</div> </div>
</div> </div>
<!-- Search & View Toggle --> <!-- Search -->
<div class="bg-white rounded-xl shadow-sm p-4 mb-6"> <div class="bg-white rounded-xl shadow-sm p-4 mb-6">
<div class="flex gap-4 items-center">
<div class="flex-1">
<q-input <q-input
v-model="searchQuery" v-model="searchQuery"
placeholder="ค้นหาชื่อคอร์ส, ผู้สอน..." placeholder="ค้นหาชื่อคอร์ส, ผู้สอน..."
@ -51,28 +49,15 @@
</q-input> </q-input>
</div> </div>
<div class="flex justify-end mb-6">
<q-btn-toggle <q-btn-toggle
v-model="viewMode" v-model="viewMode"
toggle-color="primary" toggle-color="primary"
:options="[ :options="[
{ value: 'card', slot: 'card' }, { label: 'การ์ด', value: 'card' },
{ value: 'table', slot: 'table' } { label: 'ตาราง', value: 'table' }
]" ]"
dense />
rounded
unelevated
class="border"
>
<template v-slot:card>
<q-icon name="view_stream" size="20px" />
<q-tooltip>มมองการ</q-tooltip>
</template>
<template v-slot:table>
<q-icon name="view_list" size="20px" />
<q-tooltip>มมองตาราง</q-tooltip>
</template>
</q-btn-toggle>
</div>
</div> </div>
<!-- Pending Courses List --> <!-- Pending Courses List -->
@ -230,7 +215,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useQuasar, type QTableColumn } from 'quasar'; import { useQuasar } from 'quasar';
import { adminService, type PendingCourse } from '~/services/admin.service'; import { adminService, type PendingCourse } from '~/services/admin.service';
definePageMeta({ definePageMeta({
@ -247,12 +232,12 @@ const loading = ref(true);
const searchQuery = ref(''); const searchQuery = ref('');
const viewMode = ref('table'); const viewMode = ref('table');
const columns: QTableColumn[] = [ const columns = [
{ name: 'thumbnail', label: 'รูปปก', field: 'thumbnail', align: 'left' }, { name: 'thumbnail', label: 'รูปปก', field: 'thumbnail', align: 'left' },
{ name: 'title', label: 'ชื่อคอร์ส', field: (row: any) => row.title.th, align: 'left', sortable: true }, { name: 'title', label: 'ชื่อคอร์ส', field: (row: PendingCourse) => row.title.th, align: 'left', sortable: true },
{ name: 'instructor', label: 'ผู้สอน', field: (row: any) => getPrimaryInstructor(row), align: 'left', sortable: true }, { name: 'instructor', label: 'ผู้สอน', field: (row: PendingCourse) => getPrimaryInstructor(row), align: 'left', sortable: true },
{ name: 'stats', label: 'จำนวนบท', field: 'stats', align: 'center' }, { name: 'stats', label: 'จำนวนบท', field: 'stats', align: 'center' },
{ name: 'submitted_at', label: 'วันที่ส่ง', field: (row: any) => row.latest_submission?.created_at, align: 'left', sortable: true }, { name: 'submitted_at', label: 'วันที่ส่ง', field: (row: PendingCourse) => row.latest_submission?.created_at, align: 'left', sortable: true },
{ name: 'actions', label: '', field: 'actions', align: 'center' } { name: 'actions', label: '', field: 'actions', align: 'center' }
]; ];

View file

@ -146,7 +146,7 @@
<div class="card bg-white rounded-lg shadow-sm"> <div class="card bg-white rounded-lg shadow-sm">
<div class="p-6 border-b flex justify-between items-center"> <div class="p-6 border-b flex justify-between items-center">
<h2 class="text-lg font-semibold text-gray-900">จกรรมลาส</h2> <h2 class="text-lg font-semibold text-gray-900">จกรรมลาส</h2>
<q-btn flat color="primary" label="ดูทั้งหมด" to="/admin/audit-log" size="sm" /> <q-btn flat color="primary" label="ดูทั้งหมด" to="/admin/audit-logs" size="sm" />
</div> </div>
<div class="divide-y"> <div class="divide-y">
<div v-if="loading" class="p-8 text-center text-gray-500"> <div v-if="loading" class="p-8 text-center text-gray-500">

View file

@ -1,346 +0,0 @@
<template>
<NuxtLayout name="admin">
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-800">ดการคอรสแนะนำ</h1>
<p class="text-gray-600">Recommended Courses Management</p>
</div>
</div>
<!-- Search & Filter -->
<!-- <div class="bg-white p-4 rounded-lg shadow-sm mb-6">
<div class="flex gap-4">
<q-input
v-model="search"
outlined
dense
placeholder="ค้นหาคอร์ส..."
class="w-full max-w-md"
>
<template v-slot:prepend>
<q-icon name="search" />
</template>
</q-input>
</div>
</div> -->
<!-- Table -->
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
<q-table
:rows="courses"
:columns="columns"
row-key="id"
:loading="loading"
:pagination="initialPagination"
>
<!-- Thumbnail Column -->
<template v-slot:body-cell-thumbnail="props">
<q-td :props="props">
<q-img
:src="props.row.thumbnail_url || '/placeholder-course.jpg'"
class="w-16 h-10 rounded object-cover"
/>
</q-td>
</template>
<!-- Title Column -->
<template v-slot:body-cell-title="props">
<q-td :props="props">
<div class="font-medium text-gray-900">{{ props.row.title.th }}</div>
<div class="text-xs text-gray-500">{{ props.row.title.en }}</div>
</q-td>
</template>
<!-- Instructor Column -->
<template v-slot:body-cell-instructor="props">
<q-td :props="props">
<div class="flex items-center gap-2">
<q-avatar size="24px" class="bg-primary-100 text-primary">
<!-- <img :src="props.row.instructor.user.avatar_url || '/default-avatar.png'"> -->
<q-icon name="person" size="16px" />
</q-avatar>
<div>
<div class="text-sm font-medium">
{{ (props.row.instructors && props.row.instructors.length > 0) ? props.row.instructors.find((i: any) => i.is_primary)?.user.username : 'Unknown' }}
</div>
<!-- <div class="text-xs text-gray-500">{{ props.row.instructor.user.username }}</div> -->
</div>
</div>
</q-td>
</template>
<!-- Price Column -->
<template v-slot:body-cell-price="props">
<q-td :props="props">
<div v-if="props.row.is_free" class="text-green-600 font-medium">Free</div>
<div v-else class="font-medium">{{ formatPrice(props.row.price) }}</div>
</q-td>
</template>
<!-- Recommended Toggle Column -->
<template v-slot:body-cell-is_recommended="props">
<q-td :props="props">
<q-toggle
v-model="props.row.is_recommended"
color="green"
@update:model-value="(val) => handleToggleRecommendation(props.row, val)"
/>
</q-td>
</template>
<!-- Actions Column -->
<template v-slot:body-cell-actions="props">
<q-td :props="props">
<q-btn flat round dense icon="visibility" color="primary" @click="viewCourse(props.row.id)">
<q-tooltip>รายละเอยด</q-tooltip>
</q-btn>
</q-td>
</template>
</q-table>
</div>
<!-- View Details Dialog -->
<q-dialog v-model="showDialog" maximized transition-show="slide-up" transition-hide="slide-down">
<q-card>
<q-bar class="bg-primary-500 text-white">
<q-space />
<q-btn dense flat icon="close" v-close-popup>
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
</q-btn>
</q-bar>
<q-card-section>
<div class="text-h6">รายละเอยดคอร (Course Details)</div>
</q-card-section>
<q-card-section v-if="selectedCourse" class="q-pt-none">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Left Column: Image & Basic Info -->
<div>
<q-img
:src="selectedCourse.thumbnail_url || '/placeholder-course.jpg'"
class="rounded-lg shadow-md mb-4"
style="max-height: 300px; object-fit: cover;"
/>
<div class="text-2xl font-bold text-gray-800 mb-2">{{ selectedCourse.title.th }}</div>
<div class="text-lg text-gray-600 mb-4">{{ selectedCourse.title.en }}</div>
<div class="flex gap-2 mb-4">
<q-badge :color="selectedCourse.is_free ? 'green' : 'blue'" class="text-base p-2">
{{ selectedCourse.is_free ? 'FREE' : formatPrice(selectedCourse.price) }}
</q-badge>
<q-badge :color="getStatusColor(selectedCourse.status)" class="text-base p-2">
{{ selectedCourse.status }}
</q-badge>
<q-badge v-if="selectedCourse.have_certificate" color="orange" class="text-base p-2">
Certificate
</q-badge>
</div>
</div>
<!-- Right Column: Details -->
<div class="space-y-4">
<!-- Description -->
<div class="bg-gray-50 p-4 rounded-lg">
<div class="font-bold mb-2">รายละเอยด (Description)</div>
<div class="text-gray-700 whitespace-pre-wrap">{{ selectedCourse.description.th }}</div>
<div class="text-gray-500 mt-2 text-sm">{{ selectedCourse.description.en }}</div>
</div>
<!-- Category -->
<div class="bg-gray-50 p-4 rounded-lg gap-2">
<div class="font-bold mb-2">หมวดหม (Category):</div>
<div class="text-gray-700 mb-2">{{ selectedCourse.category.name.th }} ({{ selectedCourse.category.name.en }})</div>
</div>
<!-- Instructors -->
<div class="bg-gray-50 p-4 rounded-lg">
<div class="font-bold mb-2">สอน (Instructors)</div>
<div v-for="inst in selectedCourse.instructors" :key="inst.user_id" class="flex items-center gap-2 mb-2">
<q-avatar size="32px" class="bg-primary-100 text-primary">
<q-icon name="person" />
</q-avatar>
<div>
<div class="font-medium text-gray-700">{{ inst.user.username }}</div>
<div class="text-xs text-gray-500" v-if="inst.is_primary">(Primary)</div>
</div>
</div>
</div>
<!-- Stats -->
<div class="grid grid-cols-3 gap-2">
<div class="bg-blue-50 p-2 rounded-lg text-center">
<div class="text-2xl font-bold text-blue-800">{{ selectedCourse.chapters_count || 0 }}</div>
<div class="text-blue-600 text-sm">Chapters</div>
</div>
<div class="bg-purple-50 p-2 rounded-lg text-center">
<div class="text-2xl font-bold text-purple-800">{{ selectedCourse.lessons_count || 0 }}</div>
<div class="text-purple-600 text-sm">Lessons</div>
</div>
</div>
</div>
</div>
<!-- Course Structure -->
<div v-if="selectedCourse.chapters && selectedCourse.chapters.length > 0" class="mt-6">
<div class="font-bold text-lg mb-3">โครงสรางหลกสตร (Course Structure)</div>
<div class="space-y-3">
<q-expansion-item
v-for="(chapter, index) in selectedCourse.chapters"
:key="chapter.id"
:label="`บทที่ ${index + 1}: ${chapter.title.th}`"
:caption="`${chapter.lessons.length} บทเรียน`"
header-class="bg-gray-50 rounded-lg"
expand-icon-class="text-primary"
>
<div class="pl-4 pt-2">
<div
v-for="(lesson, lessonIndex) in chapter.lessons"
:key="lesson.id"
class="flex items-center gap-3 py-2 border-b last:border-b-0"
>
<q-icon name="article" color="primary" size="20px" />
<span class="text-gray-700">{{ lessonIndex + 1 }}. {{ lesson.title.th }}</span>
<span v-if="lesson.title.en" class="text-gray-400 text-xs ml-auto">{{ lesson.title.en }}</span>
</div>
</div>
</q-expansion-item>
</div>
</div>
</q-card-section>
<!-- Inner Loading -->
<q-inner-loading :showing="dialogLoading">
<q-spinner-gears size="50px" color="primary" />
</q-inner-loading>
</q-card>
</q-dialog>
</div>
</NuxtLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useQuasar } from 'quasar';
import { adminService, type RecommendedCourse } from '~/services/admin.service';
const $q = useQuasar();
const loading = ref(false);
const courses = ref<RecommendedCourse[]>([]);
const search = ref('');
// Dialog state
const showDialog = ref(false);
const dialogLoading = ref(false);
const selectedCourse = ref<RecommendedCourse | null>(null);
const initialPagination = {
sortBy: 'desc',
descending: false,
page: 1,
rowsPerPage: 10
}
const columns = [
{ name: 'id', label: 'ID', field: 'id', sortable: true, align: 'left' as const },
{ name: 'thumbnail', label: 'Image', field: 'thumbnail_url', align: 'left' as const },
{
name: 'title',
label: 'Course Name',
field: (row: RecommendedCourse) => row.title.th,
sortable: true,
align: 'left' as const
},
{
name: 'instructor',
label: 'Instructor',
field: (row: RecommendedCourse) => row.instructors?.find((i: any) => i.is_primary)?.user.username || '',
align: 'left' as const
},
{ name: 'category', label: 'Category', field: (row: RecommendedCourse) => row.category.name.th, sortable: true, align: 'left' as const },
{ name: 'price', label: 'Price', field: 'price', sortable: true, align: 'right' as const },
{ name: 'is_recommended', label: 'Recommended', field: 'is_recommended', sortable: true, align: 'center' as const },
{ name: 'actions', label: 'Actions', field: 'actions', align: 'center' as const },
];
const fetchCourses = async () => {
loading.value = true;
try {
courses.value = await adminService.getRecommendedCourses();
} catch (error) {
console.error('Error fetching courses:', error);
$q.notify({
type: 'negative',
message: 'ไม่สามารถโหลดข้อมูลคอร์สได้',
position: 'top'
});
} finally {
loading.value = false;
}
};
const viewCourse = async (id: number) => {
showDialog.value = true;
dialogLoading.value = true;
selectedCourse.value = null; // Clear previous data
try {
selectedCourse.value = await adminService.getRecommendedCourseById(id);
} catch (error) {
console.error('Error fetching course details:', error);
$q.notify({
type: 'negative',
message: 'ไม่สามารถโหลดข้อมูลรายละเอียดคอร์สได้',
position: 'top'
});
showDialog.value = false;
} finally {
dialogLoading.value = false;
}
};
const handleToggleRecommendation = async (course: RecommendedCourse, isRecommended: boolean) => {
try {
await adminService.toggleCourseRecommendation(course.id, isRecommended);
$q.notify({
type: 'positive',
message: isRecommended ? 'เพิ่มคอร์สแนะนำสำร็จ' : 'ยกเลิกคอร์สแนะนำสำเร็จ',
position: 'top'
});
} catch (error) {
console.error('Error toggling recommendation:', error);
// Revert the toggle if API fails
course.is_recommended = !isRecommended;
$q.notify({
type: 'negative',
message: 'เกิดข้อผิดพลาดในการบันทึกข้อมูล',
position: 'top'
});
}
};
const formatPrice = (price: number) => {
return new Intl.NumberFormat('th-TH', {
style: 'currency',
currency: 'THB'
}).format(price);
};
const getStatusColor = (status: string) => {
switch (status) {
case 'PUBLISHED': return 'positive';
case 'DRAFT': return 'grey';
case 'PENDING': return 'warning';
case 'REJECTED': return 'negative';
default: return 'grey';
}
};
onMounted(() => {
fetchCourses();
});
</script>

View file

@ -216,7 +216,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import { adminService, type AdminUserResponse, type RoleResponse } from '~/services/admin.service'; import { adminService, type AdminUserResponse } from '~/services/admin.service';
import { useAuthStore } from '~/stores/auth'; import { useAuthStore } from '~/stores/auth';
definePageMeta({ definePageMeta({
@ -228,7 +228,6 @@ const $q = useQuasar();
// Data // Data
const users = ref<AdminUserResponse[]>([]); const users = ref<AdminUserResponse[]>([]);
const roles = ref<RoleResponse[]>([]);
const loading = ref(true); const loading = ref(true);
const searchQuery = ref(''); const searchQuery = ref('');
const filterRole = ref<string | null>(null); const filterRole = ref<string | null>(null);
@ -287,14 +286,6 @@ const filteredUsers = computed(() => {
}); });
// Methods // Methods
const fetchRoles = async () => {
try {
roles.value = await adminService.getRoles();
} catch (error) {
console.error('Failed to fetch roles:', error);
}
};
const fetchUsers = async () => { const fetchUsers = async () => {
loading.value = true; loading.value = true;
try { try {
@ -337,32 +328,24 @@ const viewUser = (user: AdminUserResponse) => {
showViewModal.value = true; showViewModal.value = true;
}; };
const getRoleLabel = (code: string): string => {
const labels: Record<string, string> = {
INSTRUCTOR: 'Instructor',
STUDENT: 'Student',
ADMIN: 'Admin'
};
return labels[code] || code;
};
const changeRole = (user: AdminUserResponse) => { const changeRole = (user: AdminUserResponse) => {
// Find current role ID from fetched roles const roleIds: Record<string, number> = {
const currentRole = roles.value.find(r => r.code === user.role.code); INSTRUCTOR: 1,
STUDENT: 2,
// Build items from API roles ADMIN: 3
const roleItems = roles.value.map(r => ({ };
label: getRoleLabel(r.code),
value: r.id
}));
$q.dialog({ $q.dialog({
title: 'เปลี่ยน Role', title: 'เปลี่ยน Role',
message: `เลือก Role ใหม่สำหรับ ${user.profile.first_name}`, message: `เลือก Role ใหม่สำหรับ ${user.profile.first_name}`,
options: { options: {
type: 'radio', type: 'radio',
model: (currentRole?.id ?? 0) as any, model: roleIds[user.role.code] as any,
items: roleItems items: [
{ label: 'Instructor', value: 1 },
{ label: 'Student', value: 2 },
{ label: 'Admin', value: 3 }
]
}, },
cancel: true, cancel: true,
persistent: true persistent: true
@ -432,7 +415,6 @@ const exportExcel = () => {
// Lifecycle // Lifecycle
onMounted(() => { onMounted(() => {
fetchRoles();
fetchUsers(); fetchUsers();
}); });
</script> </script>

View file

@ -63,7 +63,7 @@
<div class="mb-6"> <div class="mb-6">
<q-input <q-input
v-model="form.description.th" v-model="form.description.th"
label="คำอธิบาย (ภาษาไทย) *" label="คำอธิบาย (ภาษาไทย)"
type="textarea" type="textarea"
outlined outlined
autogrow autogrow
@ -74,7 +74,7 @@
<div class="mb-6"> <div class="mb-6">
<q-input <q-input
v-model="form.description.en" v-model="form.description.en"
label="คำอธิบาย (English) *" label="คำอธิบาย (English)"
type="textarea" type="textarea"
outlined outlined
autogrow autogrow

View file

@ -14,7 +14,7 @@
</div> </div>
<!-- Stats Cards --> <!-- Stats Cards -->
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6"> <div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-xl shadow-sm p-5 text-center"> <div class="bg-white rounded-xl shadow-sm p-5 text-center">
<div class="text-3xl font-bold text-primary-600">{{ stats.total }}</div> <div class="text-3xl font-bold text-primary-600">{{ stats.total }}</div>
<div class="text-gray-500 text-sm mt-1">หลกสตรทงหมด</div> <div class="text-gray-500 text-sm mt-1">หลกสตรทงหมด</div>
@ -31,16 +31,12 @@
<div class="text-3xl font-bold text-gray-500">{{ stats.draft }}</div> <div class="text-3xl font-bold text-gray-500">{{ stats.draft }}</div>
<div class="text-gray-500 text-sm mt-1">แบบราง</div> <div class="text-gray-500 text-sm mt-1">แบบราง</div>
</div> </div>
<div class="bg-white rounded-xl shadow-sm p-5 text-center">
<div class="text-3xl font-bold text-red-600">{{ stats.rejected }}</div>
<div class="text-gray-500 text-sm mt-1">กปฏเสธ</div>
</div>
</div> </div>
<!-- Filter Bar --> <!-- Filter Bar -->
<div class="bg-white rounded-xl shadow-sm p-4 mb-6"> <div class="bg-white rounded-xl shadow-sm p-4 mb-6">
<div class="flex gap-4 items-center"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="flex-1"> <div class="md:col-span-2">
<q-input <q-input
v-model="searchQuery" v-model="searchQuery"
placeholder="ค้นหาหลักสูตร..." placeholder="ค้นหาหลักสูตร..."
@ -62,39 +58,15 @@
dense dense
emit-value emit-value
map-options map-options
style="min-width: 160px"
/> />
<q-btn-toggle
v-model="viewMode"
toggle-color="primary"
:options="[
{ value: 'card', slot: 'card' },
{ value: 'table', slot: 'table' }
]"
dense
rounded
unelevated
class="border"
>
<template v-slot:card>
<q-icon name="grid_view" size="20px" />
<q-tooltip>มมองการ</q-tooltip>
</template>
<template v-slot:table>
<q-icon name="view_list" size="20px" />
<q-tooltip>มมองตาราง</q-tooltip>
</template>
</q-btn-toggle>
</div> </div>
</div> </div>
<!-- Loading --> <!-- Courses Grid -->
<div v-if="loading" class="flex justify-center py-10"> <div v-if="loading" class="flex justify-center py-10">
<q-spinner-dots size="50px" color="primary" /> <q-spinner-dots size="50px" color="primary" />
</div> </div>
<!-- Empty State -->
<div v-else-if="filteredCourses.length === 0" class="bg-white rounded-xl shadow-sm p-10 text-center"> <div v-else-if="filteredCourses.length === 0" class="bg-white rounded-xl shadow-sm p-10 text-center">
<q-icon name="school" size="60px" color="grey-5" class="mb-4" /> <q-icon name="school" size="60px" color="grey-5" class="mb-4" />
<p class="text-gray-500 text-lg">งไมหลกสตร</p> <p class="text-gray-500 text-lg">งไมหลกสตร</p>
@ -106,8 +78,7 @@
/> />
</div> </div>
<!-- Card View --> <div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div v-else-if="viewMode === 'card'" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div <div
v-for="course in filteredCourses" v-for="course in filteredCourses"
:key="course.id" :key="course.id"
@ -155,10 +126,19 @@
dense dense
icon="visibility" icon="visibility"
color="grey" color="grey"
@click="handleViewDetails(course)" @click="navigateTo(`/instructor/courses/${course.id}`)"
> >
<q-tooltip>รายละเอยด</q-tooltip> <q-tooltip>รายละเอยด</q-tooltip>
</q-btn> </q-btn>
<!-- <q-btn
flat
dense
icon="edit"
color="primary"
@click="navigateTo(`/instructor/courses/${course.id}/edit`)"
>
<q-tooltip>แกไข</q-tooltip>
</q-btn> -->
<q-space /> <q-space />
<q-btn flat round dense icon="more_vert"> <q-btn flat round dense icon="more_vert">
<q-menu> <q-menu>
@ -182,165 +162,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Table View -->
<div v-else class="bg-white rounded-xl shadow-sm overflow-hidden">
<q-table
:rows="filteredCourses"
:columns="tableColumns"
row-key="id"
flat
:pagination="tablePagination"
:rows-per-page-options="[10, 20, 50, 0]"
@update:pagination="tablePagination = $event"
>
<!-- Thumbnail + Title -->
<template v-slot:body-cell-title="props">
<q-td :props="props">
<div class="flex items-center gap-3">
<div class="w-16 h-10 rounded overflow-hidden flex-shrink-0 bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center">
<img
v-if="props.row.thumbnail_url"
:src="props.row.thumbnail_url"
:alt="props.row.title.th"
class="w-full h-full object-cover"
@error="($event.target as HTMLImageElement).style.display = 'none'"
/>
<q-icon v-else name="school" size="20px" color="white" />
</div>
<div class="min-w-0">
<div class="font-medium text-gray-900 truncate">{{ props.row.title.th }}</div>
<div class="text-xs text-gray-400 truncate">{{ props.row.title.en }}</div>
</div>
</div>
</q-td>
</template>
<!-- Status Badge -->
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-badge :color="getStatusColor(props.row.status)">
{{ getStatusLabel(props.row.status) }}
</q-badge>
</q-td>
</template>
<!-- Price -->
<template v-slot:body-cell-price="props">
<q-td :props="props">
<span class="font-medium" :class="props.row.is_free ? 'text-green-600' : 'text-primary-600'">
{{ props.row.is_free ? 'ฟรี' : `฿${parseFloat(props.row.price).toLocaleString()}` }}
</span>
</q-td>
</template>
<!-- Date -->
<template v-slot:body-cell-created_at="props">
<q-td :props="props">
{{ formatDate(props.row.created_at) }}
</q-td>
</template>
<!-- Actions -->
<template v-slot:body-cell-actions="props">
<q-td :props="props">
<q-btn flat round dense icon="visibility" color="grey" size="sm" @click="handleViewDetails(props.row)">
<q-tooltip>รายละเอยด</q-tooltip>
</q-btn>
<q-btn flat round dense icon="more_vert" size="sm">
<q-menu>
<q-list style="min-width: 150px">
<q-item clickable v-close-popup @click="duplicateCourse(props.row)">
<q-item-section avatar>
<q-icon name="content_copy" />
</q-item-section>
<q-item-section>ทำสำเนา</q-item-section>
</q-item>
<q-separator />
<q-item clickable v-close-popup @click="confirmDelete(props.row)">
<q-item-section avatar>
<q-icon name="delete" color="negative" />
</q-item-section>
<q-item-section class="text-negative">ลบ</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</q-td>
</template>
</q-table>
</div>
<!-- Rejection Details Dialog -->
<q-dialog v-model="rejectionDialog">
<q-card style="min-width: 400px">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6 text-red">หลกสตรถกปฏเสธ</div>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-card-section>
<div class="text-subtitle1 font-bold mb-2">เหตผลการปฏเสธ:</div>
<div class="bg-red-50 p-4 rounded-lg text-red-800 border border-red-100">
{{ selectedRejectionCourse?.rejection_reason || 'ไม่ระบุเหตุผล' }}
</div>
<div class="text-gray-500 text-sm mt-4">
ณสามารถแกไขหลกสตรและสงขออนใหมได โดยการคนสถานะเปนแบบราง
</div>
</q-card-section>
<q-card-actions align="right" class="text-primary q-pt-none q-pb-md q-px-md">
<q-btn flat label="ยกเลิก" v-close-popup color="grey" />
<q-btn
label="คืนสถานะเป็นแบบร่าง"
color="primary"
@click="returnToDraft"
/>
</q-card-actions>
</q-card>
</q-dialog>
<!-- Clone Course Dialog -->
<q-dialog v-model="cloneDialog">
<q-card style="min-width: 400px">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">ทำสำเนาหลกสตร</div>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-card-section>
<div class="mb-4">
กรณาระบอสำหรบหลกสตรใหม
</div>
<q-input
v-model="cloneCourseTitleTh"
label="ชื่อหลักสูตร (ภาษาไทย)"
outlined
autofocus
class="mb-4"
:rules="[val => !!val || 'กรุณากรอกชื่อหลักสูตรภาษาไทย']"
/>
<q-input
v-model="cloneCourseTitleEn"
label="Course Name (English)"
outlined
:rules="[val => !!val || 'Please enter course name in English']"
/>
</q-card-section>
<q-card-actions align="right" class="text-primary q-pt-none q-pb-md q-px-md">
<q-btn flat label="ยกเลิก" v-close-popup color="grey" />
<q-btn
label="ยืนยันการทำสำเนา"
color="primary"
@click="confirmClone"
:loading="cloneLoading"
/>
</q-card-actions>
</q-card>
</q-dialog>
</div> </div>
</template> </template>
@ -360,17 +181,6 @@ const courses = ref<CourseResponse[]>([]);
const loading = ref(true); const loading = ref(true);
const searchQuery = ref(''); const searchQuery = ref('');
const filterStatus = ref<string | null>(null); const filterStatus = ref<string | null>(null);
const viewMode = ref<'card' | 'table'>('card');
// Table config
const tablePagination = ref({ page: 1, rowsPerPage: 10 });
const tableColumns = [
{ name: 'title', label: 'หลักสูตร', field: 'title', align: 'left' as const, sortable: true },
{ name: 'status', label: 'สถานะ', field: 'status', align: 'center' as const, sortable: true },
{ name: 'price', label: 'ราคา', field: 'price', align: 'center' as const, sortable: true },
{ name: 'created_at', label: 'วันที่สร้าง', field: 'created_at', align: 'center' as const, sortable: true },
{ name: 'actions', label: 'จัดการ', field: 'actions', align: 'center' as const }
];
// Status options // Status options
const statusOptions = [ const statusOptions = [
@ -378,8 +188,7 @@ const statusOptions = [
{ label: 'เผยแพร่แล้ว', value: 'APPROVED' }, { label: 'เผยแพร่แล้ว', value: 'APPROVED' },
{ label: 'รอตรวจสอบ', value: 'PENDING' }, { label: 'รอตรวจสอบ', value: 'PENDING' },
{ label: 'แบบร่าง', value: 'DRAFT' }, { label: 'แบบร่าง', value: 'DRAFT' },
{ label: 'ถูกปฏิเสธ', value: 'REJECTED' }, { label: 'ถูกปฏิเสธ', value: 'REJECTED' }
//{ label: '', value: 'ARCHIVED' }
]; ];
// Stats // Stats
@ -387,11 +196,10 @@ const stats = computed(() => ({
total: courses.value.length, total: courses.value.length,
approved: courses.value.filter(c => c.status === 'APPROVED').length, approved: courses.value.filter(c => c.status === 'APPROVED').length,
pending: courses.value.filter(c => c.status === 'PENDING').length, pending: courses.value.filter(c => c.status === 'PENDING').length,
draft: courses.value.filter(c => c.status === 'DRAFT').length, draft: courses.value.filter(c => c.status === 'DRAFT').length
rejected: courses.value.filter(c => c.status === 'REJECTED').length
})); }));
// Filtered courses (search only, status is handled server-side) // Filtered courses
const filteredCourses = computed(() => { const filteredCourses = computed(() => {
let result = courses.value; let result = courses.value;
@ -403,6 +211,10 @@ const filteredCourses = computed(() => {
); );
} }
if (filterStatus.value) {
result = result.filter(course => course.status === filterStatus.value);
}
return result; return result;
}); });
@ -410,7 +222,7 @@ const filteredCourses = computed(() => {
const fetchCourses = async () => { const fetchCourses = async () => {
loading.value = true; loading.value = true;
try { try {
courses.value = await instructorService.getCourses(filterStatus.value || undefined); courses.value = await instructorService.getCourses();
} catch (error) { } catch (error) {
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
@ -422,18 +234,12 @@ const fetchCourses = async () => {
} }
}; };
// Re-fetch when status filter changes
watch(filterStatus, () => {
fetchCourses();
});
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
const colors: Record<string, string> = { const colors: Record<string, string> = {
APPROVED: 'green', APPROVED: 'green',
PENDING: 'orange', PENDING: 'orange',
DRAFT: 'grey', DRAFT: 'grey',
REJECTED: 'red', REJECTED: 'red'
ARCHIVED: 'blue-grey'
}; };
return colors[status] || 'grey'; return colors[status] || 'grey';
}; };
@ -443,8 +249,7 @@ const getStatusLabel = (status: string) => {
APPROVED: 'เผยแพร่แล้ว', APPROVED: 'เผยแพร่แล้ว',
PENDING: 'รอตรวจสอบ', PENDING: 'รอตรวจสอบ',
DRAFT: 'แบบร่าง', DRAFT: 'แบบร่าง',
REJECTED: 'ถูกปฏิเสธ', REJECTED: 'ถูกปฏิเสธ'
ARCHIVED: 'เก็บถาวร'
}; };
return labels[status] || status; return labels[status] || status;
}; };
@ -456,45 +261,15 @@ const formatDate = (date: string) => {
year: '2-digit' year: '2-digit'
}); });
}; };
// Clone Dialog
const cloneDialog = ref(false);
const cloneLoading = ref(false);
const cloneCourseTitleTh = ref('');
const cloneCourseTitleEn = ref('');
const courseToClone = ref<CourseResponse | null>(null);
const duplicateCourse = (course: CourseResponse) => { const duplicateCourse = (course: CourseResponse) => {
courseToClone.value = course;
cloneCourseTitleTh.value = `${course.title.th} (Copy)`;
cloneCourseTitleEn.value = `${course.title.en} (Copy)`;
cloneDialog.value = true;
};
const confirmClone = async () => {
if (!courseToClone.value || !cloneCourseTitleTh.value || !cloneCourseTitleEn.value) return;
cloneLoading.value = true;
try {
const response = await instructorService.cloneCourse(courseToClone.value.id, cloneCourseTitleTh.value, cloneCourseTitleEn.value);
$q.notify({ $q.notify({
type: 'positive', type: 'info',
message: response.message || 'ทำสำเนาหลักสูตรสำเร็จ', message: `กำลังทำสำเนา "${course.title.th}"...`,
position: 'top' position: 'top'
}); });
cloneDialog.value = false;
fetchCourses(); // Refresh list
} catch (error: any) {
$q.notify({
type: 'negative',
message: error.data?.message || 'ไม่สามารถทำสำเนาหลักสูตรได้',
position: 'top'
});
} finally {
cloneLoading.value = false;
}
}; };
const confirmDelete = (course: CourseResponse) => { const confirmDelete = (course: CourseResponse) => {
$q.dialog({ $q.dialog({
title: 'ยืนยันการลบ', title: 'ยืนยันการลบ',
@ -521,41 +296,6 @@ const confirmDelete = (course: CourseResponse) => {
}); });
}; };
// Rejection Dialog
const rejectionDialog = ref(false);
const selectedRejectionCourse = ref<CourseResponse | null>(null);
const handleViewDetails = (course: CourseResponse) => {
if (course.status === 'REJECTED') {
selectedRejectionCourse.value = course;
rejectionDialog.value = true;
} else {
navigateTo(`/instructor/courses/${course.id}`);
}
};
const returnToDraft = async () => {
if (!selectedRejectionCourse.value) return;
try {
const response = await instructorService.setCourseDraft(selectedRejectionCourse.value.id);
$q.notify({
type: 'positive',
message: response.message || 'คืนสถานะเป็นแบบร่างสำเร็จ',
position: 'top'
});
rejectionDialog.value = false;
selectedRejectionCourse.value = null;
fetchCourses(); // Refresh list
} catch (error: any) {
$q.notify({
type: 'negative',
message: error.data?.message || 'ไม่สามารถคืนสถานะได้',
position: 'top'
});
}
};
// Lifecycle // Lifecycle
onMounted(() => { onMounted(() => {
fetchCourses(); fetchCourses();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

View file

@ -254,91 +254,13 @@ export interface AuditLogStats {
recentActivity: AuditLog[]; recentActivity: AuditLog[];
} }
export interface RecommendedCourse {
id: number;
title: {
th: string;
en: string;
};
slug: string;
description: {
th: string;
en: string;
};
thumbnail_url: string | null;
price: number;
is_free: boolean;
have_certificate: boolean;
is_recommended: boolean;
status: string;
created_at: string;
updated_at: string;
category: {
id: number;
name: {
th: string;
en: string;
};
};
instructors: {
user_id: number;
is_primary: boolean;
user: {
id: number;
username: string;
email: string;
};
}[];
creator: {
id: number;
username: string;
email: string;
};
chapters_count: number;
lessons_count: number;
chapters?: {
id: number;
title: { th: string; en: string };
sort_order: number;
lessons: {
id: number;
title: { th: string; en: string };
}[];
}[];
}
export interface RecommendedCoursesListResponse {
code: number;
message: string;
data: RecommendedCourse[];
total: number;
}
// Helper function to get auth token from cookie // Helper function to get auth token from cookie
const getAuthToken = (): string => { const getAuthToken = (): string => {
const tokenCookie = useCookie('token'); const tokenCookie = useCookie('token');
return tokenCookie.value || ''; return tokenCookie.value || '';
}; };
// Role interface
export interface RoleResponse {
id: number;
code: string;
}
export const adminService = { export const adminService = {
async getRoles(): Promise<RoleResponse[]> {
const config = useRuntimeConfig();
const token = getAuthToken();
const response = await $fetch<{ roles: RoleResponse[] }>('/api/user/roles', {
baseURL: config.public.apiBaseUrl as string,
headers: {
Authorization: `Bearer ${token}`
}
});
return response.roles;
},
async getUsers(): Promise<AdminUserResponse[]> { async getUsers(): Promise<AdminUserResponse[]> {
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const token = getAuthToken(); const token = getAuthToken();
@ -595,48 +517,6 @@ export const adminService = {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
query: { days } query: { days }
}); });
return response;
},
// ============ Recommended Courses ============
async getRecommendedCourses(): Promise<RecommendedCourse[]> {
const config = useRuntimeConfig();
const token = getAuthToken();
const response = await $fetch<RecommendedCoursesListResponse>('/api/admin/recommended-courses', {
baseURL: config.public.apiBaseUrl as string,
headers: {
Authorization: `Bearer ${token}`
}
});
return response.data;
},
async getRecommendedCourseById(id: number): Promise<RecommendedCourse> {
const config = useRuntimeConfig();
const token = getAuthToken();
const response = await $fetch<ApiResponse<RecommendedCourse>>(`/api/admin/recommended-courses/${id}`, {
baseURL: config.public.apiBaseUrl as string,
headers: {
Authorization: `Bearer ${token}`
}
});
return response.data;
},
async toggleCourseRecommendation(courseId: number, isRecommended: boolean): Promise<ApiResponse<void>> {
const config = useRuntimeConfig();
const token = getAuthToken();
const response = await $fetch<ApiResponse<void>>(`/api/admin/recommended-courses/${courseId}/toggle`, {
method: 'PUT',
baseURL: config.public.apiBaseUrl as string,
headers: {
Authorization: `Bearer ${token}`
},
query: { is_recommended: isRecommended }
});
return response; return response;
} }
}; };

View file

@ -208,12 +208,8 @@ const authRequest = async <T>(
}; };
export const instructorService = { export const instructorService = {
async getCourses(status?: string): Promise<CourseResponse[]> { async getCourses(): Promise<CourseResponse[]> {
let url = '/api/instructors/courses'; const response = await authRequest<CoursesListResponse>('/api/instructors/courses');
if (status) {
url += `?status=${status}`;
}
const response = await authRequest<CoursesListResponse>(url);
return response.data; return response.data;
}, },
@ -305,22 +301,6 @@ export const instructorService = {
return await authRequest<ApiResponse<void>>(`/api/instructors/courses/send-review/${courseId}`, { method: 'POST' }); return await authRequest<ApiResponse<void>>(`/api/instructors/courses/send-review/${courseId}`, { method: 'POST' });
}, },
async setCourseDraft(courseId: number): Promise<ApiResponse<void>> {
return await authRequest<ApiResponse<void>>(`/api/instructors/courses/set-draft/${courseId}`, { method: 'POST' });
},
async cloneCourse(courseId: number, titleTh: string, titleEn: string): Promise<ApiResponse<CourseResponse>> {
return await authRequest<ApiResponse<CourseResponse>>(`/api/instructors/courses/${courseId}/clone`, {
method: 'POST',
body: {
title: {
en: titleEn,
th: titleTh
}
}
});
},
async getEnrolledStudents( async getEnrolledStudents(
courseId: number, courseId: number,
page: number = 1, page: number = 1,