Compare commits

...

32 commits

Author SHA1 Message Date
Missez
9dc8636d31 feat: Implement admin user and pending course management, instructor course listing, and a dedicated admin service.
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 42s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 4s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 1s
2026-02-24 14:43:06 +07:00
supalerk-ar66
5ad7184e6c feat: Introduce keyboard shortcut to focus chat input and prevent message submission during text composition. 2026-02-24 11:56:21 +07:00
supalerk-ar66
c697a15525 refactor: Extract chat input state management into a custom hook. 2026-02-24 11:49:24 +07:00
supalerk-ar66
8cbef76b1e Please provide the file changes to generate a commit message.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 43s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 3s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 2s
2026-02-24 11:17:33 +07:00
supalerk-ar66
797e3db644 feat: Implement initial core features including course browsing, authentication, user dashboard, and internationalization. 2026-02-24 11:12:26 +07:00
Missez
031ca5c984 feat: Add initial e-learning frontend setup including admin and instructor services, layouts, and pages.
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 46s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 6s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 1s
2026-02-24 09:25:02 +07:00
supalerk-ar66
01d249c19a feat: add initial frontend pages for course browsing, recommendations, and user dashboard.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 38s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 3s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
2026-02-23 17:44:02 +07:00
JakkrapartXD
0588ad7acd feat: Reduce minimum audit log deletion period to 6 days and update enrollment last access only for active enrollments.
All checks were successful
Build and Deploy Backend / Build Backend Docker Image (push) Successful in 26s
Build and Deploy Backend / Deploy E-learning Backend to Dev Server (push) Successful in 3s
Build and Deploy Backend / Notify Deployment Status (push) Successful in 1s
2026-02-23 13:54:03 +07:00
JakkrapartXD
ce2a472cac feat: Update enrollment last accessed timestamp on course content access and correct k6 test comment typo.
All checks were successful
Build and Deploy Backend / Build Backend Docker Image (push) Successful in 27s
Build and Deploy Backend / Deploy E-learning Backend to Dev Server (push) Successful in 4s
Build and Deploy Backend / Notify Deployment Status (push) Successful in 1s
2026-02-23 13:18:38 +07:00
supalerk-ar66
096b5bbc52 feat: Add useCourse composable for course data management and CourseDetailView component for displaying course details. 2026-02-20 16:47:27 +07:00
supalerk-ar66
13ad2097df feat: Implement default authenticated user layout and initial dashboard pages for 'My Courses' and 'Profile'.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 48s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 3s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 2s
2026-02-20 15:18:30 +07:00
JakkrapartXD
45b9c6516b feat: Add user role retrieval, enhance recommended course filtering and detail, and introduce new k6 load tests. 2026-02-20 15:16:43 +07:00
supalerk-ar66
e3873f616e feat: Add initial pages and components for user dashboard, profile, course discovery, and classroom learning with i18n support.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 47s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
2026-02-20 14:58:18 +07:00
Missez
f26a94076c feat: Introduce comprehensive course management features for admin, including recommended, pending, and detailed course views, and instructor course listing with a lesson preview component.
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 46s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 4s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 2s
2026-02-20 14:33:08 +07:00
supalerk-ar66
0f92f0d00c feat: Implement user profile management, course browsing, and dashboard structure with new components and layouts.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 45s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
2026-02-19 17:37:28 +07:00
JakkrapartXD
c118e5c3dc feat: Add k6 video watching load test and remove optional comment body from admin course approval.
All checks were successful
Build and Deploy Backend / Build Backend Docker Image (push) Successful in 28s
Build and Deploy Backend / Deploy E-learning Backend to Dev Server (push) Successful in 3s
Build and Deploy Backend / Notify Deployment Status (push) Successful in 1s
2026-02-19 15:20:34 +07:00
supalerk-ar66
743d3b8c2f feat: introduce LandingHeader component with scroll-adaptive styling and mobile navigation, and a new LandingFooter component.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 41s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 3s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
2026-02-19 13:49:44 +07:00
supalerk-ar66
0f88aeb06f feat: create responsive LandingHeader component with scroll-triggered glass effect and mobile drawer menu. 2026-02-19 13:33:39 +07:00
supalerk-ar66
76b64a30ae feat: Initialize project with core Nuxt configuration, Quasar layouts, global Tailwind CSS, and essential components. 2026-02-19 13:12:14 +07:00
supalerk-ar66
1b9119e606 feat: Implement core application UI with new headers, navigation, and initial pages.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 42s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 3s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
2026-02-19 10:39:44 +07:00
supalerk-ar66
3fa236cff5 feat: Implement initial application layouts, global navigation, and course browsing pages with i18n support.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 41s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
2026-02-18 16:28:29 +07:00
JakkrapartXD
b56f604890 feat: introduce Joi validation schemas and integrate them across various controllers for categories, lessons, courses, chapters, announcements, and admin course approvals.
All checks were successful
Build and Deploy Backend / Build Backend Docker Image (push) Successful in 26s
Build and Deploy Backend / Deploy E-learning Backend to Dev Server (push) Successful in 3s
Build and Deploy Backend / Notify Deployment Status (push) Successful in 2s
2026-02-18 15:59:40 +07:00
JakkrapartXD
c5aa195b13 feat: implement course cloning functionality including chapters, lessons, quizzes, and attachments for instructors.
All checks were successful
Build and Deploy Backend / Build Backend Docker Image (push) Successful in 24s
Build and Deploy Backend / Deploy E-learning Backend to Dev Server (push) Successful in 3s
Build and Deploy Backend / Notify Deployment Status (push) Successful in 1s
2026-02-13 17:41:01 +07:00
Missez
5442f1beb6 feat: Introduce core admin and instructor dashboards with dedicated services, pages, and layouts. 2026-02-13 15:26:51 +07:00
JakkrapartXD
af14610442 feat: Add token-based authorization to category deletion and enhance user registration with error handling and audit logging. 2026-02-13 14:54:45 +07:00
JakkrapartXD
45941fbe6c feat: Add error audit logging to instructor course operations and implement status filtering for listing courses. 2026-02-13 14:45:59 +07:00
supalerk-ar66
21273fcaeb feat: Implement the core online learning classroom interface with video player, quiz management, and announcements. 2026-02-13 11:42:10 +07:00
supalerk-ar66
11f9ad57cd feat: Add initial internationalization files for English and Thai languages.
All checks were successful
Build and Deploy Backend / Build Backend Docker Image (push) Successful in 14s
Build and Deploy Backend / Deploy E-learning Backend to Dev Server (push) Successful in 3s
Build and Deploy Backend / Notify Deployment Status (push) Successful in 1s
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 35s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
2026-02-12 16:56:02 +07:00
supalerk-ar66
1146373ca0 feat: Implement quiz functionality with a new dedicated page and comprehensive internationalization strings.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 35s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
2026-02-12 16:15:09 +07:00
supalerk-ar66
7f5119e5aa feat: Implement core classroom functionality including video player, learning and quiz pages, course detail view, and i18n support. 2026-02-12 16:05:37 +07:00
supalerk-ar66
008f712480 feat: add CurriculumSidebar component for displaying course chapters, lessons, and progress. 2026-02-12 13:10:09 +07:00
supalerk-ar66
a0ca6f7e6b feat: Implement core e-learning platform features including quiz, dashboard, course discovery, and classroom learning with i18n support.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 35s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
2026-02-12 12:01:37 +07:00
99 changed files with 7530 additions and 1914 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -27,6 +27,18 @@ import {
UpdateQuizResponse,
UpdateQuizBody,
} from '../types/ChaptersLesson.typs';
import {
CreateChapterValidator,
UpdateChapterValidator,
ReorderChapterValidator,
CreateLessonValidator,
UpdateLessonValidator,
ReorderLessonsValidator,
AddQuestionValidator,
UpdateQuestionValidator,
ReorderQuestionValidator,
UpdateQuizValidator
} from '../validators/ChaptersLesson.validator';
const chaptersLessonService = new ChaptersLessonService();
@ -55,6 +67,10 @@ export class ChaptersLessonInstructorController {
): Promise<CreateChapterResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
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({
token,
course_id: courseId,
@ -82,6 +98,10 @@ export class ChaptersLessonInstructorController {
): Promise<UpdateChapterResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
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({
token,
course_id: courseId,
@ -125,6 +145,10 @@ export class ChaptersLessonInstructorController {
): Promise<ReorderChapterResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
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({
token,
course_id: courseId,
@ -170,6 +194,10 @@ export class ChaptersLessonInstructorController {
): Promise<CreateLessonResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
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({
token,
course_id: courseId,
@ -197,6 +225,10 @@ export class ChaptersLessonInstructorController {
): Promise<UpdateLessonResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
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({
token,
course_id: courseId,
@ -246,6 +278,10 @@ export class ChaptersLessonInstructorController {
): Promise<ReorderLessonsResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
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({
token,
course_id: courseId,
@ -275,6 +311,10 @@ export class ChaptersLessonInstructorController {
): Promise<AddQuestionResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
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({
token,
course_id: courseId,
@ -300,6 +340,10 @@ export class ChaptersLessonInstructorController {
): Promise<UpdateQuestionResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
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({
token,
course_id: courseId,
@ -322,6 +366,10 @@ export class ChaptersLessonInstructorController {
): Promise<ReorderQuestionResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
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({
token,
course_id: courseId,
@ -371,6 +419,10 @@ export class ChaptersLessonInstructorController {
): Promise<UpdateQuizResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
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({
token,
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 { CoursesInstructorService } from '../services/CoursesInstructor.service';
import {
createCourses,
createCourseResponse,
GetMyCourseResponse,
ListMyCourseResponse,
addinstructorCourseResponse,
removeinstructorCourseResponse,
setprimaryCourseInstructorResponse,
GetMyCourseResponse,
UpdateMyCourse,
UpdateMyCourseResponse,
DeleteMyCourseResponse,
submitCourseResponse,
listinstructorCourseResponse,
GetCourseApprovalsResponse,
SearchInstructorResponse,
addinstructorCourseResponse,
removeinstructorCourseResponse,
setprimaryCourseInstructorResponse,
GetEnrolledStudentsResponse,
GetEnrolledStudentDetailResponse,
GetQuizScoresResponse,
GetQuizAttemptDetailResponse,
GetEnrolledStudentDetailResponse,
GetCourseApprovalsResponse,
SearchInstructorResponse,
GetCourseApprovalHistoryResponse,
setCourseDraftResponse,
CloneCourseResponse,
} from '../types/CoursesInstructor.types';
import { CreateCourseValidator } from "../validators/CoursesInstructor.validator";
import { CreateCourseValidator, UpdateCourseValidator, CloneCourseValidator } from "../validators/CoursesInstructor.validator";
import jwt from 'jsonwebtoken';
import { config } from '../config';
@ -41,12 +41,15 @@ export class CoursesInstructorController {
@SuccessResponse('200', 'Courses retrieved successfully')
@Response('401', 'Invalid or expired token')
@Response('404', 'Courses not found')
public async listMyCourses(@Request() request: any): Promise<ListMyCourseResponse> {
public async listMyCourses(
@Request() request: any,
@Query() status?: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'ARCHIVED'
): Promise<ListMyCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await CoursesInstructorService.listMyCourses(token);
return await CoursesInstructorService.listMyCourses({ token, status });
}
/**
@ -99,9 +102,11 @@ export class CoursesInstructorController {
@Response('404', 'Course not found')
public async updateCourse(@Request() request: any, @Path() courseId: number, @Body() body: UpdateMyCourse): Promise<UpdateMyCourseResponse> {
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);
}
@ -174,6 +179,36 @@ export class CoursesInstructorController {
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

View file

@ -16,6 +16,7 @@ import {
GetQuizAttemptsResponse,
} from '../types/CoursesStudent.types';
import { EnrollmentStatus } from '@prisma/client';
import { SaveVideoProgressValidator, SubmitQuizValidator } from '../validators/CoursesStudent.validator';
@Route('api/students')
@Tags('CoursesStudent')
@ -149,9 +150,11 @@ export class CoursesStudentController {
@Body() body: SaveVideoProgressBody
): Promise<SaveVideoProgressResponse> {
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({
token,
lesson_id: lessonId,
@ -225,9 +228,11 @@ export class CoursesStudentController {
@Body() body: SubmitQuizBody
): Promise<SubmitQuizResponse> {
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({
token,
course_id: courseId,

View file

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

View file

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

View file

@ -10,7 +10,8 @@ import {
ChangePasswordResponse,
updateAvatarResponse,
SendVerifyEmailResponse,
VerifyEmailResponse
VerifyEmailResponse,
rolesResponse
} from '../types/user.types';
import { ChangePassword } from '../types/auth.types';
import { profileUpdateSchema, changePasswordSchema } from "../validators/user.validator";
@ -56,6 +57,18 @@ export class UserController {
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
* @summary Change user password using old password

View file

@ -1,6 +1,7 @@
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 { AnnouncementsService } from '../services/announcements.service';
import { CreateAnnouncementValidator, UpdateAnnouncementValidator } from '../validators/announcements.validator';
import {
ListAnnouncementResponse,
CreateAnnouncementResponse,
@ -68,6 +69,10 @@ export class AnnouncementsController {
// Parse JSON data field
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({
token,
course_id: courseId,
@ -100,6 +105,11 @@ export class AnnouncementsController {
): Promise<UpdateAnnouncementResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
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({
token,
course_id: courseId,

View file

@ -18,7 +18,7 @@ export class AdminCourseApprovalService {
/**
* Get all pending courses for admin review
*/
static async listPendingCourses(): Promise<ListPendingCoursesResponse> {
static async listPendingCourses(token: string): Promise<ListPendingCoursesResponse> {
try {
const courses = await prisma.course.findMany({
where: { status: 'PENDING' },
@ -96,6 +96,16 @@ export class AdminCourseApprovalService {
};
} catch (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;
}
}
@ -103,7 +113,7 @@ export class AdminCourseApprovalService {
/**
* Get course details for admin review
*/
static async getCourseDetail(courseId: number): Promise<GetCourseDetailForAdminResponse> {
static async getCourseDetail(token: string, courseId: number): Promise<GetCourseDetailForAdminResponse> {
try {
const course = await prisma.course.findUnique({
where: { id: courseId },
@ -123,7 +133,11 @@ export class AdminCourseApprovalService {
},
chapters: {
orderBy: { sort_order: 'asc' },
include: {
select: {
id: true,
title: true,
sort_order: true,
is_published: true,
lessons: {
orderBy: { sort_order: 'asc' },
select: {
@ -214,6 +228,16 @@ export class AdminCourseApprovalService {
};
} catch (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;
}
}
@ -275,6 +299,17 @@ export class AdminCourseApprovalService {
};
} catch (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;
}
}
@ -341,6 +376,17 @@ export class AdminCourseApprovalService {
};
} catch (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;
}
}

View file

@ -142,6 +142,17 @@ export class ChaptersLessonService {
return { code: 200, message: 'Chapter created successfully', data: chapter as ChapterData };
} catch (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;
}
}
@ -163,6 +174,17 @@ export class ChaptersLessonService {
return { code: 200, message: 'Chapter updated successfully', data: chapter as ChapterData };
} catch (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;
}
}
@ -197,6 +219,17 @@ export class ChaptersLessonService {
return { code: 200, message: 'Chapter deleted successfully' };
} catch (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;
}
}
@ -280,6 +313,17 @@ export class ChaptersLessonService {
return { code: 200, message: 'Chapter reordered successfully', data: chapters as ChapterData[] };
} catch (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;
}
}
@ -354,6 +398,17 @@ export class ChaptersLessonService {
return { code: 200, message: 'Lesson created successfully', data: lesson as LessonData };
} catch (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;
}
}
@ -494,6 +549,17 @@ export class ChaptersLessonService {
return { code: 200, message: 'Lesson fetched successfully', data: lessonData as LessonData };
} catch (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;
}
}
@ -515,6 +581,17 @@ export class ChaptersLessonService {
return { code: 200, message: 'Lesson updated successfully', data: lesson as LessonData };
} catch (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;
}
}
@ -605,6 +682,17 @@ export class ChaptersLessonService {
return { code: 200, message: 'Lessons reordered successfully', data: lessons as LessonData[] };
} catch (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;
}
}
@ -676,6 +764,17 @@ export class ChaptersLessonService {
return { code: 200, message: 'Lesson deleted successfully' };
} catch (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;
}
}
@ -754,6 +853,17 @@ export class ChaptersLessonService {
};
} catch (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;
}
}
@ -836,6 +946,17 @@ export class ChaptersLessonService {
};
} catch (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;
}
}
@ -917,6 +1038,17 @@ export class ChaptersLessonService {
};
} catch (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;
}
}
@ -993,6 +1125,17 @@ export class ChaptersLessonService {
};
} catch (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;
}
}
@ -1051,6 +1194,17 @@ export class ChaptersLessonService {
return { code: 200, message: 'Attachment deleted successfully' };
} catch (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;
}
}
@ -1127,6 +1281,17 @@ export class ChaptersLessonService {
return { code: 200, message: 'Question added successfully', data: completeQuestion as QuizQuestionData };
} catch (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;
}
}
@ -1202,6 +1367,17 @@ export class ChaptersLessonService {
return { code: 200, message: 'Question updated successfully', data: completeQuestion as QuizQuestionData };
} catch (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;
}
}
@ -1295,6 +1471,17 @@ export class ChaptersLessonService {
return { code: 200, message: 'Question reordered successfully', data: questions as QuizQuestionData[] };
} catch (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;
}
}
@ -1343,6 +1530,17 @@ export class ChaptersLessonService {
return { code: 200, message: 'Question deleted successfully' };
} catch (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;
}
}

View file

@ -10,6 +10,7 @@ import {
UpdateCourseInput,
createCourseResponse,
GetMyCourseResponse,
ListMyCoursesInput,
ListMyCourseResponse,
addinstructorCourse,
addinstructorCourseResponse,
@ -33,6 +34,8 @@ import {
GetEnrolledStudentDetailInput,
GetEnrolledStudentDetailResponse,
GetCourseApprovalHistoryResponse,
CloneCourseInput,
CloneCourseResponse,
setCourseDraft,
setCourseDraftResponse,
} from "../types/CoursesInstructor.types";
@ -102,16 +105,27 @@ export class CoursesInstructorService {
};
} catch (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;
}
}
static async listMyCourses(token: string): Promise<ListMyCourseResponse> {
static async listMyCourses(input: ListMyCoursesInput): Promise<ListMyCourseResponse> {
try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
const decoded = jwt.verify(input.token, config.jwt.secret) as { id: number; type: string };
const courseInstructors = await prisma.courseInstructor.findMany({
where: {
user_id: decoded.id
user_id: decoded.id,
course: input.status ? { status: input.status } : undefined
},
include: {
course: true
@ -143,6 +157,17 @@ export class CoursesInstructorService {
};
} catch (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;
}
}
@ -200,6 +225,17 @@ export class CoursesInstructorService {
};
} catch (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;
}
}
@ -222,6 +258,17 @@ export class CoursesInstructorService {
};
} catch (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;
}
}
@ -275,6 +322,17 @@ export class CoursesInstructorService {
};
} catch (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;
}
}
@ -291,6 +349,15 @@ export class CoursesInstructorService {
id: courseId
}
});
await auditService.logSync({
userId: courseInstructorId.user_id,
action: AuditAction.DELETE,
entityType: 'Course',
entityId: courseId,
metadata: {
operation: 'delete_course'
}
});
return {
code: 200,
message: 'Course deleted successfully',
@ -298,6 +365,17 @@ export class CoursesInstructorService {
};
} catch (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;
}
}
@ -319,12 +397,32 @@ export class CoursesInstructorService {
status: 'PENDING'
}
});
await auditService.logSync({
userId: decoded.id,
action: AuditAction.UPDATE,
entityType: 'Course',
entityId: sendCourseForReview.course_id,
metadata: {
operation: 'send_course_for_review'
}
});
return {
code: 200,
message: 'Course sent for review successfully',
};
} catch (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;
}
}
@ -347,6 +445,17 @@ export class CoursesInstructorService {
};
} catch (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;
}
}
@ -358,8 +467,6 @@ export class CoursesInstructorService {
total: number;
}> {
try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
// Validate instructor access
await this.validateCourseInstructor(token, courseId);
@ -384,6 +491,17 @@ export class CoursesInstructorService {
};
} catch (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;
}
}
@ -445,6 +563,17 @@ export class CoursesInstructorService {
};
} catch (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;
}
}
@ -490,12 +619,35 @@ 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 {
code: 200,
message: 'Instructor added to course successfully',
};
} catch (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;
}
}
@ -511,12 +663,36 @@ 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 {
code: 200,
message: 'Instructor removed from course successfully',
};
} catch (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;
}
}
@ -567,6 +743,17 @@ export class CoursesInstructorService {
};
} catch (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;
}
}
@ -585,12 +772,36 @@ export class CoursesInstructorService {
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 {
code: 200,
message: 'Primary instructor set successfully',
};
} catch (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;
}
}
@ -629,7 +840,6 @@ export class CoursesInstructorService {
static async getEnrolledStudents(input: GetEnrolledStudentsInput): Promise<GetEnrolledStudentsResponse> {
try {
const { token, course_id, page = 1, limit = 20, search, status } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor
await this.validateCourseInstructor(token, course_id);
@ -707,6 +917,17 @@ export class CoursesInstructorService {
};
} catch (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;
}
}
@ -874,6 +1095,17 @@ export class CoursesInstructorService {
};
} catch (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;
}
}
@ -885,7 +1117,6 @@ export class CoursesInstructorService {
static async getQuizAttemptDetail(input: GetQuizAttemptDetailInput): Promise<GetQuizAttemptDetailResponse> {
try {
const { token, course_id, lesson_id, student_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor
await this.validateCourseInstructor(token, course_id);
@ -988,6 +1219,17 @@ export class CoursesInstructorService {
};
} catch (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;
}
}
@ -1125,6 +1367,17 @@ export class CoursesInstructorService {
};
} catch (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;
}
}
@ -1181,6 +1434,241 @@ export class CoursesInstructorService {
};
} catch (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;
}
}

View file

@ -186,7 +186,20 @@ export class CoursesStudentService {
},
};
} catch (error) {
logger.error(error);
logger.error(`Error enrolling in course: ${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;
}
}
@ -261,6 +274,17 @@ export class CoursesStudentService {
};
} catch (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;
}
}
@ -316,6 +340,19 @@ export class CoursesStudentService {
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
const lessonIds = course.chapters.flatMap(ch => ch.lessons.map(l => l.id));
const lessonProgress = await prisma.lessonProgress.findMany({
@ -416,6 +453,17 @@ export class CoursesStudentService {
};
} catch (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;
}
}
@ -678,6 +726,17 @@ export class CoursesStudentService {
};
} catch (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;
}
}
@ -866,6 +925,17 @@ export class CoursesStudentService {
};
} catch (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;
}
}
@ -940,6 +1010,17 @@ export class CoursesStudentService {
};
} catch (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;
}
}
@ -1037,6 +1118,17 @@ export class CoursesStudentService {
};
} catch (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;
}
}
@ -1168,7 +1260,19 @@ export class CoursesStudentService {
},
};
} catch (error) {
logger.error(error);
logger.error(`Error completing lesson: ${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;
}
}
@ -1213,22 +1317,14 @@ 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
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;
@ -1332,7 +1428,20 @@ export class CoursesStudentService {
},
};
} catch (error) {
logger.error(error);
logger.error(`Error submitting quiz: ${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;
}
}
@ -1373,22 +1482,14 @@ 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
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
const attempts = await prisma.quizAttempt.findMany({
@ -1438,6 +1539,21 @@ export class CoursesStudentService {
};
} catch (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;
}
}

View file

@ -8,7 +8,8 @@ import {
ListApprovedCoursesResponse,
GetCourseByIdResponse,
ToggleRecommendedResponse,
RecommendedCourseData
RecommendedCourseData,
RecommendedCourseDetailData
} from '../types/RecommendedCourses.types';
import { auditService } from './audit.service';
import { AuditAction } from '@prisma/client';
@ -18,10 +19,24 @@ export class RecommendedCoursesService {
/**
* List all approved courses (for admin to manage recommendations)
*/
static async listApprovedCourses(): Promise<ListApprovedCoursesResponse> {
static async listApprovedCourses(
token: string,
filters?: { search?: string; categoryId?: number }
): Promise<ListApprovedCoursesResponse> {
try {
const { search, categoryId } = filters ?? {};
const courses = await prisma.course.findMany({
where: { status: 'APPROVED' },
where: {
status: 'APPROVED',
...(categoryId ? { category_id: categoryId } : {}),
...(search ? {
OR: [
{ title: { path: ['th'], string_contains: search } },
{ title: { path: ['en'], string_contains: search } }
]
} : {})
},
orderBy: [
{ is_recommended: 'desc' },
{ updated_at: 'desc' }
@ -40,9 +55,9 @@ export class RecommendedCoursesService {
}
}
},
chapters: {
include: {
lessons: true
_count: {
select: {
chapters: true
}
}
}
@ -81,8 +96,7 @@ export class RecommendedCoursesService {
is_primary: i.is_primary,
user: i.user
})),
chapters_count: course.chapters.length,
lessons_count: course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0)
chapters_count: course._count.chapters,
} as RecommendedCourseData;
}));
@ -94,6 +108,19 @@ export class RecommendedCoursesService {
};
} catch (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;
}
}
@ -101,7 +128,7 @@ export class RecommendedCoursesService {
/**
* Get course by ID (for admin to view details)
*/
static async getCourseById(courseId: number): Promise<GetCourseByIdResponse> {
static async getCourseById(token: string, courseId: number): Promise<GetCourseByIdResponse> {
try {
const course = await prisma.course.findUnique({
where: { id: courseId },
@ -145,7 +172,7 @@ export class RecommendedCoursesService {
}
}
const data: RecommendedCourseData = {
const data: RecommendedCourseDetailData = {
id: course.id,
title: course.title as { th: string; en: string },
slug: course.slug,
@ -168,8 +195,15 @@ export class RecommendedCoursesService {
is_primary: i.is_primary,
user: i.user
})),
chapters_count: course.chapters.length,
lessons_count: course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0)
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 }
}))
}))
};
return {
@ -179,6 +213,19 @@ export class RecommendedCoursesService {
};
} catch (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;
}
}
@ -229,6 +276,17 @@ export class RecommendedCoursesService {
};
} catch (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;
}
}

View file

@ -20,6 +20,8 @@ import {
} from '../types/announcements.types';
import { CoursesInstructorService } from './CoursesInstructor.service';
import { uploadFile, deleteFile, getPresignedUrl } from '../config/minio';
import { auditService } from './audit.service';
import { AuditAction } from '@prisma/client';
export class AnnouncementsService {
@ -37,9 +39,7 @@ export class AnnouncementsService {
where: { id: decoded.id },
include: { role: true },
});
if (!user) {
throw new UnauthorizedError('Invalid token');
}
if (!user) throw new UnauthorizedError('Invalid token');
// Admin can access all courses
const isAdmin = user.role.code === 'ADMIN';
@ -130,6 +130,16 @@ export class AnnouncementsService {
};
} catch (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;
}
}
@ -226,6 +236,16 @@ export class AnnouncementsService {
};
} catch (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;
}
}
@ -300,6 +320,16 @@ export class AnnouncementsService {
};
} catch (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;
}
}
@ -346,6 +376,16 @@ export class AnnouncementsService {
};
} catch (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;
}
}
@ -411,6 +451,16 @@ export class AnnouncementsService {
};
} catch (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;
}
}
@ -458,6 +508,16 @@ export class AnnouncementsService {
};
} catch (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;
}
}

View file

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

View file

@ -83,6 +83,7 @@ export class AuthService {
* User registration
*/
async register(data: RegisterRequest): Promise<RegisterResponse> {
try {
const { username, email, password, first_name, last_name, prefix, phone } = data;
// Check if username already exists
@ -162,9 +163,26 @@ export class AuthService {
user: this.formatUserResponseSync(user),
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> {
try {
const { username, email, password, first_name, last_name, prefix, phone } = data;
// Check if username already exists
@ -244,6 +262,22 @@ export class AuthService {
user: this.formatUserResponseSync(user),
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,6 +5,8 @@ import { logger } from '../config/logger';
import jwt from 'jsonwebtoken';
import { createCategory, createCategoryResponse, deleteCategoryResponse, updateCategory, updateCategoryResponse, ListCategoriesResponse, Category } from '../types/categories.type';
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
import { auditService } from './audit.service';
import { AuditAction } from '@prisma/client';
export class CategoryService {
async listCategories(): Promise<ListCategoriesResponse> {
@ -30,6 +32,13 @@ export class CategoryService {
const newCategory = await prisma.category.create({
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 {
code: 200,
message: 'Category created successfully',
@ -43,6 +52,16 @@ export class CategoryService {
};
} catch (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;
}
}
@ -54,6 +73,13 @@ export class CategoryService {
where: { id },
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 {
code: 200,
message: 'Category updated successfully',
@ -67,21 +93,49 @@ export class CategoryService {
};
} catch (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;
}
}
async deleteCategory(id: number): Promise<deleteCategoryResponse> {
async deleteCategory(token: string, id: number): Promise<deleteCategoryResponse> {
try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string };
const deletedCategory = await prisma.category.delete({
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 {
code: 200,
message: 'Category deleted successfully',
};
} catch (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;
}
}

View file

@ -16,6 +16,8 @@ import {
ListMyCertificatesInput,
ListMyCertificatesResponse,
} from '../types/certificate.types';
import { auditService } from './audit.service';
import { AuditAction } from '@prisma/client';
export class CertificateService {
private static TEMPLATE_PATH = path.join(__dirname, '../../assets/templates/Certificate.pdf');
@ -54,17 +56,11 @@ 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
const existingCertificate = await prisma.certificate.findFirst({
@ -121,6 +117,14 @@ 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);
return {
@ -135,6 +139,18 @@ export class CertificateService {
};
} catch (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;
}
}
@ -186,6 +202,18 @@ export class CertificateService {
};
} catch (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;
}
}
@ -239,6 +267,17 @@ export class CertificateService {
};
} catch (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;
}
}

View file

@ -5,6 +5,8 @@ import { logger } from '../config/logger';
import { listCourseResponse, getCourseResponse, ListCoursesInput } from '../types/courses.types';
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
import { getPresignedUrl } from '../config/minio';
import { auditService } from './audit.service';
import { AuditAction } from '@prisma/client';
export class CoursesService {
async ListCourses(input: ListCoursesInput): Promise<listCourseResponse> {
@ -82,6 +84,16 @@ export class CoursesService {
};
} catch (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;
}
}
@ -91,7 +103,56 @@ export class CoursesService {
const course = await prisma.course.findFirst({
where: {
id,
status: 'APPROVED' // Only show approved courses to students
status: 'APPROVED'
},
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 }
}
}
}
}
});
@ -112,16 +173,83 @@ export class CoursesService {
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 {
code: 200,
message: 'Course fetched successfully',
data: {
...course,
title: course.title as { th: string; en: string },
description: course.description as { th: string; en: string },
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) {
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;
}
}

View file

@ -14,7 +14,8 @@ import {
updateAvatarRequest,
updateAvatarResponse,
SendVerifyEmailResponse,
VerifyEmailResponse
VerifyEmailResponse,
rolesResponse
} from '../types/user.types';
import nodemailer from 'nodemailer';
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
@ -135,6 +136,17 @@ export class UserService {
throw new UnauthorizedError('Token expired');
}
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;
}
}
@ -186,6 +198,41 @@ export class UserService {
throw new UnauthorizedError('Token expired');
}
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;
}
}
@ -252,6 +299,18 @@ 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
const presignedUrl = await this.getAvatarPresignedUrl(filePath);
@ -273,6 +332,18 @@ export class UserService {
throw new UnauthorizedError('Token expired');
}
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;
}
}
@ -385,6 +456,17 @@ export class UserService {
if (error instanceof jwt.JsonWebTokenError) throw new UnauthorizedError('Invalid token');
if (error instanceof jwt.TokenExpiredError) throw new UnauthorizedError('Token expired');
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;
}
}
@ -415,6 +497,15 @@ export class UserService {
});
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 {
code: 200,
message: 'Email verified successfully'
@ -423,6 +514,17 @@ export class UserService {
if (error instanceof jwt.JsonWebTokenError) throw new UnauthorizedError('Invalid verification token');
if (error instanceof jwt.TokenExpiredError) throw new UnauthorizedError('Verification link has expired');
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;
}
}

View file

@ -39,6 +39,16 @@ export class UserManagementService {
};
} catch (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;
}
}
@ -61,6 +71,16 @@ export class UserManagementService {
};
} catch (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;
}
}
@ -95,6 +115,17 @@ export class UserManagementService {
};
} catch (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;
}
}
@ -114,6 +145,16 @@ export class UserManagementService {
};
} catch (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;
}
}
@ -160,6 +201,16 @@ export class UserManagementService {
throw new UnauthorizedError('Token expired');
}
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;
}
}
@ -207,6 +258,16 @@ export class UserManagementService {
throw new UnauthorizedError('Token expired');
}
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;
}
}

View file

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

View file

@ -23,6 +23,11 @@ export interface createCourseResponse {
data: Course;
}
export interface ListMyCoursesInput {
token: string;
status?: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'ARCHIVED';
}
export interface ListMyCourseResponse {
code: number;
message: string;
@ -428,3 +433,18 @@ export interface GetCourseApprovalHistoryResponse {
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,14 +1,10 @@
import { MultiLanguageText } from './index';
// ============================================
// Request Types
// ============================================
// ============================================
// Response Types
// ============================================
/** ใช้ใน listApprovedCourses — มีแค่ chapters_count */
export interface RecommendedCourseData {
id: number;
title: MultiLanguageText;
@ -41,7 +37,19 @@ export interface RecommendedCourseData {
};
}>;
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 {
@ -54,7 +62,7 @@ export interface ListApprovedCoursesResponse {
export interface GetCourseByIdResponse {
code: number;
message: string;
data: RecommendedCourseData;
data: RecommendedCourseDetailData;
}
export interface ToggleRecommendedResponse {

View file

@ -1,4 +1,5 @@
import { Course } from '@prisma/client';
import { MultiLanguageText } from './index';
export interface ListCoursesInput {
category_id?: number;
@ -18,8 +19,47 @@ export interface listCourseResponse {
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 {
code: number;
message: string;
data: Course | null;
data: CourseDetail | null;
}

View file

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

View file

@ -0,0 +1,30 @@
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

@ -0,0 +1,186 @@
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,3 +20,38 @@ export const CreateCourseValidator = Joi.object({
is_free: 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

@ -0,0 +1,38 @@
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

@ -0,0 +1,15 @@
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

@ -0,0 +1,72 @@
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

@ -0,0 +1,58 @@
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

@ -0,0 +1,160 @@
// 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: {
http_req_duration: ['p(95)<2000'], // 95% of requests < 2s
errors: ['rate<0.1'], // Error rate < 10%
login_duration: ['p(95)<2000'], // 95% of logins < 2s
login_duration: ['p(95)<2000'], // 95% pof logins < 2s
},
};

View file

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

View file

@ -27,7 +27,7 @@
/* Typography */
/* Typography */
--font-main:
"Prompt", "Sarabun", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
"Prompt", "Inter", "Sarabun", -apple-system, BlinkMacSystemFont, "Segoe UI",
"Roboto", "Helvetica Neue", Arial, sans-serif;
/* Layout */
@ -634,6 +634,7 @@ ul {
}
.font-bold {
font-weight: 700;
letter-spacing: normal;
}
.w-full {
width: 100%;
@ -658,12 +659,12 @@ ul {
.page-container {
max-width: 1280px; /* max-7xl equivalent roughly */
margin: 0 auto;
padding: 2rem 1.5rem;
padding: 1rem 1.5rem;
}
@media (min-width: 1024px) {
.page-container {
padding: 3rem 2rem;
padding: 1.5rem 2rem;
}
}

View file

@ -90,7 +90,7 @@ const getLocalizedText = (text: any) => {
</div>
<div v-else class="p-10 flex flex-col items-center justify-center text-slate-400">
<q-icon name="campaign" size="40px" class="mb-2 opacity-50" />
<p>{{ $t('classroom.noAnnouncements', 'ไม่มีประกาศในขณะนี้') }}</p>
<p>{{ $t('classroom.noAnnouncements') }}</p>
</div>
</q-card-section>
</q-card>

View file

@ -21,14 +21,76 @@ const emit = defineEmits<{
const { locale } = useI18n()
// State for expansion items
const chapterOpenState = ref<Record<string, boolean>>({})
// Helper for localization
const getLocalizedText = (text: any) => {
if (!text) return ''
if (typeof text === 'string') return text
const currentLocale = locale.value as 'th' | 'en'
// Safe locale access
const currentLocale = (locale?.value || 'th') as 'th' | '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
const progressPercentage = computed(() => {
if (!props.courseData || !props.courseData.chapters) return 0
let total = 0
let completed = 0
props.courseData.chapters.forEach((c: any) => {
c.lessons.forEach((l: any) => {
total++
if (isLessonCompleted(l)) completed++
})
})
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>
<template>
@ -37,54 +99,133 @@ const getLocalizedText = (text: any) => {
@update:model-value="(val) => emit('update:modelValue', val)"
show-if-above
bordered
side="left"
:width="320"
side="right"
:width="300"
:breakpoint="1024"
class="bg-[var(--bg-surface)]"
content-class="bg-[var(--bg-surface)]"
class="bg-slate-50 dark:bg-slate-900 shadow-xl"
>
<div v-if="courseData" class="h-full scroll">
<q-list class="pb-10">
<!-- Main Container: Enforce Column Layout and Full Width -->
<div v-if="courseData" class="flex flex-col w-full h-full overflow-hidden text-slate-900 dark:text-white relative">
<!-- 1. Header Section (Fixed at Top) -->
<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>
<template v-for="chapter in courseData.chapters" :key="chapter.id">
<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">
{{ getLocalizedText(chapter.title) }}
</q-item-label>
<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-sm font-black text-blue-600 dark:text-blue-400">{{ progressPercentage }}%</span>
</div>
<div class="h-2 w-full bg-slate-100 dark:bg-slate-800 rounded-full overflow-hidden">
<div
class="h-full bg-blue-600 dark:bg-blue-500 rounded-full transition-all duration-700 ease-out"
:style="{ width: `${progressPercentage}%` }"
></div>
</div>
</div>
<q-item
v-for="lesson in chapter.lessons"
:key="lesson.id"
clickable
v-ripple
:active="currentLessonId === lesson.id"
active-class="bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-200 font-medium"
class="border-b border-gray-50 dark:border-white/5"
@click="!lesson.is_locked && emit('select-lesson', lesson.id)"
:disable="lesson.is_locked"
<!-- 2. Curriculum List (Scrollable Area) -->
<div class="flex-1 overflow-y-auto bg-slate-50 dark:bg-[#0f1219] w-full p-4 space-y-3">
<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"
>
<q-item-section avatar v-if="lesson.is_locked">
<q-icon name="lock" size="xs" color="grey" />
</q-item-section>
<q-item-section>
<q-item-label class="text-sm md:text-base line-clamp-2 text-[var(--text-main)]">
{{ getLocalizedText(lesson.title) }}
</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon v-if="lesson.is_completed || lesson.progress?.is_completed" name="check_circle" color="positive" size="xs" />
<q-icon v-else-if="currentLessonId === lesson.id" name="play_circle" color="primary" size="xs" />
<q-icon v-else name="radio_button_unchecked" color="grey-4" size="xs" />
</q-item-section>
</q-item>
<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">
<div
v-for="(lesson, lIdx) in chapter.lessons"
: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"
:class="currentLessonId === lesson.id
? 'border-blue-600 bg-blue-50 dark:bg-blue-900/10'
: 'border-transparent'"
@click="!lesson.is_locked && emit('select-lesson', lesson.id)"
>
<!-- Lesson Status Icon -->
<div class="mr-3 flex-shrink-0">
<!-- Completed (Takes Precedence) -->
<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">
<div class="text-xs font-bold truncate leading-snug block w-full"
:class="currentLessonId === lesson.id ? 'text-blue-700 dark:text-blue-300' : 'text-slate-600 dark:text-slate-300'"
>
{{ getLocalizedText(lesson.title) }}
</div>
</div>
</div>
</div>
</q-expansion-item>
</div>
</q-list>
</div>
</div>
<div v-else-if="isLoading" class="p-6 text-center text-slate-500">
<q-spinner color="primary" size="2em" />
<div class="mt-2 text-xs">{{ $t('classroom.loadingCurriculum') }}</div>
</div>
</q-drawer>
</template>
<style scoped>
/* Custom scrollbar for better aesthetics */
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
.dark ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
}
</style>

View file

@ -6,6 +6,7 @@
const props = defineProps<{
src: string;
poster?: string;
initialSeekTime?: number;
}>();
@ -164,9 +165,21 @@ const togglePlay = () => {
return;
}
if (!videoRef.value) return;
if (isPlaying.value) videoRef.value.pause();
else videoRef.value.play();
isPlaying.value = !isPlaying.value;
if (isPlaying.value) {
videoRef.value.pause();
isPlaying.value = false;
} else {
const playPromise = videoRef.value.play();
if (playPromise !== undefined) {
playPromise.then(() => {
isPlaying.value = true;
}).catch(error => {
// Auto-play was prevented or play was interrupted
// We can safely ignore this error
console.log("Video play request handled:", error.name);
});
}
}
};
const handleTimeUpdate = () => {
@ -237,11 +250,12 @@ watch([volume, isMuted], () => {
></iframe>
<!-- 2. Standard HTML5 Video Player -->
<div v-else class="w-full h-full relative">
<div v-else class="w-full h-full relative group/video cursor-pointer">
<video
ref="videoRef"
:src="src"
class="w-full h-full object-contain"
:poster="poster"
class="w-full h-full object-contain bg-slate-900"
@click="togglePlay"
@timeupdate="handleTimeUpdate"
@loadedmetadata="handleLoadedMetadata"
@ -249,26 +263,30 @@ watch([volume, isMuted], () => {
/>
<!-- Custom Controls Overlay (Only for HTML5 Video) -->
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/80 via-black/40 to-transparent transition-opacity opacity-0 group-hover:opacity-100">
<div class="flex items-center gap-4 text-white">
<q-btn flat round dense :icon="isPlaying ? 'pause' : 'play_arrow'" @click.stop="togglePlay" />
<div class="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-black/90 via-black/40 to-transparent transition-opacity opacity-0 group-hover/video:opacity-100 flex flex-col gap-3">
<!-- Progress Bar -->
<div class="relative flex-grow h-1.5 bg-white/20 rounded-full cursor-pointer group/progress overflow-hidden" @click="seek">
<div class="absolute top-0 left-0 h-full bg-blue-500 rounded-full group-hover/progress:bg-blue-400 transition-all shadow-[0_0_10px_rgba(59,130,246,0.5)]" :style="{ width: videoProgress + '%' }"></div>
<div class="absolute top-0 left-0 h-full bg-blue-500 rounded-full group-hover/progress:bg-blue-400 transition-all shadow-[0_0_12px_rgba(59,130,246,0.6)]" :style="{ width: videoProgress + '%' }"></div>
</div>
<span class="text-xs font-mono font-medium opacity-90">{{ currentTimeDisplay }} / {{ durationDisplay }}</span>
<div class="flex items-center gap-4 text-white">
<q-btn flat round dense :icon="isPlaying ? 'pause' : 'play_arrow'" @click.stop="togglePlay" class="hover:scale-110 active:scale-95 transition-transform" />
<span class="text-xs font-mono font-bold opacity-80">{{ currentTimeDisplay }} / {{ durationDisplay }}</span>
<div class="flex-grow"></div>
<!-- Volume Control -->
<div class="flex items-center gap-2 group/volume">
<q-btn flat round dense :icon="volumeIcon" @click.stop="handleToggleMute" color="white" />
<div class="w-0 group-hover/volume:w-20 overflow-hidden transition-all duration-300 flex items-center">
<div class="flex items-center gap-2 group/volume relative">
<q-btn flat round dense :icon="volumeIcon" @click.stop="handleToggleMute" color="white" class="hover:scale-110 transition-transform" />
<div class="w-0 group-hover/volume:w-24 overflow-hidden transition-all duration-300 flex items-center bg-black/60 backdrop-blur-md rounded-full px-2">
<input
type="range"
min="0"
max="1"
step="0.1"
step="0.05"
:value="volume"
@input="handleVolumeChange"
class="w-20 h-1 bg-white/30 rounded-lg appearance-none cursor-pointer accent-blue-500"
class="w-20 h-1 bg-white/20 rounded-lg appearance-none cursor-pointer accent-blue-500 mx-2"
/>
</div>
</div>

View file

@ -25,6 +25,8 @@ interface CourseCardProps {
showContinue?: boolean
showCertificate?: boolean
showStudyAgain?: boolean
hideProgress?: boolean
hideActions?: boolean
}
const props = withDefaults(defineProps<CourseCardProps>(), {
@ -55,7 +57,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</script>
<template>
<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">
<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">
<!-- Thumbnail Section -->
<div class="relative w-full aspect-video overflow-hidden">
@ -106,7 +108,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))
<div class="mt-auto pt-4">
<!-- Progress Bar -->
<div v-if="progress !== undefined && !completed" class="mb-4">
<div v-if="progress !== undefined && !completed && !hideProgress" class="mb-4">
<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-blue-600 dark:text-blue-400">{{ progress }}%</span>
@ -117,12 +119,13 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</div>
<!-- Action Buttons -->
<div v-if="!hideActions" class="flex flex-col gap-3">
<!-- View Details (Secondary Action) -->
<q-btn
v-if="showViewDetails && !completed && !progress"
flat
rounded
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"
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"
:label="$t('menu.viewDetails')"
:to="`/course/${id}`"
/>
@ -136,6 +139,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))
:label="(!progress || progress === 0) ? $t('course.startLearning') : $t('course.continueLearning')"
:to="`/classroom/learning?course_id=${id}`"
/>
</div>
<div v-if="completed" class="space-y-2">
<!-- Study Again -->

View file

@ -50,7 +50,7 @@ const toggleCategory = (id: number) => {
</div>
<div class="flex-1">
<div class="text-lg font-black text-slate-900 dark:!text-white leading-none mb-1">{{ $t('discovery.categoryTitle') }}</div>
<div class="text-[10px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500">{{ modelValue.length }} Selectable</div>
<div class="text-[10px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500">{{ modelValue.length }} {{ $t('discovery.selectable') }}</div>
</div>
</div>
</template>

View file

@ -33,6 +33,19 @@ const formatPrice = (price: number) => {
}
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 = () => {
if(!props.course) return;
@ -42,26 +55,28 @@ const handleEnroll = () => {
// In this pattern, we just emit.
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>
<template>
<div class="animate-fade-in-up">
<q-btn
unelevated
rounded
icon="arrow_back"
:label="$t('common.back')"
class="mb-8 font-bold bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-white/10 dark:text-white dark:hover:bg-white/20 transition-all px-4 shadow-sm"
@click="emit('back')"
/>
<div class="flex items-center gap-2 mb-8 group cursor-pointer" @click="emit('back')">
<q-icon name="arrow_back" size="20px" class="text-slate-400 group-hover:text-blue-600 transition-colors" />
<span class="text-sm font-bold text-slate-500 group-hover:text-blue-600 transition-colors uppercase tracking-widest">{{ $t('common.back') }}</span>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Left: Content Detail -->
<div class="lg:col-span-2 space-y-8">
<!-- Video Preview Section -->
<div class="relative aspect-video rounded-3xl overflow-hidden shadow-2xl group bg-black">
<div class="relative aspect-video rounded-3xl overflow-hidden shadow-2xl group cursor-pointer bg-slate-900 border-4 border-white dark:border-slate-800 transition-transform duration-500 hover:scale-[1.01]">
<template v-if="course.media?.video_url">
<video
controls
@ -69,27 +84,28 @@ const handleEnroll = () => {
:poster="course.thumbnail_url || course.cover_image || 'https://placehold.co/800x450?text=Course+Preview'"
>
<source :src="course.media.video_url" type="video/mp4">
Your browser does not support the video tag.
{{ $t('course.videoNotSupported') }}
</video>
<!-- Custom Play Overlay when not playing - simple version is often best -->
</template>
<!-- Beautiful Image Showcase if no video -->
<template v-else>
<div class="w-full h-full flex items-center justify-center relative overflow-hidden bg-slate-950">
<!-- Show Thumbnail as Background if exists (Blurred background fill) -->
<div class="w-full h-full flex items-center justify-center relative overflow-hidden bg-slate-950 group">
<!-- Blurred background fill -->
<img
v-if="course.thumbnail_url || course.cover_image"
:src="course.thumbnail_url || course.cover_image"
class="absolute inset-0 w-full h-full object-cover opacity-30 blur-xl scale-110"
class="absolute inset-0 w-full h-full object-cover opacity-40 blur-2xl scale-125"
/>
<!-- Main Sharp Image -->
<img
v-if="course.thumbnail_url || course.cover_image"
:src="course.thumbnail_url || course.cover_image"
class="relative z-10 w-full h-full object-contain shadow-2xl shadow-black/50"
class="relative z-10 w-full h-full object-cover shadow-2xl transition-transform duration-700 group-hover:scale-105"
/>
<div v-else class="absolute inset-0 bg-gradient-to-br from-slate-800 to-slate-900 flex items-center justify-center">
<div v-if="!course.thumbnail_url && !course.cover_image" class="absolute inset-0 bg-gradient-to-br from-slate-800 to-slate-900 flex items-center justify-center">
<q-icon name="image" size="80px" class="text-slate-700 opacity-50" />
</div>
</div>
@ -101,43 +117,68 @@ const handleEnroll = () => {
<h1 class="text-3xl md:text-4xl font-extrabold text-slate-900 dark:text-white mb-4 leading-tight">
{{ getLocalizedText(course.title) }}
</h1>
<div class="flex flex-wrap items-center gap-4 text-sm text-slate-500 dark:text-slate-400 mb-6">
<span class="flex items-center gap-1 bg-slate-100 dark:bg-slate-800 px-3 py-1 rounded-full text-slate-700 dark:text-slate-300 font-medium">
{{ getLocalizedText(course.category?.name) }}
</span>
</div>
<div class="prose max-w-none text-slate-600 dark:text-slate-400 leading-relaxed font-light">
<p>{{ getLocalizedText(course.description) }}</p>
</div>
</div>
<!-- Curriculum Preview -->
<div class="bg-slate-100/50 dark:bg-slate-800/50 rounded-3xl p-6 md:p-8 border border-transparent dark:border-slate-700/50">
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-6 flex items-center gap-2">
<q-icon name="list_alt" class="text-blue-600 dark:text-blue-400" />
<!-- Course Detail - Single Page Layout -->
<div class="space-y-10">
<!-- 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 class="text-sm text-slate-500 mb-1 font-bold uppercase tracking-wider">{{ $t('course.instructor') }}</div>
<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') }}
</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>
<div class="space-y-4">
<div v-for="(chapter, idx) in course.chapters" :key="chapter.id" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
<div class="px-6 py-4 bg-slate-100 dark:bg-white/5 font-bold text-slate-800 dark:text-white flex justify-between items-center">
<span>{{ Number(idx) + 1 }}. {{ getLocalizedText(chapter.title) }}</span>
<span class="text-xs text-slate-500 dark:text-slate-400 font-normal">{{ chapter.lessons?.length || 0 }} {{ $t('course.lessonsUnit') }}</span>
<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">
<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>
{{ getLocalizedText(chapter.title) }}
</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 class="divide-y divide-slate-100 dark:divide-slate-800">
<div v-for="lesson in chapter.lessons" :key="lesson.id" class="px-6 py-3 flex items-center gap-3 text-sm text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-white/5 transition-colors">
<!-- Lessons List -->
<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 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'">
<q-icon
:name="lesson.type === 'VIDEO' ? 'play_circle' : 'article'"
:class="lesson.type === 'VIDEO' ? 'text-blue-500 dark:text-blue-400' : 'text-orange-500 dark:text-orange-400'"
size="18px"
:name="lesson.type === 'VIDEO' ? 'play_arrow' : 'article'"
size="16px"
/>
<span class="flex-1">{{ getLocalizedText(lesson.title) }}</span>
<span v-if="lesson.duration_minutes" class="text-slate-400 dark:text-slate-500 text-xs">{{ lesson.duration_minutes }} {{ $t('course.minutes') }}</span>
<q-icon v-if="lesson.is_locked !== false" name="lock" size="14px" class="text-slate-300 dark:text-slate-600" />
</div>
<span class="flex-1 font-bold truncate">{{ 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>
<q-icon v-if="lesson.is_locked !== false" name="lock" size="14px" class="text-slate-300 dark:text-slate-600 shrink-0" />
</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">
<q-icon name="menu_book" size="40px" class="mb-2 opacity-50" />
<p class="text-sm font-medium">{{ $t('course.noContent') }}</p>
@ -147,20 +188,23 @@ const handleEnroll = () => {
</div>
</div>
<!-- Right: Enrollment Card -->
<div class="lg:col-span-1">
<div class="sticky top-24">
<div class="bg-white dark:bg-slate-800/40 dark:backdrop-blur-xl rounded-3xl shadow-xl shadow-slate-200/50 dark:shadow-none p-6 border border-slate-100 dark:border-white/10 relative overflow-hidden">
<div class="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-blue-500/20 to-purple-500/20 rounded-full blur-3xl -mr-16 -mt-16"></div>
<div class="bg-white dark:bg-slate-900 rounded-3xl shadow-2xl shadow-blue-500/10 dark:shadow-none p-8 border border-slate-100 dark:border-white/5 relative overflow-hidden group">
<!-- Decorative background glow -->
<div class="absolute -top-12 -right-12 w-48 h-48 bg-blue-500/10 rounded-full blur-3xl group-hover:bg-blue-500/20 transition-colors"></div>
<div class="relative">
<div class="text-3xl font-black mb-2 font-display">
<span v-if="course.price > 0" class="text-slate-900 dark:text-white">
<div v-if="course.price > 0" class="mb-4">
<span class="text-xs font-black uppercase tracking-widest text-slate-400 mb-1 block">{{ $t('course.price') }}</span>
<div class="text-4xl font-black font-display">
<span class="text-slate-900 dark:text-white">
{{ formatPrice(course.price) }}
</span>
<span v-else class="text-blue-600 dark:text-blue-400 drop-shadow-sm">
{{ $t('course.free') }}
</span>
</div>
</div>
<q-btn
@ -168,19 +212,38 @@ const handleEnroll = () => {
rounded
size="lg"
color="primary"
class="w-full mb-4 shadow-lg shadow-blue-600/30 font-bold"
class="w-full h-14 shadow-xl shadow-blue-600/30 font-black tracking-tight"
:label="user ? (course.enrolled ? $t('course.startLearning') : (course.price > 0 ? $t('course.buyNow') : $t('course.enrollFree'))) : $t('course.loginToEnroll')"
:loading="enrollmentLoading"
@click="handleEnroll"
/>
>
<template v-slot:loading>
<q-spinner-facebook />
</template>
</q-btn>
<div class="mt-6 space-y-4">
<p class="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 mb-4">{{ $t('course.includes') }}</p>
<hr class="my-6 border-slate-100 dark:border-slate-800">
<div class="flex items-center gap-3 text-sm text-slate-600 dark:text-slate-300 font-bold">
<div class="w-6 h-6 rounded-lg bg-blue-50 dark:bg-blue-500/10 flex items-center justify-center">
<q-icon name="all_inclusive" size="14px" class="text-blue-600 dark:text-blue-400" />
</div>
{{ $t('course.fullLifetimeAccess') }}
</div>
<div class="space-y-3 block">
<div class="flex items-center gap-3 text-sm text-slate-600 dark:text-slate-400">
<q-icon name="check_circle" class="text-green-500" />
{{ $t('course.certificate') }} ({{ $t('course.available') }})
<div class="flex items-center gap-3 text-sm text-slate-600 dark:text-slate-300 font-bold">
<div class="w-6 h-6 rounded-lg bg-emerald-50 dark:bg-emerald-500/10 flex items-center justify-center">
<q-icon name="workspace_premium" size="14px" class="text-emerald-600 dark:text-emerald-400" />
</div>
{{ $t('course.certificate') }}
</div>
<div class="flex items-center gap-3 text-sm text-slate-600 dark:text-slate-300 font-bold">
<div class="w-6 h-6 rounded-lg bg-purple-50 dark:bg-purple-500/10 flex items-center justify-center">
<q-icon name="devices" size="14px" class="text-purple-600 dark:text-purple-400" />
</div>
{{ $t('course.accessOnMobile') }}
</div>
</div>
</div>

View file

@ -5,80 +5,164 @@
* Uses Quasar QToolbar.
*/
defineProps<{
/** Controls visibility of the search bar */
showSearch?: boolean
}>()
import { ref, computed } from "vue";
const props = defineProps<{
/** Controls visibility of the sidebar toggle button */
showSidebarToggle?: boolean;
/** Type of navigation links to display */
navType?: "public" | "learner";
}>();
const emit = defineEmits<{
/** 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>
<template>
<q-toolbar class="bg-transparent text-slate-800 dark:text-white h-16 px-4">
<!-- Menu Toggle (Always Visible) -->
<q-toolbar class="bg-white dark:!bg-[#0f172a] text-slate-900 dark:!text-white h-16 border-none p-0 overflow-visible">
<div class="w-full px-4 md:px-12 flex items-center h-full no-wrap relative">
<!-- Mobile Sidebar Toggle (For non-learner routes) -->
<q-btn
v-if="showSidebarToggle !== false && navTypeComputed !== 'learner' && $q.screen.lt.md"
flat
round
dense
icon="menu"
class="mr-2 text-gray-500"
@click="$emit('toggleSidebar')"
/>
<!-- Branding: Logo + Name -->
<div
class="flex items-center gap-3 cursor-pointer group flex-shrink-0"
@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
</div>
<div class="flex flex-col text-left">
<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="text-[9px] md:text-[10px] font-bold uppercase tracking-[0.2em] leading-none mt-1 text-slate-500">Platform</span>
</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 />
<!-- Right Section: Tools -->
<div class="flex items-center gap-2 flex-shrink-0 no-wrap">
<!-- 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
icon="menu"
@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"
class="header-mobile text-slate-700 dark:text-white bg-slate-100 dark:bg-slate-800 flex-shrink-0"
style="width: 40px; height: 40px; min-width: 40px;"
@click="$emit('toggleRightDrawer')"
/>
<!-- Branding -->
<div class="flex items-center gap-3 cursor-pointer group" @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">
E
</div>
<div class="flex flex-col">
<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-[10px] font-bold uppercase tracking-[0.2em] leading-none mt-1 text-slate-500 dark:text-slate-400">Platform</span>
</div>
</div>
<q-space />
<!-- Center Search (Optional) -->
<div v-if="showSearch !== false" class="hidden md:block w-1/3 max-w-md mx-4">
<q-input
dense
outlined
rounded
v-model="searchText"
: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>
<q-space />
<!-- Right Actions -->
<div class="flex items-center gap-2">
<!-- Language Switcher -->
<LanguageSwitcher />
<!-- User Profile Dropdown -->
<UserMenu />
</div>
</q-toolbar>
</template>
<style scoped>
.search-input :deep(.q-field__control) {
border-radius: 9999px; /* Full rounded */
/* High Priority Visibility Logic */
@media (max-width: 1023px) {
.header-desktop {
display: none !important;
}
.search-input :deep(.q-field__control:before) {
border-color: #e2e8f0; /* slate-200 */
.header-mobile {
display: flex !important;
}
}
@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>

View file

@ -5,28 +5,10 @@
* Uses Quasar QList for structure.
*/
const { sidebarItems } = useNavItems()
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) => {
if (import.meta.client) {
@ -55,7 +37,7 @@ const handleNavigate = (path: string) => {
</q-item-section>
<q-item-section>
<q-item-label class="font-bold text-sm">{{ item.label }}</q-item-label>
<q-item-label class="font-bold text-sm">{{ $t(item.labelKey) }}</q-item-label>
</q-item-section>
</q-item>
</q-list>

View file

@ -0,0 +1,98 @@
<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,17 +3,35 @@
* @file LandingHeader.vue
* @description The main header for the public landing pages.
* 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
const isScrolled = ref(false)
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(() => {
// Add scroll listener to toggle 'isScrolled' class
window.addEventListener('scroll', () => {
isScrolled.value = window.scrollY > 20
window.addEventListener('scroll', handleScroll)
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
document.body.style.overflow = '' // Cleanup
})
</script>
@ -23,17 +41,13 @@ onMounted(() => {
- Transitions between transparent and glass effect based on scroll.
-->
<header
class="landing-header transition-all duration-300"
class="fixed top-0 left-0 right-0 z-[100] transition-all duration-300"
:class="[isScrolled ? 'h-16 glass-nav shadow-lg' : 'h-24 bg-transparent']"
>
<div class="container h-full flex items-center justify-between">
<!--
Left Section: Logo & Desktop Navigation
-->
<div class="flex items-center gap-12">
<!-- Logo -->
<div class="container mx-auto px-6 md:px-12 h-full flex items-center justify-start">
<!-- Left Section: Logo -->
<NuxtLink to="/" class="flex items-center gap-3 group">
<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">
<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">
E
</div>
<div class="flex flex-col">
@ -52,80 +66,162 @@ onMounted(() => {
</div>
</NuxtLink>
<!-- Desktop Links -->
<nav class="hidden md:block">
<ul class="flex items-center gap-8 text-sm font-bold">
<li>
<!-- Desktop Navigation (Visible by default, hidden on mobile via CSS 'desktop-nav') -->
<nav class="flex desktop-nav items-center gap-8 text-sm font-bold ml-12">
<NuxtLink
to="/browse"
class="transition-colors relative group"
:class="[isScrolled ? 'text-slate-400 hover:text-white' : 'text-slate-600 hover:text-blue-600']"
class="transition-colors relative group py-2"
:class="[isScrolled ? 'text-slate-300 hover:text-white' : 'text-slate-600 hover:text-blue-600']"
>
{{ $t('landing.allCourses') }}
<span class="absolute -bottom-1 left-0 w-0 h-0.5 bg-blue-600 transition-all group-hover:w-full"/>
{{ $t('sidebar.onlineCourses') }}
<span class="absolute bottom-0 left-0 w-0 h-0.5 bg-blue-600 transition-all group-hover:w-full"/>
</NuxtLink>
</li>
<li>
<NuxtLink
to="/browse/discovery"
class="transition-colors relative group"
:class="[isScrolled ? 'text-slate-400 hover:text-white' : 'text-slate-600 hover:text-blue-600']"
to="/browse/recommended"
class="transition-colors relative group py-2"
:class="[isScrolled ? 'text-slate-300 hover:text-white' : 'text-slate-600 hover:text-blue-600']"
>
{{ $t('landing.discovery') }}
<span class="absolute -bottom-1 left-0 w-0 h-0.5 bg-blue-600 transition-all group-hover:w-full"/>
{{ $t('sidebar.recommendedCourses') }}
<span class="absolute bottom-0 left-0 w-0 h-0.5 bg-blue-600 transition-all group-hover:w-full"/>
</NuxtLink>
</li>
</ul>
</nav>
</div>
<!--
Right Section: Action Buttons (Login/Register or Dashboard)
-->
<div class="flex items-center gap-4">
<!-- Desktop Action Buttons (Visible by default, hidden on mobile via CSS 'desktop-nav') -->
<div class="flex desktop-nav items-center gap-4 ml-auto">
<template v-if="!isAuthenticated">
<!-- Login Button -->
<NuxtLink
to="/auth/login"
class="btn-secondary-premium shadow-sm"
: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']"
class="px-6 py-2.5 rounded-xl font-bold text-sm border-2 transition-all hover:-translate-y-0.5"
: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']"
>
{{ $t('auth.login') }}
</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') }}
</NuxtLink>
</template>
<template v-else>
<NuxtLink to="/dashboard" class="btn-primary-premium shadow-lg shadow-blue-600/20">
<NuxtLink
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') }}
</NuxtLink>
</template>
</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>
</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>
<style scoped>
/* Header content */
.landing-header {
width: 100%;
z-index: 100;
transition: all 0.3s ease;
}
/* Glassmorphism Effect for Scrolled Header */
.glass-nav {
background: rgba(15, 23, 42, 0.8);
backdrop-filter: blur(12px);
background: rgba(15, 23, 42, 0.95); /* Darker background for legibility */
backdrop-filter: blur(16px);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.container {
max-width: 1440px;
margin: 0 auto;
padding: 0 24px;
}
/* Premium Primary Button Styling */
.btn-primary-premium {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
@ -167,4 +263,21 @@ onMounted(() => {
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>

View file

@ -1,11 +1,6 @@
<script setup lang="ts">
const { t } = useI18n()
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 { mobileItems } = useNavItems()
const navItems = mobileItems
const handleNavigate = (path: string) => {
if (import.meta.client) {
@ -27,7 +22,7 @@ const handleNavigate = (path: string) => {
:key="item.to"
@click="handleNavigate(item.to)"
:icon="item.icon"
:label="item.label"
:label="$t(item.labelKey)"
no-caps
class="py-2"
:class="{ 'q-tab--active text-primary': $route.path === item.to }"

View file

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

View file

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

View file

@ -29,12 +29,9 @@ const userInitials = computed(() => {
return f + l
})
const menuItems = computed(() => [
{ 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 { userMenuItems } = useNavItems()
const menuItems = userMenuItems
const handleLogout = async () => {
await logout()
@ -63,14 +60,14 @@ const handleLogout = async () => {
<q-list class="py-2">
<q-item
v-for="item in menuItems"
:key="item.label"
:key="item.labelKey"
clickable
v-close-popup
@click="navigateTo(item.to)"
class="hover:bg-slate-100 dark:hover:bg-white/5 transition-colors"
>
<q-item-section>
<q-item-label class="font-bold text-sm text-slate-800 dark:text-slate-100">{{ item.label }}</q-item-label>
<q-item-label class="font-bold text-sm text-slate-800 dark:text-slate-100">{{ $t(item.labelKey) }}</q-item-label>
</q-item-section>
</q-item>

View file

@ -1,45 +1,4 @@
// 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
}
import type { User, LoginResponse, RegisterPayload } from '@/types/auth'
// ==========================================
// Composable: useAuth

View file

@ -1,124 +1,14 @@
// Interface สำหรับข้อมูลคอร์สเรียน (Public Course Data)
export interface Course {
id: number
title: string | { th: string; en: string } // รองรับ 2 ภาษา
slug: string
description: string | { th: string; en: string }
thumbnail_url: string
price: string
is_free: boolean
original_price?: string
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
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
}
import type {
Course,
CourseResponse,
SingleCourseResponse,
EnrolledCourse,
EnrolledCourseResponse,
QuizAnswerSubmission,
QuizSubmitRequest,
QuizResult,
Certificate
} from '@/types/course'
// ==========================================
// Composable: useCourse
@ -144,6 +34,7 @@ export const useCourse = () => {
category_id?: number;
page?: number;
limit?: number;
search?: string;
random?: boolean;
is_recommended?: boolean;
forceRefresh?: boolean
@ -166,6 +57,7 @@ export const useCourse = () => {
if (apiParams.category_id) queryParams.append('category_id', apiParams.category_id.toString())
if (apiParams.page) queryParams.append('page', apiParams.page.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.is_recommended !== undefined) queryParams.append('is_recommended', apiParams.is_recommended.toString())

View file

@ -0,0 +1,76 @@
/**
* @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

@ -0,0 +1,55 @@
/**
* @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);
color: var(--text-main);
padding: 24px;
font-family: 'Inter', 'Prompt', 'Sarabun', sans-serif;
font-family: var(--font-main);
}
.error-content {

View file

@ -5,7 +5,26 @@
},
"dashboard": {
"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": {
"continueLearning": "Continue Learning",
@ -44,16 +63,32 @@
"unlimitedQuizzes": "Unlimited quizzes",
"satisfactionGuarantee": "Satisfaction guarantee, 7-day refund",
"noContent": "No content available yet",
"buyNow": "Buy this course",
"enrollFree": "Enroll for free",
"loginToEnroll": "Log in to enroll",
"minutes": "Minutes",
"noVideoPreview": "Video preview not available"
"noVideoPreview": "Video preview not available",
"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": {
"overview": "Home",
"myCourses": "My Courses",
"browseCourses": "Browse Courses",
"onlineCourses": "All Courses",
"recommendedCourses": "Recommended Courses",
"announcements": "Announcements",
"profile": "My Profile"
},
@ -69,9 +104,17 @@
"emptyDesc": "Try different keywords or check spelling",
"showAll": "Show All",
"loadMore": "Load More",
"backToCatalog": "Back to Catalog"
"backToCatalog": "Back to Catalog",
"selectable": "Selected",
"foundTotal": "Found Total",
"items": "items",
"subtitle": "Choose to learn new skills from our curated quality courses",
"searchBtn": "Search"
},
"myCourses": {
"title": "My Courses",
"subtitle": "Track your progress and continue learning from where you left off",
"searchPlaceholder": "Search my courses...",
"filterAll": "All",
"filterProgress": "In Progress",
"filterCompleted": "Completed",
@ -84,7 +127,8 @@
"successDesc": "You have successfully enrolled in this course.",
"startNow": "Start Learning",
"later": "Later",
"alreadyEnrolledHint": "You have already enrolled in the course {course}."
"alreadyEnrolledHint": "You have already enrolled in the course {course}.",
"error": "Failed to enroll"
},
"certificate": {
"title": "Certificate of Completion",
@ -101,6 +145,8 @@
"email": "Email",
"phone": "Phone",
"joinedAt": "Joined",
"generalInfo": "General Information",
"accountDetails": "Account Details",
"editPersonalDesc": "Edit Personal Information",
"yourAvatar": "Your Profile Photo",
"avatarHint": "PNG, JPG only",
@ -195,16 +241,29 @@
"attachments": "Attachments",
"announcements": "Course Announcements",
"posts": "Posts",
"noAnnouncements": "No announcements yet"
"noAnnouncements": "No announcements yet",
"quizRequired": "Please pass the quiz \"{title}\" first",
"lessonRequired": "Please complete the lesson \"{title}\" first",
"notEnrolled": "You are not yet enrolled in this course",
"curriculum": "Course Content"
},
"quiz": {
"exitTitle": "Exit Quiz",
"exitConfirm": "You are taking a quiz. If you leave now, your progress will be lost. Are you sure you want to exit?",
"exitConfirm": "Do you want to exit the quiz? Your quiz attempt will be cancelled.",
"startTitle": "End of Chapter Quiz",
"preparationTitle": "Preparation before starting",
"instructionTitle": "Instructions",
"instruction1": "Pay attention to the questions to measure your learning progress.",
"startBtn": "Start Quiz",
"warningTitle": "Warning",
"singleAttemptWarning": "This quiz can only be taken once. If you fail, you will not be able to try again. Do you want to continue?",
"continue": "Continue",
"alreadyPassed": "You have already passed",
"latestScore": "Latest Score",
"retryMaybe": "Try Again",
"passedStatus": "Passed",
"failedStatus": "Failed",
"passingScore": "Passing Score",
"nextBtn": "Next Question",
"prevBtn": "Previous Question",
"submitBtn": "Submit Answers",
@ -235,6 +294,18 @@
"statusCompleted": "Completed",
"statusSkipped": "Skipped",
"statusNotStarted": "Not Started",
"alertIncomplete": "Please answer all questions"
"alertIncomplete": "Please answer all questions",
"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,7 +5,26 @@
},
"dashboard": {
"welcomeTitle": "ยินดีต้อนรับกลับ",
"welcomeSubtitle": "วันนี้เป็นวันที่ดีสำหรับการเรียนรู้สิ่งใหม่ๆ มาเก็บความรู้เพิ่มกันเถอะ"
"welcomeSubtitle": "วันนี้เป็นวันที่ดีสำหรับการเรียนรู้สิ่งใหม่ๆ มาเก็บความรู้เพิ่มกันเถอะ",
"heroTitle": "อัปสกิลของคุณต่อเนื่อง",
"heroSubtitle": "เพื่อเป้าหมายที่วางไว้",
"heroDesc": "วันนี้คุณเรียนไปกี่นาทีแล้ว? มาสร้างนิสัยการเรียนรู้ที่ยอดเยี่ยมกันเถอะ เรามีคอร์สแนะนำใหม่ๆ มากมายรอคุณอยู่",
"goToMyCourses": "ไปที่คอร์สเรียนของฉัน",
"searchNewCourses": "ค้นหาคอร์สใหม่",
"continueLearningTitle": "เรียนต่อกับคอร์สของคุณ",
"myCourses": "คอร์สเรียนของฉัน",
"studyAgain": "เรียนอีกครั้ง",
"continue": "เรียนต่อ",
"startNewCourse": "เริ่มเรียนคอร์สใหม่ๆ เพื่อเติมเต็มส่วนนี้",
"knowledgeLibrary": "คลังความรู้",
"libraryDesc": "คุณสามารถเลือกเรียนคอร์สเรียนที่คุณเป็นเจ้าของ",
"chooseLibrary": "เลือกเรียนคอร์สในคลังความรู้ของคุณ",
"viewAll": "ดูทั้งหมด",
"emptyLibraryTitle": "ยังไม่มีคอร์สเรียนในคลัง",
"emptyLibraryDesc": "เริ่มเรียนรู้สิ่งใหม่ๆ วันนี้ เลือกดูคอร์สเรียนที่น่าสนใจเพื่อพัฒนาทักษะของคุณ",
"viewAllCourses": "ดูคอร์สเรียนทั้งหมด",
"recommendedCourses": "คอร์สแนะนำ",
"noRecommended": "ไม่พบข้อมูลคอร์สแนะนำ"
},
"menu": {
"continueLearning": "เรียนต่อจากเดิม",
@ -44,16 +63,32 @@
"unlimitedQuizzes": "ทำแบบทดสอบไม่จำกัด",
"satisfactionGuarantee": "รับประกันความพึงพอใจ คืนเงินภายใน 7 วัน",
"noContent": "ยังไม่มีเนื้อหาในขณะนี้",
"buyNow": "ซื้อคอร์สเรียนนี้",
"enrollFree": "ลงทะเบียนเรียนฟรี",
"loginToEnroll": "เข้าสู่ระบบเพื่อลงทะเบียน",
"minutes": "นาที",
"noVideoPreview": "วิดีโอตัวอย่างยังไม่พร้อมใช้งาน"
"noVideoPreview": "วิดีโอตัวอย่างยังไม่พร้อมใช้งาน",
"videoNotSupported": "เบราว์เซอร์ของคุณไม่รองรับการเล่นวิดีโอ",
"aboutCourse": "เกี่ยวกับคอร์ส",
"lessonDetails": "รายละเอียดบทเรียน",
"courseStats": {
"level": "ระดับ",
"duration": "ระยะเวลา",
"lessons": "บทเรียน",
"students": "ผู้เรียน"
},
"certificatePreview": "ตัวอย่างใบประกาศนียบัตร",
"certificateDesc": "เมื่อเรียนจบและสอบผ่านตามเกณฑ์ที่กำหนด",
"includes": "สิ่งที่รวมอยู่ในคอร์ส",
"fullLifetimeAccess": "เข้าเรียนได้ตลอดชีพ",
"accessOnMobile": "เรียนได้บนมือถือและแท็บเล็ต",
"buyNow": "ซื้อคอร์สนี้"
},
"sidebar": {
"overview": "หน้าหลัก",
"myCourses": "คอร์สของฉัน",
"browseCourses": "ค้นหาคอร์ส",
"onlineCourses": "คอร์สเรียนทั้งหมด",
"recommendedCourses": "คอร์สเรียนแนะนำ",
"announcements": "ข่าวประกาศ",
"profile": "บัญชีผู้ใช้"
},
@ -69,9 +104,17 @@
"emptyDesc": "ลองใช้คำค้นหาอื่น หรือตรวจดูความถูกต้องของตัวอักษรอีกครั้ง",
"showAll": "แสดงทั้งหมด",
"loadMore": "โหลดเพิ่มเติม",
"backToCatalog": "กลับหน้ารายการคอร์ส"
"backToCatalog": "กลับหน้ารายการคอร์ส",
"selectable": "รายการที่เลือก",
"foundTotal": "พบทั้งหมด",
"items": "รายการ",
"subtitle": "เลือกเรียนรู้ทักษะใหม่ๆ จากหลักสูตรคุณภาพที่คัดสรรมาเพื่อคุณ",
"searchBtn": "ค้นหา"
},
"myCourses": {
"title": "คอร์สของฉัน",
"subtitle": "ติดตามความคืบหน้าและเรียนรู้ต่อจากจุดที่ค้างไว้",
"searchPlaceholder": "ค้นหาชื่อคอร์สของฉัน...",
"filterAll": "ทั้งหมด",
"filterProgress": "กำลังเรียน",
"filterCompleted": "เรียนจบแล้ว",
@ -84,7 +127,8 @@
"successDesc": "คุณได้ลงทะเบียนคอร์สนี้เรียบร้อยแล้ว",
"startNow": "เริ่มเรียนทันที",
"later": "ไว้ทีหลัง",
"alreadyEnrolledHint": "ท่านเคยลงทะเบียนคอร์ส {course} นี้ไปเรียบร้อยแล้ว"
"alreadyEnrolledHint": "ท่านเคยลงทะเบียนคอร์ส {course} นี้ไปเรียบร้อยแล้ว",
"error": "ไม่สามารถลงทะเบียนได้"
},
"certificate": {
"title": "ใบประกาศนียบัตรจบหลักสูตร",
@ -101,6 +145,8 @@
"email": "อีเมล",
"phone": "เบอร์โทรศัพท์",
"joinedAt": "สมัครสมาชิกเมื่อ",
"generalInfo": "ข้อมูลทั่วไป",
"accountDetails": "รายละเอียดบัญชี",
"editPersonalDesc": "แก้ไขข้อมูลส่วนตัว",
"yourAvatar": "รูปโปรไฟล์ของคุณ",
"avatarHint": "เฉพาะไฟล์ png , jpg",
@ -145,7 +191,7 @@
"logout": "ออกจากระบบ"
},
"landing": {
"allCourses": "คอร์สทั้งหมด",
"allCourses": "คอร์สเรียนทั้งหมด",
"discovery": "ค้นพบ",
"goToDashboard": "เข้าสู่หน้าจัดการเรียน"
},
@ -195,7 +241,11 @@
"attachments": "เอกสารประกอบ",
"announcements": "ประกาศในคอร์ส",
"posts": "โพสต์",
"noAnnouncements": "ยังไม่มีประกาศในขณะนี้"
"noAnnouncements": "ยังไม่มีประกาศในขณะนี้",
"quizRequired": "กรุณาทำแบบทดสอบ \"{title}\" ให้ผ่านก่อน",
"lessonRequired": "กรุณาเรียนบทเรียน \"{title}\" ให้จบก่อน",
"notEnrolled": "คุณยังไม่ได้ลงทะเบียนในคอร์สนี้",
"curriculum": "เนื้อหาหลักสูตร"
},
"quiz": {
"startTitle": "แบบทดสอบ",
@ -205,10 +255,19 @@
"instructionTitle": "คำแนะนำ",
"instruction1": "แบบทดสอบนี้มีไว้เพื่อวัดความรู้ความเข้าใจของคุณในบทเรียนนี้",
"startBtn": "เริ่มทำแบบทดสอบ",
"warningTitle": "คำเตือน",
"singleAttemptWarning": "แบบทดสอบนี้สามารถทำได้เพียงครั้งเดียวเท่านั้น หากไม่ผ่านคุณจะไม่สามารถทำใหม่ได้อีก คุณต้องการดำเนินการต่อหรือไม่?",
"continue": "ดำเนินการต่อ",
"alreadyPassed": "คุณสอบผ่านเกณฑ์แล้ว",
"latestScore": "คะแนนล่าสุด",
"retryMaybe": "ลองใหม่อีกครั้ง",
"passedStatus": "ผ่านเกณฑ์",
"failedStatus": "ไม่ผ่านเกณฑ์",
"passingScore": "เกณฑ์การผ่าน",
"exitTitle": "ออกจากแบบทดสอบ",
"timeLeft": "เวลาที่เหลือ",
"submitConfirm": "คุณต้องการส่งคำตอบหรือไม่?",
"exitConfirm": "คุณต้องการออกจากแบบทดสอบหรือไม่? การทำแบบทดสอบจะถูกยกเลิก",
"exitConfirm": "คุณต้องการออกจากแบบทดสอบหรือไม่? การทำแบบทดสอบของคุณจะถูกยกเลิก",
"submitValues": "ส่งคำตอบ",
"question": "คำถาม",
"backToLesson": "กลับไปหน้าเรียน",
@ -235,6 +294,18 @@
"statusCompleted": "ทำแล้ว",
"statusSkipped": "ข้าม",
"statusNotStarted": "ยังไม่ทำ",
"alertIncomplete": "กรุณาเลือกคำตอบให้ครบทุกข้อ"
"alertIncomplete": "กรุณาเลือกคำตอบให้ครบทุกข้อ",
"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

@ -0,0 +1,167 @@
<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,33 +8,157 @@
// Initialize global theme management
useThemeMode()
const { currentUser, logout } = useAuth()
const { isDark, set: setTheme } = useThemeMode()
const leftDrawerOpen = ref(false)
const rightDrawerOpen = ref(false)
const toggleLeftDrawer = () => {
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>
<template>
<q-layout view="hHh LpR lFf" class="bg-slate-50 dark:!bg-[#020617] text-slate-900 dark:!text-slate-50 font-sans">
<q-layout view="hHh LpR lFf" class="bg-slate-50 dark:!bg-[#020617] text-slate-900 dark:!text-slate-50">
<!-- Header -->
<q-header
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"
class="bg-white/80 dark:!bg-[#0f172a]/80 backdrop-blur-md text-slate-900 dark:!text-white border-none shadow-none"
>
<AppHeader @toggleSidebar="toggleLeftDrawer" />
<AppHeader
@toggleSidebar="toggleLeftDrawer"
@toggleRightDrawer="toggleRightDrawer"
:showSidebarToggle="!shouldHideSidebar"
/>
</q-header>
<!-- Sidebar (Drawer) -->
<!-- Sidebar (Drawer - Desktop Left) -->
<q-drawer
v-if="!shouldHideSidebar"
v-model="leftDrawerOpen"
show-if-above
bordered
:width="280"
class="bg-white dark:!bg-[#0f172a] border-r border-slate-200 dark:border-slate-800"
class="bg-white dark:!bg-[#0f172a]"
>
<AppSidebar />
</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 -->
<q-page-container>
<q-page class="relative">
@ -42,20 +166,13 @@ const toggleLeftDrawer = () => {
</q-page>
</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>
</template>
<style>
/* Ensure fonts are applied */
.font-inter {
font-family: 'Inter', sans-serif;
font-family: var(--font-main);
}
</style>

View file

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

View file

@ -66,10 +66,11 @@ export default defineNuxtConfig({
{ name: "viewport", content: "width=device-width, initial-scale=1" },
],
link: [
{ rel: 'icon', type: 'image/png', href: '/img/logo.png' },
{
rel: "stylesheet",
// โหลด 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&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&family=Poppins:wght@300;400;500;600;700;800;900&display=swap",
},
],
},

View file

@ -237,17 +237,24 @@ onMounted(() => {
<div v-else class="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
</button>
</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>
<!-- 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>
<!-- Register Link -->
<div class="text-center">
<div class="text-center mt-8">
<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">

View file

@ -33,16 +33,16 @@ const currentPage = ref(1);
const totalPages = ref(1);
const itemsPerPage = 12;
const { t, locale } = useI18n();
const { currentUser } = useAuth();
const $q = useQuasar();
const { fetchCategories } = useCategory();
const { fetchCourses, fetchCourseById, enrollCourse, getLocalizedText } = useCourse();
const { fetchCourses, fetchCourseById, enrollCourse, getLocalizedText } =
useCourse();
// 2. Computed Properties
const sortOption = ref(t('discovery.sortRecent'));
const sortOptions = computed(() => [t('discovery.sortRecent')]);
const sortOption = ref(t("discovery.sortRecent"));
const sortOptions = computed(() => [t("discovery.sortRecent")]);
const filteredCourses = computed(() => {
let result = courses.value;
@ -50,12 +50,14 @@ const filteredCourses = computed(() => {
// 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.
if (selectedCategoryIds.value.length > 1) {
result = result.filter(c => selectedCategoryIds.value.includes(c.category_id));
result = result.filter((c) =>
selectedCategoryIds.value.includes(c.category_id),
);
}
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
result = result.filter(c => {
result = result.filter((c) => {
const title = getLocalizedText(c.title).toLowerCase();
const desc = getLocalizedText(c.description).toLowerCase();
return title.includes(query) || (desc && desc.includes(query));
@ -66,7 +68,6 @@ const filteredCourses = computed(() => {
// 3. Helper Functions
// 4. API Actions
const loadCategories = async () => {
const res = await fetchCategories();
@ -77,13 +78,17 @@ const loadCourses = async (page = 1) => {
isLoading.value = true;
// Use server-side filtering if exactly one category is selected
const categoryId = selectedCategoryIds.value.length === 1 ? selectedCategoryIds.value[0] : undefined;
const categoryId =
selectedCategoryIds.value.length === 1
? selectedCategoryIds.value[0]
: undefined;
const res = await fetchCourses({
category_id: categoryId,
search: searchQuery.value,
page: page,
limit: itemsPerPage,
forceRefresh: true
forceRefresh: true,
});
if (res.success) {
@ -108,24 +113,37 @@ const handleEnroll = async (id: number) => {
isEnrolling.value = true;
const res = await enrollCourse(id);
if (res.success) {
return navigateTo('/dashboard/my-courses?enrolled=true');
return navigateTo("/dashboard/my-courses?enrolled=true");
} else {
$q.notify({
type: 'negative',
message: res.error || 'Failed to enroll',
position: 'top',
type: "negative",
message: res.error || t("enrollment.error"),
position: "top",
timeout: 3000,
actions: [{ icon: 'close', color: 'white' }]
})
actions: [{ icon: "close", color: "white" }],
});
}
isEnrolling.value = false;
};
// Watch for category selection changes to reload courses
watch(selectedCategoryIds, () => {
watch(
selectedCategoryIds,
() => {
currentPage.value = 1;
loadCourses(1);
}, { deep: true });
},
{ deep: true },
);
const toggleCategory = (id: number) => {
const index = selectedCategoryIds.value.indexOf(id);
if (index === -1) {
selectedCategoryIds.value.push(id);
} else {
selectedCategoryIds.value.splice(index, 1);
}
};
onMounted(() => {
loadCategories();
@ -135,54 +153,97 @@ onMounted(() => {
<template>
<div class="page-container">
<!-- CATALOG VIEW: Browse courses -->
<div v-if="!showDetail">
<!-- Top Header Area -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
<!-- Title -->
<h1 class="text-3xl font-black text-slate-900 dark:text-white flex items-center gap-3">
<span class="w-1.5 h-8 bg-blue-600 rounded-full shadow-sm shadow-blue-500/50"></span>
{{ $t('discovery.title') }}
<!-- New Enhanced Search Section (Image 1 Style) -->
<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 items-center gap-4 mb-2">
<h1 class="text-2xl md:text-3xl font-black text-slate-900 dark:text-white">
{{ $t("discovery.title") }}
</h1>
</div>
<p class="text-slate-500 dark:text-slate-400 font-medium mb-8">
{{ $t("discovery.subtitle") }}
</p>
<!-- Right Side: Search & Sort -->
<div class="flex items-center gap-3 w-full md:w-auto">
<div class="flex flex-col md:flex-row gap-4">
<!-- Search Input -->
<q-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"
dense
outlined
rounded
:placeholder="$t('discovery.searchPlaceholder')"
class="w-full md:w-72 search-input"
bg-color="transparent"
>
<template v-slot:prepend>
<q-icon name="search" class="text-slate-400" />
</template>
</q-input>
</div>
</div>
<!-- Main Layout: Sidebar + Grid -->
<div class="flex flex-col lg:flex-row gap-8">
<!-- LEFT SIDEBAR: Category Filter -->
<div class="w-full lg:w-64 flex-shrink-0 lg:sticky lg:top-24 z-10">
<ClientOnly>
<CategorySidebar
:categories="categories"
v-model="selectedCategoryIds"
type="text"
:placeholder="$t('discovery.searchPlaceholder') || 'ค้นหาคอร์สที่น่าสนใจที่นี่...'"
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"
@keyup.enter="loadCourses(1)"
/>
</ClientOnly>
</div>
<!-- RIGHT CONTENT: Course Grid -->
<div class="flex-1 w-full">
<!-- 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">
<q-icon name="search" size="20px" />
<span class="text-base">{{ $t("discovery.searchBtn") }}</span>
</div>
</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>
<!-- Unified Filter Section: Categories -->
<div
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
flat
rounded
dense
class="px-5 py-2 font-bold transition-all text-[11px] uppercase tracking-wider"
:class="
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 = []"
:label="$t('discovery.showAll')"
/>
<q-btn
v-for="cat in categories"
:key="cat.id"
flat
rounded
dense
class="px-5 py-2 font-bold transition-all text-[11px] uppercase tracking-wider"
:class="
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)"
:label="getLocalizedText(cat.name)"
/>
</div>
<!-- Main Layout: Grid Only -->
<div class="w-full">
<div v-if="filteredCourses.length > 0" class="flex flex-col gap-12">
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8"
>
<CourseCard
v-for="course in filteredCourses"
:key="course.id"
@ -212,21 +273,30 @@ onMounted(() => {
<!-- Empty State -->
<div
v-else
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"
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 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">{{ $t('discovery.emptyTitle') }}</h3>
<q-icon
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">
{{ $t("discovery.emptyTitle") }}
</h3>
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">
{{ $t('discovery.emptyDesc') }}
{{ $t("discovery.emptyDesc") }}
</p>
<button class="mt-6 font-bold text-blue-600 hover:text-blue-700 dark:hover:text-blue-400 transition-colors" @click="searchQuery = ''; selectedCategoryIds = []">
{{ $t('discovery.showAll') }}
<button
class="mt-6 font-bold text-blue-600 hover:text-blue-700 dark:hover:text-blue-400 transition-colors"
@click="
searchQuery = '';
selectedCategoryIds = [];
"
>
{{ $t("discovery.showAll") }}
</button>
</div>
</div>
</div>
</div>
<!-- COURSE DETAIL VIEW: Detailed information about a specific course -->
@ -235,8 +305,12 @@ onMounted(() => {
@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"
>
<q-icon name="arrow_back" size="24px" class="transition-transform group-hover:-translate-x-1" />
{{ $t('discovery.backToCatalog') }}
<q-icon
name="arrow_back"
size="24px"
class="transition-transform group-hover:-translate-x-1"
/>
{{ $t("discovery.backToCatalog") }}
</button>
<div v-if="isLoadingDetail" class="flex justify-center py-20">
@ -279,4 +353,3 @@ onMounted(() => {
box-shadow: none !important;
}
</style>

View file

@ -18,6 +18,7 @@ useHead({
// 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) => {
@ -26,8 +27,72 @@ const getLocalizedText = (text: string | { th: string; en: string } | undefined)
return text.th || text.en || ''
}
// Fetch courses from API
const { data: coursesResponse, error } = await useAsyncData('courses-list', () => fetchCourses())
const route = useRoute()
const router = useRouter()
// 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
const courses = computed(() => {
@ -75,13 +140,9 @@ const filteredCourses = computed(() => {
<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 -->
<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 -->
<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;">
<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 -->
@ -97,35 +158,90 @@ const filteredCourses = computed(() => {
<!-- ==========================================
SEARCH & GRID SECTION
========================================== -->
<section class="container mx-auto max-w-[1440px] px-6 pb-32">
<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">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-8 mb-12">
<h2 class="text-2xl font-black text-slate-900 flex items-center gap-3">
<span class="w-2 h-8 bg-blue-600 rounded-full"/>
รายการคอรสเรยน
</h2>
<!-- 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>
<!-- Search Bar (Compact) -->
<div class="relative max-w-md w-full">
<div class="relative group">
<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"
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"
placeholder="ค้นหาบทเรียน..."
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="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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" />
</svg>
<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="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>
<!-- 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 (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
@ -197,15 +313,16 @@ const filteredCourses = computed(() => {
CTA SECTION
Call to action to register
========================================== -->
<section class="py-32 relative overflow-hidden">
<!-- Gradient Overlay -->
<div class="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent pointer-events-none"/>
<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-8 tracking-tight">
<h2 class="text-4xl md:text-5xl font-black text-slate-900 mb-6 tracking-tight">
พรอมจะเรมตนแลวหรอย?
</h2>
<p class="text-slate-400 text-xl mb-12 max-w-2xl mx-auto leading-relaxed">
<p class="text-slate-500 text-lg md:text-xl mb-10 max-w-2xl mx-auto leading-relaxed">
ลงทะเบยนฟรนนเพอเขาถงบทเรยนพนฐาน และตดตามความคบหนาการเรยนของคณไดนท ไมาใชายแอบแฝง
</p>
<NuxtLink

View file

@ -0,0 +1,360 @@
<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

@ -6,9 +6,8 @@
* ออกแบบใหเหมอนระบบ LMS มาตรฐาน
*/
definePageMeta({
layout: false, // Custom layout defined within this component
layout: false,
middleware: 'auth'
})
@ -21,10 +20,9 @@ const router = useRouter()
const { t } = useI18n()
const { user } = useAuth()
const { fetchCourseLearningInfo, fetchLessonContent, saveVideoProgress, checkLessonAccess, fetchVideoProgress, fetchCourseAnnouncements, markLessonComplete, getLocalizedText } = useCourse()
// Media Prefs (Global Volume)
const { volume, muted: isMuted, setVolume, setMuted, applyTo } = useMediaPrefs()
const $q = useQuasar()
// State
// State management
const sidebarOpen = ref(false)
const courseId = computed(() => Number(route.query.course_id))
@ -92,6 +90,7 @@ const isPlaying = ref(false)
const videoProgress = ref(0)
const currentTime = ref(0)
const duration = ref(0)
const activeTab = ref('content')
@ -99,6 +98,83 @@ const toggleSidebar = () => {
sidebarOpen.value = !sidebarOpen.value
}
// Logic Quiz Attempt Management
const quizStatus = computed(() => {
if (!currentLesson.value || currentLesson.value.type !== 'QUIZ' || !currentLesson.value.quiz) return null
const quiz = currentLesson.value.quiz
const latestAttempt = quiz.latest_attempt
const allowMultiple = quiz.allow_multiple_attempts
// If never attempted
if (!latestAttempt) {
return {
canStart: true,
label: t('quiz.startBtn'),
icon: 'play_arrow',
showScore: false
}
}
// If multiple attempts allowed
if (allowMultiple) {
return {
canStart: true,
label: t('quiz.retryBtn'),
icon: 'refresh',
showScore: true,
score: latestAttempt.score,
isPassed: latestAttempt.is_passed
}
}
// allowMultiple is false (Single attempt only)
// Lock the quiz regardless of pass/fail once attempted
return {
canStart: false,
label: latestAttempt.is_passed ? t('quiz.passedStatus') : t('quiz.failedStatus'),
icon: latestAttempt.is_passed ? 'check_circle' : 'cancel',
showScore: true,
score: latestAttempt.score,
isPassed: latestAttempt.is_passed
}
})
const handleStartQuiz = () => {
if (!currentLesson.value || !currentLesson.value.quiz) return
const quiz = currentLesson.value.quiz
// If multiple attempts are disabled and it's the first time
if (!quiz.allow_multiple_attempts && !quiz.latest_attempt) {
$q.dialog({
title: `<div class="text-slate-900 dark:text-white font-black text-xl">${t('quiz.warningTitle')}</div>`,
message: `<div class="text-slate-600 dark:text-slate-300 text-base leading-relaxed mt-2">${t('quiz.singleAttemptWarning')}</div>`,
html: true,
persistent: true,
class: 'rounded-[24px]',
ok: {
label: t('quiz.continue'),
color: 'primary',
unelevated: true,
rounded: true,
class: 'px-8 font-black'
},
cancel: {
label: t('common.cancel'),
color: 'grey-7',
flat: true,
rounded: true,
class: 'font-bold'
}
}).onOk(() => {
router.push(`/classroom/quiz?course_id=${courseId.value}&lesson_id=${currentLesson.value.id}`)
})
} else {
router.push(`/classroom/quiz?course_id=${courseId.value}&lesson_id=${currentLesson.value.id}`)
}
}
// Helper (Hard Reload)
const resetAndNavigate = (path: string) => {
if (import.meta.client) {
@ -117,18 +193,18 @@ const resetAndNavigate = (path: string) => {
}
}
// 2. localStorage
// 2. Clear all localStorage
localStorage.clear()
// 3.
Object.entries(whitelist).forEach(([key, value]) => {
localStorage.setItem(key, value)
// 3. Restore ONLY whitelisted keys
Object.keys(whitelist).forEach(key => {
localStorage.setItem(key, whitelist[key])
})
// 4. (Hard Reload) path
// 4. Force hard reload to the new path
window.location.href = path
} else {
// Fallback SSR
// SSR Fallback
router.push(path)
}
}
@ -137,13 +213,13 @@ const resetAndNavigate = (path: string) => {
const handleLessonSelect = (lessonId: number) => {
if (currentLesson.value?.id === lessonId) return
// 1. URL
// 1. Update URL query params
router.push({ query: { ...route.query, lesson_id: lessonId.toString() } })
// 2. Refresh
// 2. Load content without refresh
loadLesson(lessonId)
// Sidebar
// Close sidebar on mobile
if (sidebarOpen.value) {
sidebarOpen.value = false
}
@ -169,14 +245,12 @@ const loadCourseData = async () => {
if (res.success) {
courseData.value = res.data
// Auto-load logic: URL
// Auto-load logic: Check URL first, fallback to first available lesson
const urlLessonId = route.query.lesson_id ? Number(route.query.lesson_id) : null
if (urlLessonId) {
// URL
loadLesson(urlLessonId)
} else if (!currentLesson.value) {
// URL
const firstChapter = res.data.chapters[0]
if (firstChapter && firstChapter.lessons.length > 0) {
const availableLesson = firstChapter.lessons.find((l: any) => !l.is_locked) || firstChapter.lessons[0]
@ -184,7 +258,7 @@ const loadCourseData = async () => {
}
}
// Fetch Announcements
// Fetch Course Announcements
const annRes = await fetchCourseAnnouncements(courseId.value)
if (annRes.success) {
announcements.value = annRes.data || []
@ -192,7 +266,7 @@ const loadCourseData = async () => {
}
}
} catch (error) {
console.error('Error loading course:', error)
console.error('Error loading course data:', error)
} finally {
isLoading.value = false
}
@ -205,6 +279,12 @@ const loadLesson = async (lessonId: number) => {
isPlaying.value = false
videoProgress.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
isLessonLoading.value = true
@ -219,14 +299,14 @@ const loadLesson = async (lessonId: number) => {
msg = accessRes.data.lock_reason
} else if (accessRes.data.required_quiz_pass && !accessRes.data.required_quiz_pass.is_passed) {
const quizTitle = getLocalizedText(accessRes.data.required_quiz_pass.title)
msg = `กรุณาทำแบบทดสอบ "${quizTitle}" ให้ผ่านก่อน`
msg = t('classroom.quizRequired', { title: quizTitle })
} else if (accessRes.data.required_lessons && accessRes.data.required_lessons.length > 0) {
const reqLesson = accessRes.data.required_lessons.find((l: any) => !l.is_completed)
if (reqLesson) {
msg = `กรุณาเรียนบทเรียน "${getLocalizedText(reqLesson.title)}" ให้จบก่อน`
msg = t('classroom.lessonRequired', { title: getLocalizedText(reqLesson.title) })
}
} else if (accessRes.data.is_enrolled === false) {
msg = 'คุณยังไม่ได้ลงทะเบียนในคอร์สนี้'
msg = t('classroom.notEnrolled')
}
alert(msg)
@ -259,21 +339,29 @@ const loadLesson = async (lessonId: number) => {
// 2. Fetch Initial Progress (Resume Playback)
if (currentLesson.value.type === 'VIDEO') {
// A. Server Progress
// If already completed, clear local resume point to allow fresh re-watch
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)
let serverProgress = 0
if (progressRes.success && progressRes.data?.video_progress_seconds) {
serverProgress = progressRes.data.video_progress_seconds
}
// B. Local Progress (Buffer)
const localProgress = getLocalProgress(lessonId)
// C. Hybrid Resume (Max Wins)
const resumeTime = Math.max(serverProgress, localProgress)
if (resumeTime > 0) {
initialSeekTime.value = resumeTime
maxWatchedTime.value = resumeTime
currentTime.value = resumeTime
@ -283,6 +371,7 @@ const loadLesson = async (lessonId: number) => {
}
}
}
}
} catch (error) {
console.error('Error loading lesson:', error)
} finally {
@ -483,14 +572,19 @@ const videoSrc = computed(() => {
// (Complete)
const onVideoEnded = async () => {
// Safety check BEFORE trying to save
const lesson = currentLesson.value
if (!lesson || !lesson.progress || lesson.progress.is_completed || isCompleting.value) return
if (!lesson) 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
try {
// 1. Force save progress at 100%
// This will trigger the backend's auto-complete logic
await performSaveProgress(true, false)
} catch (err) {
console.error('Failed to save progress on end:', err)
@ -511,56 +605,67 @@ onBeforeUnmount(() => {
</script>
<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 -->
<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>
<q-btn flat round dense icon="menu" class="mr-2 text-slate-900 dark:text-white hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" @click="toggleSidebar" />
<!-- Back Button & Branding -->
<div class="flex items-center gap-2 mr-6">
<q-header bordered class="bg-[var(--bg-surface)] border-b border-gray-200 dark:border-white/5 text-[var(--text-main)] h-16">
<q-toolbar class="h-full px-4">
<!-- 1. Left Side: Back & Title -->
<div class="flex items-center gap-4 flex-grow overflow-hidden">
<!-- Back Button -->
<q-btn
flat
round
dense
icon="arrow_back"
class="mr-2 text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30 border border-blue-100 dark:border-blue-800/50 hover:bg-blue-100 dark:hover:bg-blue-800/50 transition-all"
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"
@click="handleExit('/dashboard/my-courses')"
>
<q-icon name="arrow_back" size="20px" />
<q-tooltip>{{ $t('classroom.backToDashboard') }}</q-tooltip>
</q-btn>
<div class="hidden sm:flex items-center gap-2 cursor-pointer group" @click="handleExit('/dashboard')">
<div class="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center text-white font-black shadow-lg shadow-blue-600/30 group-hover:scale-110 transition-transform">
E
</div>
</div>
</div>
<!-- Course Title -->
<div class="flex flex-col">
<q-toolbar-title class="text-base font-bold text-left truncate text-slate-900 dark:text-white">
<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') }}
</q-toolbar-title>
</h1>
</div>
</div>
<div class="flex items-center gap-2 pr-2">
<!-- Announcements Button -->
<!-- 2. Right Side: Actions -->
<div class="flex items-center gap-3">
<!-- Sidebar Toggle (Right Side) -->
<q-btn
flat
round
dense
icon="campaign"
@click="handleOpenAnnouncements"
class="text-slate-600 dark:text-slate-300 hover:text-blue-600 transition-colors"
class="text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
@click="toggleSidebar"
>
<q-badge v-if="hasUnreadAnnouncements" color="red" floating rounded />
<q-tooltip>{{ $t('classroom.announcements', 'ประกาศในคอร์ส') }}</q-tooltip>
<q-icon name="menu_open" size="24px" class="transform rotate-180" />
<q-tooltip>{{ $t('classroom.curriculum') }}</q-tooltip>
</q-btn>
<!-- Announcements Button (Refined) -->
<q-btn
flat
round
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"
@click="handleOpenAnnouncements"
>
<q-icon name="campaign" size="22px" />
<!-- 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-btn>
</div>
</q-toolbar>
</q-header>
<!-- Sidebar (Curriculum) -->
<!-- Sidebar (Curriculum) -->
<!-- Sidebar (Curriculum) - Positioned Right via component prop -->
<CurriculumSidebar
v-model="sidebarOpen"
:courseData="courseData"
@ -575,32 +680,50 @@ onBeforeUnmount(() => {
<q-page-container class="bg-white dark:bg-slate-900">
<q-page class="flex flex-col h-full bg-slate-50 dark:bg-[#0B0F1A]">
<!-- Video Player & Content Area -->
<div class="w-full max-w-7xl mx-auto p-4 md:p-6 flex-grow">
<div class="w-full h-full p-4 md:p-6 flex-grow overflow-y-auto">
<!-- 1. LOADING STATE (Comprehensive Skeleton) -->
<div v-if="isLessonLoading" class="animate-fade-in">
<!-- Video Skeleton -->
<div class="aspect-video bg-slate-200 dark:bg-slate-800 rounded-3xl animate-pulse flex items-center justify-center mb-10 overflow-hidden relative shadow-xl focus:outline-none">
<img
v-if="courseData?.course?.thumbnail_url"
:src="courseData.course.thumbnail_url"
class="absolute inset-0 w-full h-full object-cover opacity-20 blur-md"
/>
<div class="absolute inset-0 bg-gradient-to-br from-slate-200/50 to-slate-300/50 dark:from-slate-900/80 dark:to-slate-800/80"></div>
<div class="z-10 flex flex-col items-center">
<q-spinner size="3.5rem" color="primary" :thickness="2" />
<p class="mt-4 text-slate-500 font-bold text-xs uppercase tracking-[0.2em]">{{ $t('common.loading') }}</p>
</div>
</div>
<!-- Info Skeleton -->
<div class="bg-white dark:bg-slate-800/50 p-8 rounded-3xl border border-slate-100 dark:border-white/5 shadow-sm">
<div class="h-10 bg-slate-200 dark:bg-slate-800 rounded-xl w-3/4 mb-4 animate-pulse"></div>
<div class="h-4 bg-slate-100 dark:bg-slate-800 rounded-lg w-full mb-2 animate-pulse"></div>
<div class="h-4 bg-slate-100 dark:bg-slate-800 rounded-lg w-2/3 animate-pulse"></div>
</div>
</div>
<!-- 2. READY STATE (Real Lesson Content) -->
<div v-else-if="currentLesson" class="animate-fade-in">
<!-- Video Player -->
<VideoPlayer
v-if="currentLesson && videoSrc && !isLessonLoading"
v-if="videoSrc"
ref="videoPlayerComp"
:src="videoSrc"
:poster="courseData?.course?.thumbnail_url"
:initialSeekTime="initialSeekTime"
@timeupdate="handleVideoTimeUpdate"
@ended="onVideoEnded"
@loadedmetadata="(d: number) => onVideoMetadataLoaded(d)"
/>
<!-- Skeleton Loader for Video/Content -->
<div v-if="isLessonLoading" class="aspect-video bg-slate-200 dark:bg-slate-800 rounded-2xl animate-pulse flex items-center justify-center mb-6 overflow-hidden relative">
<div class="absolute inset-0 bg-gradient-to-br from-slate-200 to-slate-300 dark:from-slate-700 dark:to-slate-800 opacity-50"></div>
<div class="z-10 flex flex-col items-center">
<q-spinner size="4rem" color="primary" :thickness="4" />
<p class="mt-4 text-slate-500 dark:text-slate-400 font-bold animate-bounce">{{ $t('common.loading') }}...</p>
</div>
</div>
<!-- Lesson Info -->
<div v-if="currentLesson" class="bg-[var(--bg-surface)] p-6 md:p-8 rounded-3xl shadow-sm border border-[var(--border-color)]">
<div class="bg-[var(--bg-surface)] p-6 md:p-8 rounded-3xl shadow-sm border border-[var(--border-color)]">
<!-- ใชจากตวแปรกลาง: จะแยกโหมดใหตโนม (สวาง=ดำ / =ขาว) -->
<div class="flex items-start justify-between gap-4 mb-4">
<h1 class="text-2xl md:text-3xl font-bold text-slate-900 dark:text-white leading-tight font-display">{{ getLocalizedText(currentLesson.title) }}</h1>
<h1 class="text-3xl md:text-5xl font-black text-slate-900 dark:text-white leading-tight tracking-tight font-display">{{ getLocalizedText(currentLesson.title) }}</h1>
</div>
<p class="text-slate-600 dark:text-slate-400 text-base md:text-lg leading-relaxed mb-6" v-if="currentLesson.description">{{ getLocalizedText(currentLesson.description) }}</p>
@ -610,27 +733,51 @@ onBeforeUnmount(() => {
<div class="bg-white dark:bg-slate-800 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-4 shadow-sm text-blue-500 dark:text-blue-400 border dark:border-white/10">
<q-icon name="quiz" size="40px" />
</div>
<h2 class="text-xl font-bold mb-2 text-slate-900 dark:text-white">{{ $t('quiz.startTitle', 'แบบทดสอบท้ายบทเรียน') }}</h2>
<h2 class="text-xl font-bold mb-2 text-slate-900 dark:text-white">{{ $t('quiz.startTitle') }}</h2>
<p class="text-slate-500 dark:text-slate-400 mb-6 max-w-md mx-auto">{{ getLocalizedText(currentLesson.quiz?.description || currentLesson.description) }}</p>
<div class="flex justify-center flex-wrap gap-3 text-sm mb-8">
<span v-if="currentLesson.quiz?.questions?.length" class="px-3 py-1 bg-white dark:bg-white rounded-full border border-gray-200 dark:border-white/10 shadow-sm flex items-center gap-1.5 text-slate-900 font-bold">
<q-icon name="format_list_numbered" size="14px" class="text-blue-600" /> {{ currentLesson.quiz.questions.length }}
<span v-if="currentLesson.quiz?.questions?.length" class="px-4 py-1.5 bg-white dark:bg-slate-800 rounded-full border border-gray-100 dark:border-white/5 shadow-sm flex items-center gap-2 text-slate-700 dark:text-slate-300 font-bold">
<q-icon name="format_list_numbered" size="14px" class="text-blue-500" /> {{ currentLesson.quiz.questions.length }} {{ $t('quiz.questions') }}
</span>
<span v-if="currentLesson.quiz?.time_limit" class="px-3 py-1 bg-white dark:bg-white rounded-full border border-gray-200 dark:border-white/10 shadow-sm flex items-center gap-1.5 text-slate-900 font-bold">
<q-icon name="schedule" size="14px" class="text-orange-600" /> {{ currentLesson.quiz.time_limit }} นาท
<span v-if="currentLesson.quiz?.time_limit" class="px-4 py-1.5 bg-white dark:bg-slate-800 rounded-full border border-gray-100 dark:border-white/5 shadow-sm flex items-center gap-2 text-slate-700 dark:text-slate-300 font-bold">
<q-icon name="schedule" size="14px" class="text-orange-500" /> {{ currentLesson.quiz.time_limit }} {{ $t('quiz.minutes') }}
</span>
</div>
<div v-if="quizStatus?.showScore" class="mb-8 p-6 bg-white dark:!bg-slate-800/80 rounded-[32px] border border-blue-50 dark:border-white/5 shadow-xl max-w-sm mx-auto backdrop-blur-md">
<div class="text-[10px] uppercase font-black tracking-[0.2em] text-slate-400 dark:text-slate-500 mb-4">{{ $t('quiz.latestScore') }}</div>
<div class="flex items-center justify-center gap-6">
<div class="text-5xl font-black" :class="quizStatus.isPassed ? 'text-emerald-500' : 'text-rose-500'">
{{ quizStatus.score }}
</div>
<div class="h-12 w-px bg-slate-100 dark:bg-white/10"></div>
<div class="flex flex-col items-start gap-1">
<span class="text-[10px] font-black px-2.5 py-1 rounded-lg uppercase tracking-wider" :class="quizStatus.isPassed ? 'bg-emerald-500/10 text-emerald-500' : 'bg-rose-500/10 text-rose-500'">
{{ quizStatus.isPassed ? $t('quiz.passedStatus') : $t('quiz.failedStatus') }}
</span>
<span class="text-[10px] text-slate-400 dark:text-slate-500 font-bold">{{ $t('quiz.passingScore') }} {{ currentLesson.quiz.passing_score }}%</span>
</div>
</div>
</div>
<q-btn
v-if="quizStatus?.canStart"
class="bg-blue-600 text-white shadow-lg shadow-blue-600/30 hover:shadow-blue-600/50 transition-all font-bold px-8"
size="lg"
rounded
no-caps
:label="$t('quiz.startBtn', 'เริ่มทำแบบทดสอบ')"
icon="play_arrow"
@click="$router.push(`/classroom/quiz?course_id=${courseId}&lesson_id=${currentLesson.id}`)"
:label="quizStatus.label"
:icon="quizStatus.icon"
@click="handleStartQuiz"
/>
<div v-else-if="quizStatus"
class="inline-flex items-center gap-2 px-6 py-3 rounded-full font-bold"
:class="quizStatus.isPassed ? 'bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : 'bg-rose-50 dark:bg-rose-500/10 text-rose-600 dark:text-rose-400'"
>
<q-icon :name="quizStatus.icon" size="20px" />
{{ quizStatus.label }}
</div>
</div>
<div v-else-if="currentLesson.content" class="prose prose-lg dark:prose-invert max-w-none p-6 md:p-8 bg-gray-50 dark:bg-slate-800/50 rounded-2xl border border-gray-100 dark:border-white/5">
<div v-html="getLocalizedText(currentLesson.content)" class="leading-relaxed text-slate-800 dark:text-slate-200"></div>
@ -642,7 +789,7 @@ onBeforeUnmount(() => {
<div class="w-8 h-8 rounded-lg bg-orange-100 dark:bg-orange-900/30 text-orange-600 flex items-center justify-center">
<q-icon name="attach_file" size="18px" />
</div>
{{ $t('classroom.attachments') || 'เอกสารประกอบการเรียน' }}
{{ $t('classroom.attachments') }}
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<a
@ -667,9 +814,10 @@ onBeforeUnmount(() => {
<q-icon name="download" class="text-slate-300 group-hover:text-blue-500 z-10" />
</a>
</div>
</div>
</div>
</div>
</div> <!-- End Attachments -->
</div> <!-- End Lesson Info -->
</div> <!-- End Ready State Wrapper -->
</div> <!-- End Main Content Wrapper -->
</q-page>
</q-page-container>

View file

@ -11,7 +11,7 @@ definePageMeta({
middleware: 'auth'
})
const { t } = useI18n()
const { locale, t } = useI18n()
const route = useRoute()
const router = useRouter()
const $q = useQuasar()
@ -83,7 +83,7 @@ const jumpToQuestion = (targetIndex: number) => {
if (!isAnswered && !isSkippable) {
$q.notify({
type: 'warning',
message: t('quiz.pleaseSelectAnswer', 'กรุณาเลือกคำตอบ'),
message: t('quiz.pleaseSelectAnswer'),
position: 'top',
timeout: 2000
})
@ -106,6 +106,8 @@ const totalQuestions = computed(() => {
return quizData.value?.questions?.length || 0
})
const hasQuestions = computed(() => totalQuestions.value > 0)
const showQuestionMap = computed(() => $q.screen.gt.sm)
const timerDisplay = computed(() => {
@ -118,7 +120,8 @@ const timerDisplay = computed(() => {
const getLocalizedText = (text: any) => {
if (!text) return ''
if (typeof text === 'string') return text
return text.th || text.en || ''
const currentLocale = locale.value as 'th' | 'en'
return text[currentLocale] || text.th || text.en || ''
}
const lessonProgress = ref<any>(null)
@ -251,12 +254,37 @@ const submitQuiz = async (auto = false) => {
return
}
// Confirmation before submission
if (!confirm(t('quiz.submitConfirm', 'ยืนยันการส่งคำตอบ?'))) {
// Premium Confirmation before submission
$q.dialog({
title: `<div class="text-slate-900 dark:text-white font-black text-xl">${t('quiz.warningTitle')}</div>`,
message: `<div class="text-slate-600 dark:text-slate-300 text-base leading-relaxed mt-2">${t('quiz.submitConfirm')}</div>`,
html: true,
persistent: true,
class: 'rounded-[24px]',
ok: {
label: t('common.ok'),
color: 'primary',
unelevated: true,
rounded: true,
class: 'px-8 font-black'
},
cancel: {
label: t('common.cancel'),
color: 'grey-7',
flat: true,
rounded: true,
class: 'font-bold'
}
}).onOk(() => {
processSubmitQuiz(auto)
})
return
}
processSubmitQuiz(auto)
}
const processSubmitQuiz = async (auto = false) => {
// 2. Start Submission Process
if (timerInterval) clearInterval(timerInterval)
@ -284,12 +312,13 @@ const submitQuiz = async (auto = false) => {
}
} else {
// Fallback error handling
alert(res.error || 'Failed to submit quiz')
// Maybe go back to taking?
$q.notify({
type: 'negative',
message: res.error || 'Failed to submit quiz'
})
}
} catch (err) {
console.error('Submit quiz error:', err)
alert('An unexpected error occurred.')
} finally {
isSubmitting.value = false
}
@ -299,9 +328,29 @@ const confirmExit = () => {
const target = courseId ? `/classroom/learning?course_id=${courseId}` : '/dashboard/my-courses'
if (currentScreen.value === 'taking') {
if (confirm(t('quiz.exitConfirm'))) {
router.push(target)
$q.dialog({
title: `<div class="text-slate-900 dark:text-white font-black text-xl">${t('quiz.exitTitle')}</div>`,
message: `<div class="text-slate-600 dark:text-slate-300 text-base leading-relaxed mt-2">${t('quiz.exitConfirm')}</div>`,
html: true,
persistent: true,
class: 'rounded-[24px]',
ok: {
label: t('common.ok'),
color: 'primary',
unelevated: true,
rounded: true,
class: 'px-8 font-black'
},
cancel: {
label: t('common.cancel'),
color: 'grey-7',
flat: true,
rounded: true,
class: 'font-bold'
}
}).onOk(() => {
router.push(target)
})
} else {
router.push(target)
}
@ -348,7 +397,7 @@ const getCorrectChoiceId = (questionId: number) => {
</button>
<div class="w-[1px] h-4 bg-slate-300 dark:bg-white/10 mx-4"/>
<h1 class="text-base font-bold text-slate-900 dark:text-white truncate max-w-[200px] md:max-w-md hidden md:block">
{{ currentScreen === 'review' ? $t('quiz.reviewAnswers', 'เฉลยคำตอบ') : (quizData ? getLocalizedText(quizData.title) : (courseData ? getLocalizedText(courseData.course.title) : $t('quiz.startTitle'))) }}
{{ currentScreen === 'review' ? $t('quiz.reviewAnswers') : (quizData ? getLocalizedText(quizData.title) : (courseData ? getLocalizedText(courseData.course.title) : $t('quiz.startTitle'))) }}
</h1>
</div>
@ -369,6 +418,22 @@ const getCorrectChoiceId = (questionId: number) => {
<p class="text-sm font-medium text-slate-500">{{ $t('classroom.loadingTitle') }}</p>
</div>
<div v-else-if="!quizData || !hasQuestions" class="w-full max-w-[640px] animate-fade-in py-12">
<div class="bg-white dark:!bg-[#1e293b] border border-slate-200 dark:border-white/5 rounded-[32px] p-8 md:p-14 shadow-lg text-center">
<div class="w-20 h-20 rounded-3xl bg-amber-50 dark:bg-amber-500/10 border border-amber-100 dark:border-amber-500/20 flex items-center justify-center mx-auto mb-6">
<q-icon name="warning" size="2.5rem" color="warning" />
</div>
<h2 class="text-2xl font-black text-slate-900 dark:text-white mb-2">{{ $t('quiz.noQuizData') }}</h2>
<p class="text-slate-500 dark:text-slate-400 mb-8">{{ $t('quiz.noQuizDesc') }}</p>
<button
class="px-8 py-3 bg-blue-600 text-white rounded-xl hover:bg-blue-500 transition-all font-black"
@click="confirmExit"
>
{{ $t('quiz.backToLesson') }}
</button>
</div>
</div>
<template v-else>
<!-- 1. START SCREEN -->
<div v-if="currentScreen === 'start'" class="w-full max-w-[640px] animate-fade-in py-12">
@ -475,7 +540,7 @@ const getCorrectChoiceId = (questionId: number) => {
:disabled="currentQuestionIndex === 0"
class="px-6 py-3 rounded-xl font-bold text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-white/5 hover:bg-slate-200 dark:hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-all flex items-center gap-2"
>
<q-icon name="arrow_back" /> {{ $t('common.back', 'ย้อนกลับ') }}
<q-icon name="arrow_back" /> {{ $t('common.back') }}
</button>
<button
@ -483,7 +548,7 @@ const getCorrectChoiceId = (questionId: number) => {
@click="nextQuestion"
class="px-8 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition-all shadow-lg shadow-blue-500/20 flex items-center gap-2"
>
{{ $t('common.next', 'ถัดไป') }} <q-icon name="arrow_forward" />
{{ $t('common.next') }} <q-icon name="arrow_forward" />
</button>
<button
v-else
@ -555,7 +620,7 @@ const getCorrectChoiceId = (questionId: number) => {
@click="reviewQuiz"
class="w-full py-2 text-blue-500 hover:text-blue-700 dark:hover:text-blue-400 font-bold text-sm transition-colors mt-2"
>
{{ $t('quiz.reviewAnswers', 'ดูเฉลยคำตอบ') }}
{{ $t('quiz.reviewAnswers') }}
</button>
</div>
</div>

View file

@ -1,93 +1,387 @@
<script setup lang="ts">
/**
* @file home.vue
* @description หนาแดชบอรดหล (Dashboard)
* แสดงขอความตอนร และคอรสแนะนำสำหรบผเรยน
* @file index.vue
* @description Dashboard Home Page matching FutureSkill design
*/
definePageMeta({
layout: 'default',
middleware: 'auth'
})
layout: "default",
middleware: "auth",
});
useHead({
title: 'Dashboard - e-Learning'
})
title: "Dashboard - FutureSkill Clone",
});
const { currentUser } = useAuth()
const { fetchCourses, getLocalizedText } = useCourse() // Import useCourse
const { fetchCategories } = useCategory() // Import useCategory
const { currentUser } = useAuth();
const { fetchCourses, fetchEnrolledCourses, getLocalizedText } = useCourse();
const { fetchCategories } = useCategory();
const { t } = useI18n();
const { t } = useI18n()
// State
const enrolledCourses = ref<any[]>([]);
const recommendedCourses = ref<any[]>([]);
const libraryCourses = ref<any[]>([]);
const categories = ref<any[]>([]);
const isLoading = ref(true);
// Recommended Courses State
// ( 3 )
const recommendedCourses = ref<any[]>([])
// Initial Data Fetch
onMounted(async () => {
// 1. Fetch Categories for mapping
const catRes = await fetchCategories()
const catMap = new Map()
isLoading.value = true;
try {
const [catRes, enrollRes, courseRes] = await Promise.all([
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) {
catRes.data?.forEach((c: any) => catMap.set(c.id, c.name))
categories.value = catRes.data || [];
}
// 2. Fetch 3 Random Courses from Server
// Server ( API parameter random limit)
const res = await fetchCourses({ random: true, limit: 3, forceRefresh: true, is_recommended: true })
const catMap = new Map();
categories.value.forEach((c: any) => catMap.set(c.id, c.name));
if (res.success && res.data?.length) {
recommendedCourses.value = res.data.map((c: any) => ({
// Map Enrolled Courses
if (enrollRes.success && enrollRes.data) {
// 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,
title: c.title,
category: catMap.get(c.category_id),
lessons: c.lessons,
image: c.thumbnail_url || '',
badge: '',
badgeType: ''
}))
description: c.description,
lessons: c.total_lessons || 0,
image: c.thumbnail_url || "",
rating: c.rating,
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>
<template>
<div class="page-container">
<!-- Welcome Header Section -->
<div class="welcome-section mb-10 overflow-hidden relative rounded-[2.5rem] p-8 md:p-12 text-white shadow-xl dark:shadow-2xl dark:shadow-blue-950/40 transition border border-white/5">
<div class="relative z-10 flex flex-col md:flex-row justify-between items-center gap-8">
<div class="text-center md:text-left">
<ClientOnly>
<h1 class="text-4xl md:text-6xl font-black mb-4 slide-up tracking-tight text-white drop-shadow-sm">
{{ $t('dashboard.welcomeTitle') }}, {{ currentUser?.firstName }}!
<div class="bg-[#F8F9FA] dark:bg-[#020617] min-h-screen font-inter pb-20 transition-colors duration-300">
<div class="container mx-auto px-6 md:px-12 space-y-16 mt-10">
<!-- 1. Dashboard Hero Banner (Refined) -->
<section
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"
>
<!-- Subtle Decorative Elements -->
<div
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"
/>
<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"
/>
<div class="max-w-2xl space-y-6 relative z-10">
<h1
class="text-3xl md:text-4xl lg:text-5xl font-bold text-slate-900 dark:text-white leading-[1.5] tracking-tight"
>
{{ $t("dashboard.heroTitle") }}
<span class="inline-block text-blue-600 dark:text-blue-400 mt-1 md:mt-2">{{
$t("dashboard.heroSubtitle")
}}</span>
</h1>
</ClientOnly>
<p class="text-lg md:text-xl slide-up font-medium text-blue-100/90 max-w-xl" style="animation-delay: 0.1s;">
{{ $t('dashboard.welcomeSubtitle') }}
<p
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 class="stats-mini flex gap-6 slide-up" style="animation-delay: 0.2s;"/>
</div>
<!-- Decorative Background elements -->
<div class="absolute inset-0 bg-gradient-to-br from-blue-500 via-blue-600 to-indigo-700 dark:from-[#1e293b] dark:via-[#0f172a] dark:to-[#1e3a8a] -z-0"/>
<div class="absolute -top-20 -right-20 w-80 h-80 bg-white/10 blur-[100px] rounded-full"/>
<div class="absolute -bottom-20 -left-20 w-80 h-80 bg-blue-400/10 blur-[100px] rounded-full"/>
<div class="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-[0.03] mix-blend-overlay"></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>
<!-- Main Content Area -->
<div>
<!-- Section: Recommended Courses -->
<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>
<!-- Side List (Right) -->
<div class="flex flex-col gap-4">
<div
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">
<h2 class="text-xl font-black flex items-center gap-3 tracking-tight text-slate-900 dark:text-white">
<span class="w-1 h-6 bg-emerald-500 rounded-full shadow-sm shadow-emerald-500/50"/>
{{ $t('menu.recommendedCourses') }}
<h2 class="text-xl md:text-2xl font-bold text-[#2D2D2D] dark:text-white mb-1 transition-colors">
{{ $t("dashboard.knowledgeLibrary") }}
</h2>
<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>
</div>
<!-- Recommended Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 pb-20">
<!-- Recommended Grid (3 columns) -->
<div
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 animate-fade-in"
>
<CourseCard
v-for="course in recommendedCourses"
:key="course.id"
@ -95,22 +389,35 @@ onMounted(async () => {
/>
</div>
<!-- Loading State for Recommended -->
<div v-if="recommendedCourses.length === 0" class="flex justify-center py-10 opacity-50">
<div class="animate-pulse">Loading recommendations...</div>
<!-- Loading State -->
<div
v-if="recommendedCourses.length === 0 && !isLoading"
class="flex justify-center py-10 opacity-50"
>
<div class="text-gray-400 dark:text-slate-500">{{ $t("dashboard.noRecommended") }}</div>
</div>
</section>
</div>
</div>
</template>
<style scoped>
@keyframes slide-up {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
/* Scoped specific styles */
.animate-fade-in {
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
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;
:deep(.q-btn) {
text-transform: none; /* Prevent uppercase in Q-Btns */
}
</style>

View file

@ -11,13 +11,16 @@ definePageMeta({
middleware: 'auth'
})
const { t, locale } = useI18n()
useHead({
title: 'คอร์สของฉัน - e-Learning'
title: `${t('sidebar.myCourses')} - e-Learning`
})
const route = useRoute()
const showEnrollModal = ref(false)
const activeFilter = ref<'all' | 'progress' | 'completed'>('all')
const searchQuery = ref('')
// Check URL query parameters to show 'Enrollment Success' modal
@ -27,7 +30,6 @@ onMounted(() => {
}
})
const { locale } = useI18n()
// Helper to get localized text
const getLocalizedText = (text: string | { th: string; en: string } | undefined) => {
@ -71,6 +73,7 @@ const loadEnrolledCourses = async () => {
enrollment_id: item.id,
title: item.course.title,
progress: item.progress_percentage || 0,
lessons: item.course.total_lessons || 0,
completed: item.status === 'COMPLETED',
thumbnail_url: item.course.thumbnail_url
}))
@ -83,6 +86,16 @@ watch(activeFilter, () => {
loadEnrolledCourses()
})
// Client-side Search Filtering
const filteredEnrolledCourses = computed(() => {
if (!searchQuery.value) return enrolledCourses.value
const query = searchQuery.value.toLowerCase()
return enrolledCourses.value.filter(c => {
const title = getLocalizedText(c.title).toLowerCase()
return title.includes(query)
})
})
onMounted(() => {
if (route.query.enrolled) {
showEnrollModal.value = true
@ -138,21 +151,53 @@ const validCourseId = computed(() => {
<div class="page-container">
<!-- Page Header & Filters -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-10">
<h1 class="text-3xl font-black text-slate-900 dark:text-white">{{ $t('sidebar.myCourses') }}</h1>
<!-- Page Header & Filters (Unified Layout) -->
<!-- New Enhanced Search Section (Image 2 Style) -->
<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">
<h2 class="text-2xl md:text-3xl font-black text-slate-900 dark:text-white mb-2">{{ $t('myCourses.title') }}</h2>
<p class="text-slate-500 dark:text-slate-400 font-medium mb-8">{{ $t('myCourses.subtitle') }}</p>
<!-- Filter Tabs -->
<div class="flex gap-2">
<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="$t('myCourses.searchPlaceholder')"
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"
/>
</div>
<!-- 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">
<q-icon name="search" size="20px" />
<span class="text-base">{{ $t("discovery.searchBtn") }}</span>
</div>
</q-btn>
</div>
</div>
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-12">
<!-- 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">
<q-btn
v-for="filter in ['all', 'progress', 'completed']"
:key="filter"
@click="activeFilter = filter as any"
flat
rounded
unelevated
:color="activeFilter === filter ? 'primary' : 'white'"
:text-color="activeFilter === filter ? 'white' : 'grey-8'"
class="font-bold px-6"
dense
class="px-5 py-2 font-bold transition-all text-[11px] uppercase tracking-wider"
: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'"
:label="$t(`myCourses.filter${filter.charAt(0).toUpperCase() + filter.slice(1)}`)"
/>
</div>
@ -162,8 +207,8 @@ const validCourseId = computed(() => {
<div v-if="isLoading" class="flex justify-center py-20">
<q-spinner size="3rem" color="primary" />
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<template v-for="course in enrolledCourses" :key="course.id">
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<template v-for="course in filteredEnrolledCourses" :key="course.id">
<!-- In Progress Course Card -->
<CourseCard
v-if="!course.completed"
@ -192,10 +237,18 @@ const validCourseId = computed(() => {
</div>
<!-- Empty State -->
<div v-if="!isLoading && enrolledCourses.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">
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ $t('myCourses.emptyTitle') }}</h3>
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">{{ $t('myCourses.emptyDesc') }}</p>
<NuxtLink to="/browse/discovery" class="mt-6 px-6 py-2 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors">{{ $t('myCourses.goToDiscovery') }}</NuxtLink>
<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">
<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">
{{ searchQuery ? $t('discovery.emptyTitle') : $t('myCourses.emptyTitle') }}
</h3>
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">
{{ searchQuery ? $t('discovery.emptyDesc') : $t('myCourses.emptyDesc') }}
</p>
<NuxtLink v-if="!searchQuery" to="/browse/discovery" class="mt-6 px-6 py-2 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors">{{ $t('myCourses.goToDiscovery') }}</NuxtLink>
<button v-else class="mt-4 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''">
{{ $t('discovery.showAll') }}
</button>
</div>
<!-- MODAL: Enrollment Success -->

View file

@ -15,6 +15,7 @@ const { locale, t } = useI18n()
const isEditing = ref(false)
const activeTab = ref<'general' | 'security'>('general')
const isProfileSaving = ref(false)
const isPasswordSaving = ref(false)
const isSendingVerify = ref(false)
@ -202,23 +203,26 @@ onMounted(async () => {
</script>
<template>
<div class="page-container">
<div class="page-container bg-[#F8F9FA] dark:bg-[#020617] min-h-screen transition-colors duration-300">
<div class="flex items-center justify-between mb-8 md:mb-10">
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-4">
<q-btn
v-if="isHydrated && isEditing"
flat
round
icon="arrow_back"
color="slate-700"
class="dark:text-white"
class="text-slate-600 dark:text-white hover:bg-slate-100 dark:hover:bg-slate-800"
@click="toggleEdit(false)"
/>
<h1 class="text-3xl font-black text-slate-900 dark:text-white">
<div class="flex items-start gap-4">
<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') }}
</h1>
</div>
</div>
</div>
<div class="min-h-9 flex items-center">
<q-btn
@ -226,7 +230,7 @@ onMounted(async () => {
unelevated
rounded
color="primary"
class="font-bold"
class="font-bold shadow-lg shadow-blue-500/20"
icon="edit"
:label="$t('profile.editProfile')"
@click="toggleEdit(true)"
@ -242,44 +246,105 @@ onMounted(async () => {
<q-spinner size="3rem" color="primary" />
</div>
<div v-else>
<div v-if="!isEditing" class="card-premium overflow-hidden fade-in">
<div class="bg-gradient-to-r from-blue-600 to-indigo-600 h-32 w-full"/>
<div class="px-8 pb-10 -mt-16">
<div class="flex flex-col md:flex-row items-end gap-6 mb-10">
<div class="relative flex-shrink-0">
<div v-else class="max-w-4xl mx-auto pb-20">
<!-- VIEW MODE: Premium Card with Banner -->
<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">
<!-- Identity Header (Banner & Avatar) -->
<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
:photo-u-r-l="userData.photoURL"
:first-name="userData.firstName"
:last-name="userData.lastName"
size="128"
class="border-4 border-white dark:border-[#1e293b] shadow-2xl bg-slate-800"
size="140"
class="border-[6px] border-white dark:border-slate-900 shadow-2xl rounded-[2.5rem] bg-white dark:bg-slate-800 transition-colors duration-300"
/>
</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 class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div class="info-group">
<span class="label">{{ $t('profile.phone') }}</span>
<p class="value">{{ userData.phone || '-' }}</p>
<div class="text-center md:text-left pt-4 md:pt-0 flex-grow min-w-0">
<h2 class="text-3xl md:text-4xl font-black text-slate-900 dark:text-white mb-2 leading-tight tracking-tight break-words">
{{ userData.firstName }} {{ userData.lastName }}
</h2>
<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 class="info-group">
<span class="label">{{ $t('profile.joinedAt') }}</span>
<p class="value">{{ formatDate(userData.createdAt) }}</p>
</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>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-8 fade-in">
<!-- Edit Content -->
<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
v-model="userData"
:loading="isProfileSaving"
@ -288,16 +353,18 @@ onMounted(async () => {
@upload="handleFileUpload"
@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
v-model="passwordForm"
:loading="isPasswordSaving"
@submit="handleUpdatePassword"
/>
</div>
</div>
</div>
</div>
</div>
</template>
@ -307,57 +374,7 @@ onMounted(async () => {
color: white;
}
.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;
}
/* Removed card-premium and dark mode overrides as we used utility classes */
.fade-in {
animation: fadeIn 0.4s ease-out forwards;

View file

@ -6,369 +6,380 @@
*/
definePageMeta({
layout: 'landing',
middleware: 'auth' // auth middleware : Login Dashboard
middleware: 'auth'
})
useHead({
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>
<template>
<div class="relative min-h-screen text-slate-600 bg-slate-50 transition-colors">
<!-- Premium Background -->
<div class="fixed inset-0 overflow-hidden pointer-events-none -z-10">
<!-- Animated Glows -->
<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="landing-page bg-white min-h-screen">
<!-- Hero Section -->
<header class="relative pt-32 pb-16 md:pt-40 md:pb-20 overflow-hidden bg-white">
<!-- Decorative Background -->
<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="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>
<!-- Hero Section: วนหวของหนาเว แสดงขอความตอนรบและป CTA -->
<section class="hero-section min-h-[95vh] flex items-center relative overflow-hidden pt-32 pb-20">
<div class="container relative z-10 w-full">
<!-- Hero Card Container -->
<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">
<!-- Decorative background inside card -->
<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="grid-hero items-center relative z-10">
<!-- Left Content -->
<div class="hero-content">
<div class="mb-12 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)] 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 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>
<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" >
เพอชวยใหณกาวสเปาหมายทงไวไดอยางมนใจ
<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>
<!-- Hero Visual Showcase -->
<div class="hero-right flex justify-center md:justify-end items-center slide-up" style="animation-delay: 0.2s;">
<div class="relative w-full max-w-xl">
<!-- Main Illustration -->
<div class="relative z-10 animate-float">
<img
src="/img/elearning.png"
alt="E-Learning Illustration"
class="w-full h-auto drop-shadow-2xl"
/>
</div>
<!-- Decorative shapes behind the image -->
<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-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 -->
<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>
<div class="hero-actions flex flex-wrap gap-6 mb-20 slide-up" style="animation-delay: 0.3s;">
<NuxtLink to="/auth/register" class="btn-cta-premium group">
สมครเรยนฟรนน
<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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</NuxtLink>
<NuxtLink to="/browse" class="btn-outline-glass">
เปดดคอรสทงหมด
</NuxtLink>
<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">
<div v-for="(item, i) in whyChooseUs" :key="i"
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>
<!-- Right Content (Visual) - Redesigned as a Preview/Showcase -->
<div class="hero-visual flex justify-center items-center relative slide-up" style="animation-delay: 0.2s;">
<!-- 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>
<!-- Showcase Content -->
<div class="preview-body p-8 space-y-8">
<!-- Video Hero Preview -->
<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">
<div class="absolute inset-0 flex items-center justify-center">
<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">
<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>
</div>
</div>
<div class="absolute bottom-4 left-6 right-6 h-1.5 bg-white/10 rounded-full overflow-hidden">
<div class="h-full bg-blue-500 w-[65%]"/>
</div>
</div>
<!-- Course List Preview -->
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="p-4 bg-slate-50 rounded-2xl border border-slate-200 ">
<div class="w-full h-20 bg-blue-400/10 rounded-xl mb-3"/>
<div class="h-3 bg-white/10 rounded w-3/4 mb-2"/>
<div class="h-2 bg-white/5 rounded w-1/2"/>
</div>
<div class="p-4 bg-slate-50 rounded-2xl border border-slate-200 ">
<div class="w-full h-20 bg-indigo-400/10 rounded-xl mb-3"/>
<div class="h-3 bg-white/10 rounded w-3/4 mb-2"/>
<div class="h-2 bg-white/5 rounded w-1/2"/>
</div>
</div>
</div>
</div>
</div>
<!-- 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>
<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>
<!-- 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"/>
<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">
<h2 class="text-3xl md:text-4xl font-black text-slate-900 px-4">
เลอกเรยนตามเรองทณสนใจ
</h2>
</div>
<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">
เราไมใชแคแพลตฟอรมการเรยนร แตเราคอคจะชวยพาคณไปสดหมายทองการ
<!-- Horizontal Cards (New Layout - Image 2) -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 px-4">
<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"
@click="goBrowse(card.slug)"
>
<!-- Icon Box -->
<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"
>
<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>
<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>
<!-- 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>
<h3 class="text-2xl font-black mb-5 text-slate-900 ">อการเรยนระดบส</h3>
<p class="text-slate-500 text-base leading-relaxed">โอคณภาพคมช พรอมเอกสารประกอบการเรยนทดสรรมาอยางดเพอความเขาใจงาย</p>
</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>
</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>
<!-- 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>
<!-- 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>
<!-- Courses Carousel -->
<div v-if="isLoading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-10">
<div v-for="i in 4" :key="i" class="bg-white rounded-[3rem] h-[480px] animate-pulse" />
</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 v-else class="relative group/carousel slide-up">
<q-carousel
v-model="currentSlide"
transition-prev="slide-right"
transition-next="slide-left"
swipeable
animated
control-color="primary"
padding
height="auto"
class="bg-transparent rounded-none"
>
<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>
</q-carousel-slide>
</q-carousel>
<!-- Custom Carousel Navigation -->
<button
v-if="courseChunks.length > 1"
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"
@click="currentSlide = Math.max(0, currentSlide - 1)"
:disabled="currentSlide === 0"
:class="{ 'opacity-50 cursor-not-allowed': currentSlide === 0 }"
>
<q-icon name="chevron_left" size="32px" />
</button>
<button
v-if="courseChunks.length > 1"
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"
@click="currentSlide = Math.min(courseChunks.length - 1, currentSlide + 1)"
:disabled="currentSlide === courseChunks.length - 1"
:class="{ 'opacity-50 cursor-not-allowed': currentSlide === courseChunks.length - 1 }"
>
<q-icon name="chevron_right" size="32px" />
</button>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.container {
max-width: 1440px;
margin: 0 auto;
padding: 0 48px;
.landing-page {
font-family: var(--font-main);
}
.hero-title {
font-size: clamp(3.5rem, 7vw, 5.5rem);
font-weight: 900;
color: #0f172a;
}
.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;
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Animations */
@keyframes slide-up {
from { opacity: 0; transform: translateY(50px); }
from { opacity: 0; transform: translateY(40px); }
to { opacity: 1; transform: translateY(0); }
}
.slide-up {
animation: slide-up 1s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
animation: slide-up 0.8s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
opacity: 0;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-25px); }
0%, 100% { transform: translateY(0) rotate(0); }
50% { transform: translateY(-20px) rotate(5deg); }
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
@keyframes pulse-slow {
0%, 100% { opacity: 0.1; transform: scale(1); }
50% { opacity: 0.15; transform: scale(1.15); }
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.animate-pulse-slow {
animation: pulse-slow 10s linear infinite;
.animate-fade-in {
animation: fade-in 1s ease-out forwards;
}
@media (max-width: 1300px) {
.grid-hero {
gap: 4rem;
}
/* Typography Overrides */
h1, h2, h3 {
letter-spacing: normal;
}
@media (max-width: 1024px) {
.grid-hero {
grid-template-columns: 1fr;
text-align: center;
gap: 5rem;
/* Hover effects */
.hero-right:hover .animate-float {
animation-play-state: paused;
}
.hero-content {
display: flex;
flex-direction: column;
align-items: center;
}
.hero-actions {
justify-content: center;
}
.program-stats {
justify-content: center;
}
.stat-divider { display: none; }
.program-stats { gap: 50px; }
.hero-visual {
padding-top: 40px;
/* Responsive Grid Adjustments */
@media (max-width: 1200px) {
.career-cards-grid {
grid-template-columns: repeat(4, 1fr);
}
}
@media (max-width: 768px) {
.container {
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;
.career-cards-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View file

@ -6,7 +6,7 @@
*/
definePageMeta({
layout: 'default'
layout: 'auth'
})
const route = useRoute()
@ -24,7 +24,7 @@ onMounted(async () => {
if (!token) {
isLoading.value = false
isSuccess.value = false
errorMessage.value = t('auth.invalidToken') || 'Token ไม่ถูกต้อง'
errorMessage.value = t('auth.invalidToken')
return
}
@ -38,15 +38,13 @@ onMounted(async () => {
} else {
isSuccess.value = false
if (result.code === 400) {
errorMessage.value = t('profile.emailAlreadyVerified') || 'อีเมลได้รับการยืนยันแล้ว'
// Treat as success visually or show specific message?
// Requirement says "check mark" for done.
// If already verified, maybe show success-like state with "Already Verified" message.
isSuccess.value = true // Let's show checkmark but with specific message
errorMessage.value = t('profile.emailAlreadyVerified')
// If already verified, show success state with specific message
isSuccess.value = true
} else if (result.code === 401) {
errorMessage.value = t('auth.tokenExpired') || 'Token หมดอายุหรือล้มเหลว'
errorMessage.value = t('auth.tokenExpired')
} else {
errorMessage.value = result.error || 'ยืนยันอีเมลไม่สำเร็จ'
errorMessage.value = result.error || t('common.error')
}
}
})
@ -64,21 +62,21 @@ const navigateToHome = () => {
<div v-if="isLoading" class="flex flex-col items-center justify-center py-8">
<q-spinner-dots size="4rem" color="primary" />
<h2 class="mt-6 text-xl font-bold text-slate-900 dark:text-white animate-pulse">
{{ $t('auth.verifyingEmail') || 'กำลังยืนยันอีเมล...' }}
{{ $t('auth.verifyingEmail') }}
</h2>
</div>
<!-- Success State -->
<div v-else-if="isSuccess" class="flex flex-col items-center animate-bounce-in">
<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_circle" class="text-6xl text-green-500" />
<div class="w-24 h-24 rounded-full bg-green-500 flex items-center justify-center mb-10 shadow-lg shadow-green-500/20">
<q-icon name="check" class="text-5xl text-white font-black" />
</div>
<h2 class="text-3xl font-black text-slate-900 dark:text-white mb-2">
{{ errorMessage && errorMessage !== '' ? (errorMessage) : ($t('auth.emailVerified') || 'ยืนยันอีเมลสำเร็จ!') }}
{{ errorMessage && errorMessage !== '' ? (errorMessage) : ($t('auth.emailVerified')) }}
</h2>
<p class="text-slate-500 dark:text-slate-400 mb-8">
{{ $t('auth.emailVerifiedDesc') || 'บัญชีของคุณได้รับการยืนยันเรียบร้อยแล้ว' }}
{{ $t('auth.emailVerifiedDesc') }}
</p>
<q-btn
@ -86,7 +84,7 @@ const navigateToHome = () => {
rounded
color="primary"
class="w-full py-3 font-bold text-lg shadow-lg shadow-blue-500/30"
:label="$t('common.backToHome') || 'กลับสู่หน้าหลัก'"
:label="$t('common.backToHome')"
@click="navigateToHome"
/>
</div>
@ -109,7 +107,7 @@ const navigateToHome = () => {
rounded
color="slate-700"
class="w-full py-3 font-bold text-lg"
label="ลองใหม่อีกครั้ง"
:label="$t('common.tryAgain')"
@click="router.push('/')"
/>
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

View file

@ -0,0 +1,41 @@
/**
* @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

@ -0,0 +1,142 @@
/**
* @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

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

View file

@ -0,0 +1,71 @@
วันที่บันทึกปฏิบัติงาน \*
18/02/2026
องค์ความรู้ที่ได้รับ \*
- การใช้งาน Nuxt 3 Routing (useRoute, useRouter) ในการจัดการ Query Parameters
- การเชื่อมโยง State กับ URL เพื่อสร้าง Deep Linking ให้แชร์ลิงก์หมวดหมู่ได้
- การใช้งาน Vue 3 Composition API (Script Setup) แทน Options API เพื่อความเป็นระเบียบและลดความซับซ้อน
- การตกแต่ง UI ด้วย Tailwind CSS ขั้นสูง (Gradients, Glow Effects, Backdrop Filters) เพื่อให้ได้ธีม Premium/Clean
- การจัดการ Event Listener ใน Vue Component (onMounted, onUnmounted) เพื่อป้องกัน Memory Leak
รายละเอียด \*
- พัฒนาระบบ Filter หมวดหมู่คอร์สเรียน เชื่อมโยงหน้าแรก (Home) กับหน้าค้นหา (Browse)
- ปรับแก้โครงสร้าง Code ในหน้า `index.vue` ให้ใช้ Script Setup ทั้งหมดเพื่อลดความซับซ้อนและแก้ปัญหาการเรียกใช้ตัวแปร
- ปรับดีไซน์ส่วน Call to Action (CTA) ในหน้า `browse/index.vue` และ `browse/recommended.vue` ให้ดูสว่างและทันสมัยขึ้นโดยใช้แสงและเงา (Blue Glow)
ปัญหาและอุปสรรค \*
- พบปัญหาการทำงานร่วมกันระหว่าง Options API และ Script Setup ในไฟล์ `index.vue` ทำให้ฟังก์ชันบางตัวเรียกใช้ไม่ได้
- ระบบ Filter เดิมไม่ทำงานเมื่อกดย้อนกลับ (Back Button) เนื่องจากไม่ได้ Watch การเปลี่ยนแปลงของ URL Query
- การดึงข้อมูลคอร์สเริ่มต้นได้ไม่ครบเนื่องจาก API มีการกำหนด Limit ไว้
รายละเอียด \*
- แก้ไขโดยการยุบรวม Code ทั้งหมดให้เป็นรูปแบบ Script Setup มาตรฐานเดียว
- เพิ่ม `watch(() => route.query.category)` ในหน้า Browse เพื่อให้อัปเดตข้อมูลทุกครั้งที่ URL เปลี่ยน
- เพิ่มพารามิเตอร์ `limit: 1000` ในการเรียก API เพื่อดึงข้อมูลคอร์สทั้งหมดมาแสดง
หลักฐานการปฏิบัติงาน (เฉพาะไฟล์ JPG, JPEG, PNG, PDF.)
- แก้ไขไฟล์ `pages/index.vue` (เพิ่ม goBrowse, ย้าย Logic)
- แก้ไขไฟล์ `pages/browse/index.vue` (เพิ่ม Query Watcher, ปรับ UI)
- แก้ไขไฟล์ `pages/browse/recommended.vue` (ปรับ UI ส่วน CTA)
- แก้ไขไฟล์ `components/layout/LandingHeader.vue` (แก้ Memory Leak)
---
วันที่บันทึกปฏิบัติงาน \*
19/02/2026
องค์ความรู้ที่ได้รับ \*
- การออกแบบ Dashboard Layout แบบ Modern Grid (SkillLane Style/Reference Style)
- การใช้ `q-carousel` (Quasar) ทำ Image Slider แบนเนอร์ประชาสัมพันธ์
- การจัดการแสดงผลข้อมูล Course Card แยกตามสถานะ (Enrolled/Recommended/Free)
- การปรับแต่ง Menu Navigation (`useNavItems`) เพื่อลดความซ้ำซ้อนใน Sidebar
รายละเอียด \*
- ออกแบบและพัฒนาหน้า Dashboard ใหม่ (`pages/dashboard/index.vue`)
1. เพิ่ม Banner Slide ขนาดใหญ่ด้านบน
2. แสดงรายการ "คอร์สที่คุณกำลังเรียน" (In Progress Courses) พร้อม Progress Bar
3. แสดงรายการ "คอร์สแนะนำ" (Recommended Courses)
4. เพิ่มหมวด "คอร์สฟรี" (Free Courses)
- ปรับแต่ง Sidebar Navigation (`composables/useNavItems.ts`) ลบเมนูที่ไม่จำเป็นออก (Online Courses, Recommended, Announcements) ให้เหลือเฉพาะเมนูหลัก
ปัญหาและอุปสรรค \*
- การแสดงผล Carousel มีปุ่ม Arrows เกะกะสายตาบนแบนเนอร์
- ต้องการแยกข้อมูลคอร์สฟรีออกจากคอร์สทั่วไป แต่ API ยังไม่มี Filter โดยตรง
รายละเอียด \*
- แก้ไข Property ของ `q-carousel` โดยลบ `arrows` ออก เพื่อให้เหลือเฉพาะ Navigation Dots ด้านล่าง
- เขียน Logic กรองข้อมูลฝั่ง Client (Client-side filtering) สำหรับคอร์สฟรี โดยตรวจสอบจาก `price === 0` หรือ `is_free: true`
หลักฐานการปฏิบัติงาน (เฉพาะไฟล์ JPG, JPEG, PNG, PDF.)
- แก้ไขไฟล์ `pages/dashboard/index.vue` (New Dashboard UI)
- แก้ไขไฟล์ `composables/useNavItems.ts` (Clean Sidebar)

View file

@ -1,169 +1,138 @@
# 🛠️ Web Development Documentation: e-Learning Platform (Frontend)
# Frontend-Learner (Web) — Technical Documentation
เอกสารฉบับนี้สรุปรายละเอียดทางเทคนิค โครงสร้างโค้ด และการทำงานของระบบ **Frontend-Learner** (อัปเดตล่าสุด: กุมภาพันธ์ 2026)
เอกสารฉบับนี้สรุปรายละเอียดทางเทคนิค โครงสร้างโค้ด และกลไกการทำงานของระบบ **Frontend-Learner (ฝั่งผู้เรียน)**
ใช้เป็นคู่มือสำหรับการพัฒนา บำรุงรักษา และขยายระบบต่อไป
> อัปเดตล่าสุด: ปลายเดือนกุมภาพันธ์ 2026
---
## 🏗️ 1. Technical Foundation (รากฐานทางเทคนิค)
## Table of Contents
รวมข้อมูลเครื่องมือ, ระบบความปลอดภัย และประสิทธิภาพการทำงานไว้ด้วยกัน
- [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
- **Core:** [Nuxt 3](https://nuxt.com) (Vue 3 Composition API), TypeScript `^5.0`
- **UI Framework:** Quasar Framework (via `nuxt-quasar-ui`)
- **Styling:** Tailwind CSS (Utility) + Vanilla CSS Variables (Theming/Dark Mode)
- **State Management:** `ref`/`reactive` (Local) + `useState` (Global/Shared State)
- **Localization:** `@nuxtjs/i18n` (Supports JSON locales in `i18n/locales/`)
- **Media Control:** `useMediaPrefs` (Command Pattern for global volume/mute state)
- **Framework:** Nuxt 3 (Vue 3, Vite, SSR/SPA Hybrid)
- **UI System:** Quasar Framework + Tailwind CSS (Utility-first)
- **Typography:** Google Fonts (**Prompt** เป็น Font หลักเพื่อความทันสมัยและอ่านง่าย)
- **Multilingual:** `@nuxtjs/i18n` (รองรับ JSON-based locales ภาษาไทยและอังกฤษ)
- **Programming:** TypeScript (Strict Type Checking)
### 1.2 Core Systems & Security
### 1.2 Security & Authentication
- **Authentication:**
- ใช้ **JWT** (Access Token 1 วัน, Refresh Token 7 วัน)
- เก็บ Token ใน `useCookie` (Secure, SameSite)
- 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)
- **Token Management:** ใช้ JWT (Access & Refresh Tokens) จัดเก็บผ่าน `useCookie`
โดยตั้งค่าความปลอดภัยระดับ **HTTP-only** และ **SameSite**
- **Middleware:** `auth.ts` ตรวจสอบสิทธิ์การเข้าถึงหน้า Dashboard และ Classroom แบบ Real-time
- **Persistence:** ระบบ Remember Me (จดจำอีเมล) ใช้ `localStorage` แยกส่วนจาก Session
เพื่อความปลอดภัยและสะดวกสำหรับผู้ใช้
---
## 📂 2. Frontend Structure (โครงสร้างหน้าเว็บและ UI)
## 2. Project Architecture
### 2.1 Application Routes (`pages/`)
โครงสร้างโฟลเดอร์ที่จัดระเบียบตามหลัก Clean Architecture เพื่อความคล่องตัวในการขยายระบบ
| 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**) |
### 2.1 Directory Structure
### 2.2 Key Components (`components/`)
- `pages/` : ระบบ Routing ทั้งหมด (Landing, Auth, Dashboard, Classroom)
- `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/` : ตัวกรองความปลอดภัยก่อนเข้าถึงแต่ละหน้า
- **Common (`components/common/`):**
- `GlobalLoader.vue`: Loading indicator ทั่วทั้งแอป
- `LanguageSwitcher.vue`: ปุ่มเปลี่ยนภาษา (TH/EN)
- `AppHeader.vue`, `MobileNav.vue`: Navigation หลัก
- `FormInput.vue`: Input field มาตรฐาน
- **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`: ฟอร์มเปลี่ยนรหัสผ่าน
### 2.2 Shared Infrastructure
- **Types Architecture:** การสกัด Types จาก Composable ออกมาไว้ที่ `@/types`
ช่วยลดความซ้ำซ้อนและป้องกัน Error จากการเปลี่ยนโครงสร้างข้อมูล API
- **Constants System:** การใช้ `@/constants` ช่วยให้การแก้ไขคำโฆษณาหรือข้อมูลหน้าแรกทำได้จากจุดเดียว
โดยไม่ต้องแก้โค้ด HTML
---
## 🧠 3. Logic & Data Layer (Composables)
## 3. Logic & Data Layer (Composables)
รวบรวม Logic หลักแยกส่วนตามหน้าที่ (Separation of Concerns)
การแยก Logic ออกจาก UI เพื่อความสะอาดและ Testable
### 3.1 `useAuth.ts` (Authentication & User)
- `useAuth`
จัดการสถานะ Login, การดึงโปรไฟล์ล่วงหน้า (Pre-fetching), และระบบ Token Refresh
จัดการสถานะผู้ใช้, ล็อกอิน, และความปลอดภัย
- `useCourse`
หัวใจของระบบ จัดการตั้งแต่ Catalog, การสมัครเรียน (Enroll), ไปจนถึงการส่งผลการเรียน (Progress)
- **Key Functions:** `login`, `register`, `fetchUserProfile`, `uploadAvatar`, `sendVerifyEmail`
- **Features:** Refresh Token อัตโนมัติ, ตรวจสอบ Role, **Logout Logic ที่ไม่ลบข้อมูลจดจำผู้ใช้**
- `useThemeMode`
ระบบจัดการธีมกลางที่เชื่อมต่อกับ `localStorage` และ CSS Variables อย่างเป็นระบบ
### 3.2 `useCourse.ts` (Course & Classroom)
- `useQuizRunner`
จัดการสถานะการสอบ เปลี่ยนข้อสอบ และส่งคะแนนไปยัง Backend โดยตรง
หัวใจหลักของการเรียนการสอน
- **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 จริงได้ทันที
- `useNavItems`
Single Source of Truth สำหรับเมนูทั้งหมด (Sidebar, Mobile Drawer, User Menu)
---
## 🎨 4. Design System & Theming
## 4. Branding & UI Policy
มาตรฐานการออกแบบที่เน้นความ Premium และ Consistent
### 4.1 Theme Strategy
- **Framework:** Tailwind CSS + Quasar UI
- **Light/Dark Mode Policy:**
- **Public Pages:** บังคับ **Light Mode** (Landing, Course Detail, Auth) เพื่อภาพลักษณ์แบรนด์ที่สะอาดตา
- **Dashboard/Learning:** รองรับ **Dark Mode** เต็มรูปแบบ (Oceanic Theme)
- **Aesthetics:** ปรับปรุงความชัดเจนของ Badge, Icon และสถานะต่างๆ ในหน้าสอบ (Quiz) สำหรับโหมดมืดโดยเฉพาะ ให้มี Contrast สูงและดู Premium
- **Visual Fixes:** แก้ไขปัญหา "Dark Frame" ในหน้า Auth โดยการบังคับสไตล์ระดับ HTML/Body
- **Public Pages (Landing, Auth, Detail):** บังคับ **Forced Light Mode**
เพื่อภาพลักษณ์แบรนด์ที่สะอาดและน่าเชื่อถือ
- **Internal Pages (Dashboard, Learning):** รองรับ **Dark Mode (Oceanic Theme)**
ลดการเมื่อยล้าของสายตาขณะเรียนเป็นเวลานาน
- **Transitions:** ใช้ GlobalLoader และ Smooth transitions ทั่วทั้งแอปเพื่อประสบการณ์ที่ลื่นไหล
### 4.2 UI Elements
- **Image 2 Style Categories:** การ์ดหมวดหมู่แบบแนวนอนที่เป็นระเบียบ (Minimalist)
- **Glassmorphism:** พื้นผิวโปร่งแสงใน Dashboard และ Classroom ช่วยให้แอปดูมีมิติ
- **Standardized Icons:** ใช้ Material Icons ผ่าน Quasar ระบบเดียวทั้งหมด
---
## 📊 5. Dependency Map (ความสัมพันธ์ไฟล์)
## 5. Core Feature Highlights
| หน้าเว็บ (Page) | Components หลัก | Composables หลัก |
| :----------------------- | :--------------------------- | :----------------------------------------------------- |
| **Login / Register** | `FormInput` | `useAuth` (Remember Me), `useFormValidation` |
| **Discovery (Browse)** | `CourseCard` | `useCourse` (Search/Filter), `useCategory` |
| **My Courses** | `CourseCard` (with Progress) | `useCourse` (Certificates) |
| **Classroom (Learning)** | Video Player, Sidebar | `useCourse` (Progress, Announcements), `useMediaPrefs` |
| **Quiz** | `QuizHeader`, `QuizContent` | `useQuizRunner` (Real API Integration) |
| **Profile** | `UserAvatar`, `FormInput` | `useAuth` (Upload Avatar, Verify Email) |
ฟีเจอร์เด่นที่ถูกพัฒนาขึ้นเพื่อผู้เรียนโดยเฉพาะ
- **SPA Learning Journey:** การสลับบทเรียนในห้องเรียนเป็นแบบ Single Page App (ไม่มีการ Re-load หน้า)
ทำให้การเรียนต่อเนื่อง
- **Hybrid Progress Tracking:** บันทึกเวลาเรียนลง `localStorage` แบบ Real-time และ Sync ขึ้น Server เป็นระยะ
เพื่อป้องกันข้อมูลหาย
- **Announcement System:** ระบบแจ้งเตือนในคอร์สพร้อมตัวระบุ "ยังไม่ได้อ่าน" (Unread Badge)
ที่จำสถานะตามผู้ใช้งาน
- **Interactive Quizzes:** ระบบสอบที่สลับคำถามอัตโนมัติ พร้อมโหมดเฉลย (Answer Review) ที่ชัดเจน
- **Certificate Automation:** ระบบตรวจสอบสิทธิ์ความสำเร็จและออกใบประกาศนียบัตรได้ทันที
---
## ✅ 6. Project Status (สถานะล่าสุด)
## 6. Maintenance & Performance Guidelines
### ✨ Recent Updates (กุมภาพันธ์ 2026)
แนวทางสำหรับการพัฒนาต่อยอด
1. **System-Wide Code Cleanup (Phase Final):**
- **Refactoring:** ปัดกวาดโค้ดในหน้า `learning`, `quiz`, `discovery`, `dashboard` และ `profile`
- **Logging:** ลบ `console.log` มหาศาล และ logic ซ้ำซ้อนที่ตกค้างจากการพัฒนา
- **Structure:** จัดกลุ่มสไตล์และฟังก์ชันให้เป็นระเบียบ อ่านง่ายขึ้นตามมาตรฐาน Clean Code
- **Clean Code:** หลีกเลี่ยงการใช้ `console.log` ในโค้ด Final และลบ Dead Logic ทิ้งทันที
- **Standard Fonts:** ใช้ชุด Font Prompt ผ่านตัวแปร `--font-main` เสมอ
- **API Integrity:** ตรวจสอบข้อมูลผ่าน Interface ใน `@/types` ก่อนการใช้งานทุกครั้ง
- **Mobile First:** ทุก Component ต้องรองรับระบบ Master Drawer บนมือถืออย่างสมบูรณ์
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,7 +18,16 @@
<div v-else-if="lessonDetail" class="p-6 space-y-6">
<!-- Video Player -->
<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
v-else
:src="lessonDetail.video_url"
controls
class="w-full h-full object-contain"
@ -38,7 +47,7 @@
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<a
v-for="file in lessonDetail.attachments"
v-for="file in lessonDetail.attachments.filter(f => !['video/mp4', 'video/youtube'].includes(f.mime_type))"
:key="file.id"
:href="file.file_path"
target="_blank"
@ -74,6 +83,28 @@
</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 -->
<div v-if="lessonDetail.quiz.questions && lessonDetail.quiz.questions.length > 0" class="mt-6 space-y-6">
<!-- ... (questions rendering code unchanged) ... -->
@ -175,6 +206,23 @@ const formatFileSize = (bytes: number) => {
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 () => {
// Always verify lesson and courseId exist
if (!props.lesson || !props.courseId) return;

View file

@ -54,7 +54,16 @@
</NuxtLink>
<NuxtLink
to="/admin/audit-logs"
to="/admin/recommended-courses"
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"
active-class="bg-primary-500 text-white hover:bg-primary-600"
>
@ -82,11 +91,29 @@
</template>
<script setup lang="ts">
import { useQuasar } from 'quasar';
const $q = useQuasar();
const authStore = useAuthStore();
const router = useRouter();
const handleLogout = () => {
$q.dialog({
title: 'ยืนยันออกจากระบบ',
message: 'คุณต้องการออกจากระบบหรือไม่?',
persistent: true,
ok: {
label: 'ออกจากระบบ',
color: 'negative',
flat: false
},
cancel: {
label: 'ยกเลิก',
flat: true
}
}).onOk(() => {
authStore.logout();
router.push('/login');
});
};
</script>

View file

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

View file

@ -35,10 +35,13 @@ export default defineNuxtConfig({
devtools: { enabled: true },
app: {
head: {
title: 'E-Learning System',
title: 'E-Learning-Management',
meta: [
{ charset: 'utf-8' },
{ 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>
<q-select
v-model="cleanupDays"
:options="[30, 60, 90, 180, 365]"
:options="[7, 15, 30, 60, 90, 180, 365]"
label="จำนวนวัน"
suffix="วัน"
outlined
@ -297,7 +297,7 @@ const columns = [
// Actions options (for filtering)
const actionOptionsList = [
'CREATE', 'UPDATE', 'DELETE', 'LOGIN', 'LOGOUT',
'CREATE', 'UPDATE', 'DELETE', 'LOGIN', 'LOGOUT', 'ERROR',
'ENROLL', 'UNENROLL', 'SUBMIT_QUIZ',
'APPROVE_COURSE', 'REJECT_COURSE',
'UPLOAD_FILE', 'DELETE_FILE',
@ -423,7 +423,7 @@ const formatDate = (date: string) => {
const getActionColor = (action: string) => {
if (!action) return 'grey';
if (action.includes('DELETE') || action.includes('REJECT') || action.includes('DEACTIVATE')) return 'negative';
if (action.includes('DELETE') || action.includes('REJECT') || action.includes('DEACTIVATE') || action.includes('ERROR')) return 'negative';
if (action.includes('UPDATE') || action.includes('CHANGE')) return 'warning';
if (action.includes('CREATE') || action.includes('APPROVE') || action.includes('ACTIVATE')) return 'positive';
if (action.includes('LOGIN')) return 'info';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,7 +14,7 @@
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<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-gray-500 text-sm mt-1">หลกสตรทงหมด</div>
@ -31,12 +31,16 @@
<div class="text-3xl font-bold text-gray-500">{{ stats.draft }}</div>
<div class="text-gray-500 text-sm mt-1">แบบราง</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>
<!-- Filter Bar -->
<div class="bg-white rounded-xl shadow-sm p-4 mb-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="md:col-span-2">
<div class="flex gap-4 items-center">
<div class="flex-1">
<q-input
v-model="searchQuery"
placeholder="ค้นหาหลักสูตร..."
@ -58,15 +62,39 @@
dense
emit-value
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>
<!-- Courses Grid -->
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-10">
<q-spinner-dots size="50px" color="primary" />
</div>
<!-- Empty State -->
<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" />
<p class="text-gray-500 text-lg">งไมหลกสตร</p>
@ -78,7 +106,8 @@
/>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Card View -->
<div v-else-if="viewMode === 'card'" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="course in filteredCourses"
:key="course.id"
@ -126,19 +155,10 @@
dense
icon="visibility"
color="grey"
@click="navigateTo(`/instructor/courses/${course.id}`)"
@click="handleViewDetails(course)"
>
<q-tooltip>รายละเอยด</q-tooltip>
</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-btn flat round dense icon="more_vert">
<q-menu>
@ -162,6 +182,165 @@
</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>
</template>
@ -181,6 +360,17 @@ const courses = ref<CourseResponse[]>([]);
const loading = ref(true);
const searchQuery = ref('');
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
const statusOptions = [
@ -188,7 +378,8 @@ const statusOptions = [
{ label: 'เผยแพร่แล้ว', value: 'APPROVED' },
{ label: 'รอตรวจสอบ', value: 'PENDING' },
{ label: 'แบบร่าง', value: 'DRAFT' },
{ label: 'ถูกปฏิเสธ', value: 'REJECTED' }
{ label: 'ถูกปฏิเสธ', value: 'REJECTED' },
//{ label: '', value: 'ARCHIVED' }
];
// Stats
@ -196,10 +387,11 @@ const stats = computed(() => ({
total: courses.value.length,
approved: courses.value.filter(c => c.status === 'APPROVED').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
// Filtered courses (search only, status is handled server-side)
const filteredCourses = computed(() => {
let result = courses.value;
@ -211,10 +403,6 @@ const filteredCourses = computed(() => {
);
}
if (filterStatus.value) {
result = result.filter(course => course.status === filterStatus.value);
}
return result;
});
@ -222,7 +410,7 @@ const filteredCourses = computed(() => {
const fetchCourses = async () => {
loading.value = true;
try {
courses.value = await instructorService.getCourses();
courses.value = await instructorService.getCourses(filterStatus.value || undefined);
} catch (error) {
$q.notify({
type: 'negative',
@ -234,12 +422,18 @@ const fetchCourses = async () => {
}
};
// Re-fetch when status filter changes
watch(filterStatus, () => {
fetchCourses();
});
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
APPROVED: 'green',
PENDING: 'orange',
DRAFT: 'grey',
REJECTED: 'red'
REJECTED: 'red',
ARCHIVED: 'blue-grey'
};
return colors[status] || 'grey';
};
@ -249,7 +443,8 @@ const getStatusLabel = (status: string) => {
APPROVED: 'เผยแพร่แล้ว',
PENDING: 'รอตรวจสอบ',
DRAFT: 'แบบร่าง',
REJECTED: 'ถูกปฏิเสธ'
REJECTED: 'ถูกปฏิเสธ',
ARCHIVED: 'เก็บถาวร'
};
return labels[status] || status;
};
@ -261,15 +456,45 @@ const formatDate = (date: string) => {
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) => {
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({
type: 'info',
message: `กำลังทำสำเนา "${course.title.th}"...`,
type: 'positive',
message: response.message || 'ทำสำเนาหลักสูตรสำเร็จ',
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) => {
$q.dialog({
title: 'ยืนยันการลบ',
@ -296,6 +521,41 @@ 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
onMounted(() => {
fetchCourses();

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

View file

@ -254,13 +254,91 @@ export interface AuditLogStats {
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
const getAuthToken = (): string => {
const tokenCookie = useCookie('token');
return tokenCookie.value || '';
};
// Role interface
export interface RoleResponse {
id: number;
code: string;
}
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[]> {
const config = useRuntimeConfig();
const token = getAuthToken();
@ -517,6 +595,48 @@ export const adminService = {
headers: { Authorization: `Bearer ${token}` },
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;
}
};

View file

@ -208,8 +208,12 @@ const authRequest = async <T>(
};
export const instructorService = {
async getCourses(): Promise<CourseResponse[]> {
const response = await authRequest<CoursesListResponse>('/api/instructors/courses');
async getCourses(status?: string): Promise<CourseResponse[]> {
let url = '/api/instructors/courses';
if (status) {
url += `?status=${status}`;
}
const response = await authRequest<CoursesListResponse>(url);
return response.data;
},
@ -301,6 +305,22 @@ export const instructorService = {
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(
courseId: number,
page: number = 1,