Compare commits

...

16 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
62 changed files with 3339 additions and 1416 deletions

View file

@ -1,11 +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 { ApproveCourseValidator, RejectCourseValidator } from '../validators/AdminCourseApproval.validator';
import { RejectCourseValidator } from '../validators/AdminCourseApproval.validator';
import {
ListPendingCoursesResponse,
GetCourseDetailForAdminResponse,
ApproveCourseBody,
ApproveCourseResponse,
RejectCourseBody,
RejectCourseResponse,
@ -61,19 +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');
// Validate body if provided
if (body) {
const { error } = ApproveCourseValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
}
return await AdminCourseApprovalService.approveCourse(token, courseId, body?.comment);
return await AdminCourseApprovalService.approveCourse(token, courseId, undefined);
}
/**

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

@ -20,10 +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(token);
return await RecommendedCoursesService.listApprovedCourses(token, { search, categoryId });
}
/**

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

@ -113,7 +113,7 @@ export class AdminCourseApprovalService {
/**
* Get course details for admin review
*/
static async getCourseDetail(token: string,courseId: number): Promise<GetCourseDetailForAdminResponse> {
static async getCourseDetail(token: string, courseId: number): Promise<GetCourseDetailForAdminResponse> {
try {
const course = await prisma.course.findUnique({
where: { id: courseId },
@ -133,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: {

View file

@ -340,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({
@ -1249,17 +1262,17 @@ export class CoursesStudentService {
} catch (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)
}
});
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;
}
}

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(token: string): 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;
}));
@ -158,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,
@ -181,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 {

View file

@ -103,18 +103,53 @@ 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: {
select: {
id: true,
title: true,
}
orderBy: { sort_order: 'asc' },
select: { id: true, title: true }
}
}
}
@ -138,12 +173,69 @@ 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) {

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';
@ -212,6 +213,30 @@ export class UserService {
}
}
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;
}
}
/**
* Upload avatar picture to MinIO
*/

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

@ -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,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

@ -21,15 +21,40 @@ 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
@ -38,11 +63,34 @@ const progressPercentage = computed(() => {
props.courseData.chapters.forEach((c: any) => {
c.lessons.forEach((l: any) => {
total++
if (l.is_completed || l.progress?.is_completed) completed++
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>
@ -51,71 +99,111 @@ const progressPercentage = computed(() => {
@update:model-value="(val) => emit('update:modelValue', val)"
show-if-above
bordered
side="left"
:width="280"
side="right"
:width="300"
:breakpoint="1024"
class="bg-slate-50 dark:bg-slate-900 shadow-xl"
content-class="flex flex-col h-full"
>
<div v-if="courseData" class="flex flex-col h-full overflow-hidden">
<!-- Course Progress Header -->
<div class="p-5 border-b border-gray-200 dark:border-white/10 bg-slate-50/50 dark:bg-slate-900/50">
<div class="flex justify-between items-center mb-2">
<!-- 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>
<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-200 dark:bg-slate-800 rounded-full overflow-hidden shadow-inner">
<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 shadow-[0_0_12px_rgba(37,99,235,0.3)]"
class="h-full bg-blue-600 dark:bg-blue-500 rounded-full transition-all duration-700 ease-out"
:style="{ width: `${progressPercentage}%` }"
></div>
</div>
</div>
<div class="flex-grow scroll">
<q-list padding class="py-2">
<!-- 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"
>
<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>
<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-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>
<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/30 dark:text-blue-300 active-lesson-indicator"
class="px-5 py-3 transition-all duration-200 group relative border-b border-gray-100/50 dark:border-white/5"
@click="!lesson.is_locked && emit('select-lesson', lesson.id)"
:disable="lesson.is_locked"
>
<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 font-bold line-clamp-2 transition-colors"
:class="currentLessonId === lesson.id ? 'text-blue-700 dark:text-blue-300' : 'text-slate-700 dark:text-slate-300'"
>
{{ getLocalizedText(lesson.title) }}
</q-item-label>
</q-item-section>
<q-item-section side>
<div class="flex items-center">
<q-icon v-if="lesson.is_completed || lesson.progress?.is_completed" name="check_circle" color="positive" size="18px" />
<q-icon v-else-if="currentLessonId === lesson.id" name="play_arrow" color="primary" size="18px" class="animate-pulse" />
<q-icon v-else-if="lesson.is_locked" name="lock" color="grey-4" size="18px" />
<q-icon v-else name="radio_button_unchecked" color="grey-3" size="18px" />
</div>
</q-item-section>
</q-item>
</template>
</q-list>
</div>
</div>
</div>
</q-expansion-item>
</div>
</q-list>
</div>
</div>
<div v-else-if="isLoading" class="p-6 text-center text-slate-500">
@ -126,31 +214,18 @@ const progressPercentage = computed(() => {
</template>
<style scoped>
.active-lesson-indicator {
position: relative;
}
.active-lesson-indicator::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: #2563eb; /* blue-600 */
border-radius: 0 4px 4px 0;
}
.scroll::-webkit-scrollbar {
/* Custom scrollbar for better aesthetics */
::-webkit-scrollbar {
width: 4px;
}
.scroll::-webkit-scrollbar-track {
::-webkit-scrollbar-track {
background: transparent;
}
.scroll::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.05);
border-radius: 10px;
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
.dark .scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.05);
.dark ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
}
</style>

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,25 +119,27 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</div>
<!-- Action Buttons -->
<!-- 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"
:label="$t('menu.viewDetails')"
:to="`/course/${id}`"
/>
<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-500/10 dark:!text-blue-400 dark:hover:!bg-blue-500/20"
:label="$t('menu.viewDetails')"
:to="`/course/${id}`"
/>
<!-- Continue Learning (Primary Action) -->
<q-btn
v-if="showContinue || (progress && !completed) || (progress === 0 && !completed)"
unelevated
rounded
class="w-full font-bold !bg-blue-600 !text-white hover:!bg-blue-700 shadow-md shadow-blue-500/20 transition-all hover:scale-[1.02]"
:label="(!progress || progress === 0) ? $t('course.startLearning') : $t('course.continueLearning')"
:to="`/classroom/learning?course_id=${id}`"
/>
<!-- Continue Learning (Primary Action) -->
<q-btn
v-if="showContinue || (progress && !completed) || (progress === 0 && !completed)"
unelevated
rounded
class="w-full font-bold !bg-blue-600 !text-white hover:!bg-blue-700 shadow-md shadow-blue-500/20 transition-all hover:scale-[1.02]"
: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

@ -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,7 +55,13 @@ 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>
@ -104,45 +123,69 @@ const handleEnroll = () => {
</div>
</div>
<!-- Curriculum Preview -->
<div class="bg-slate-50 dark:bg-slate-900 rounded-3xl p-6 md:p-8 border border-slate-200 dark:border-white/5">
<div class="flex items-center justify-between mb-8">
<!-- 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>
<h3 class="text-xl font-black text-slate-900 dark:text-white mb-1 flex items-center gap-2">
<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>
<q-icon name="keyboard_command_key" class="text-slate-200 dark:text-slate-800" size="32px" />
</div>
<div class="space-y-4">
<div v-for="(chapter, idx) in course.chapters" :key="chapter.id" class="group">
<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="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" :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_arrow' : 'article'"
size="16px"
/>
</div>
<span class="flex-1 font-bold">{{ getLocalizedText(lesson.title) }}</span>
<span v-if="lesson.duration_minutes" class="text-slate-400 dark:text-slate-500 text-[10px] font-bold">{{ 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" />
<div class="space-y-4">
<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>
</div>
<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>
<!-- 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_arrow' : 'article'"
size="16px"
/>
</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>
</div>
</div>
</div>
</div>
</div>

View file

@ -5,86 +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-white text-slate-900 h-16 px-4 md:px-8">
<!-- Mobile Menu Toggle -->
<q-btn
flat
round
dense
icon="menu"
class="lg:hidden mr-2 text-gray-500"
@click="$emit('toggleSidebar')"
/>
<!-- 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 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">Platform</span>
</div>
</div>
<!-- Desktop Navigation -->
<nav class="hidden lg:flex items-center gap-6 text-sm font-medium text-gray-600">
<div class="cursor-pointer hover:text-purple-600 flex items-center gap-1 transition-colors">
คอรสออนไลน <q-icon name="keyboard_arrow_down" />
<q-menu>
<q-list dense style="min-width: 150px">
<q-item clickable v-close-popup to="/browse">
<q-item-section>งหมด</q-item-section>
</q-item>
</q-list>
</q-menu>
</div>
<div class="cursor-pointer hover:text-purple-600 flex items-center gap-1 transition-colors">
หลกสตร Onsite <q-icon name="keyboard_arrow_down" />
</div>
<NuxtLink to="/browse/recommended" class="hover:text-purple-600 transition-colors">
คอรสแนะนำ
</NuxtLink>
<div class="cursor-pointer hover:text-purple-600 transition-colors">บทความ</div>
<div class="cursor-pointer hover:text-purple-600 transition-colors">สมาชกรายป</div>
<div class="cursor-pointer hover:text-purple-600 transition-colors">สำหรบองคกร</div>
</nav>
<q-space />
<!-- Right Actions -->
<div class="flex items-center gap-2 sm:gap-4 text-gray-500">
<!-- Search Icon -->
<!-- Language -->
<LanguageSwitcher />
<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">
<!-- User Profile -->
<UserMenu />
<!-- 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"
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')"
/>
</div>
</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;
}
.header-mobile {
display: flex !important;
}
}
.search-input :deep(.q-field__control:before) {
border-color: #e2e8f0; /* slate-200 */
@media (min-width: 1024px) {
.header-mobile {
display: none !important;
}
.header-desktop {
display: flex !important;
}
}
.nav-link {
color: #64748b; /* slate-500 */
text-decoration: none;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
position: relative;
height: 100%;
display: flex;
align-items: center;
padding: 0 4px;
}
.nav-link:hover {
color: #2563eb; /* blue-600 */
}
.nav-link.active {
color: #2563eb; /* blue-600 */
}
.nav-link::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
width: 0;
height: 3px;
background-color: #2563eb;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
transition: all 0.3s ease;
transform: translateX(-50%);
}
.nav-link.active::after {
width: 100%;
}
.router-link-active {
color: #2563eb !important;
}
</style>

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
/**
* @file LandingFooter.vue
* @description Footer component for the landing page
* @description Footer component for the landing page - Adjusted to Image 2 (E-Learning Platform Branding)
*/
</script>
@ -23,17 +23,6 @@
<p class="text-slate-500 text-sm leading-relaxed max-w-xs">
แพลตฟอรมการเรยนรออนไลนงเนนการพฒนาทกษะดลสำหรบคนรนใหม เรยนรไดกท กเวลา บผเชยวชาญตวจร
</p>
<div class="flex gap-3">
<a href="#" class="w-9 h-9 rounded-full bg-white border border-slate-200 flex items-center justify-center text-slate-400 hover:bg-blue-600 hover:text-white hover:border-blue-600 transition-all">
<q-icon name="facebook" size="18px" />
</a>
<a href="#" class="w-9 h-9 rounded-full bg-white border border-slate-200 flex items-center justify-center text-slate-400 hover:bg-sky-400 hover:text-white hover:border-sky-400 transition-all">
<q-icon name="flutter_dash" size="18px" />
</a>
<a href="#" class="w-9 h-9 rounded-full bg-white border border-slate-200 flex items-center justify-center text-slate-400 hover:bg-pink-600 hover:text-white hover:border-pink-600 transition-all">
<q-icon name="camera_alt" size="18px" />
</a>
</div>
</div>
<!-- Links -->
@ -58,40 +47,51 @@
</ul>
</div>
<!-- Contact -->
<div>
<h4 class="font-bold text-slate-900 mb-6 text-base">ดตอเรา</h4>
<ul class="space-y-4 text-sm text-slate-500">
<li class="flex items-start gap-3">
<div class="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center shrink-0 text-blue-600 mt-[-2px]">
<q-icon name="location_on" size="18px" />
<!-- 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>
<span class="leading-relaxed">123 อาคารสยามทาวเวอร 15 เขตปทมว กรงเทพฯ 10330</span>
</li>
<li class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center shrink-0 text-blue-600">
<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>
<span>02-123-4567</span>
</li>
<li class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center shrink-0 text-blue-600">
<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>
<span>support@elearning.com</span>
</li>
</ul>
<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>
<div class="pt-8 border-t border-slate-200 flex flex-col md:flex-row justify-between items-center gap-4">
<p class="text-sm text-slate-500 text-center md:text-left">
© {{ new Date().getFullYear() }} E-Learning Platform. All rights reserved.
<!-- 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 class="flex gap-6 text-sm font-medium text-slate-500">
<a href="#" class="hover:text-blue-600 transition-colors">Privacy Policy</a>
<a href="#" class="hover:text-blue-600 transition-colors">Terms of Service</a>
</div>
</div>
</div>
</footer>

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" />
{{ $t('profile.security') }}
</h2>
<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" />
{{ $t('profile.editPersonalDesc') }}
</h2>
<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

@ -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,125 +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
total_lessons?: number
rating?: string
lessons?: number | string
levelType?: 'neutral' | 'warning' | 'success' // ใช้สำหรับการแสดงผล Badge ระดับความยาก (Frontend Logic)
// โครงสร้างบทเรียน (Chapters & Lessons)
chapters?: {
id: number
title: string | { th: string; en: string }
lessons: {
id: number
title: string | { th: string; en: string }
duration_minutes: number
video_url?: string
}[]
}[]
}
interface CourseResponse {
code: number
message: string
data: Course[]
total: number
page?: number
limit?: number
totalPages?: number
}
interface SingleCourseResponse {
code: number
message: string
data: Course
}
// Interface สำหรับคอร์สที่ผู้ใช้ลงทะเบียนเรียนแล้ว (My Course)
export interface EnrolledCourse {
id: number
course_id: number
course: Course
status: 'ENROLLED' | 'IN_PROGRESS' | 'COMPLETED' | 'DROPPED'
progress_percentage: number
enrolled_at: string
started_at?: string
completed_at?: string
last_accessed_at?: string
}
interface EnrolledCourseResponse {
code: number
message: string
data: EnrolledCourse[]
total: number
page: number
limit: number
}
// Interface สำหรับการส่งคำตอบแบบทดสอบ (Quiz Submission)
export interface QuizAnswerSubmission {
question_id: number
choice_id: number
}
export interface QuizSubmitRequest {
answers: QuizAnswerSubmission[]
}
// Interface สำหรับผลลัพธ์การสอบ (Quiz Result)
export interface QuizResult {
answers_review: {
score: number
is_correct: boolean
correct_choice_id: number
selected_choice_id: number
question_id: number
}[]
completed_at: string
started_at: string
attempt_number: number
passing_score: number
is_passed: boolean
correct_answers: number
total_questions: number
total_score: number
score: number
quiz_id: number
attempt_id: number
}
// Interface สำหรับ Certificate
export interface Certificate {
certificate_id: number
course_id: number
course_title: {
en: string
th: string
}
issued_at: string
download_url: string
}
import type {
Course,
CourseResponse,
SingleCourseResponse,
EnrolledCourse,
EnrolledCourseResponse,
QuizAnswerSubmission,
QuizSubmitRequest,
QuizResult,
Certificate
} from '@/types/course'
// ==========================================
// Composable: useCourse
@ -145,6 +34,7 @@ export const useCourse = () => {
category_id?: number;
page?: number;
limit?: number;
search?: string;
random?: boolean;
is_recommended?: boolean;
forceRefresh?: boolean
@ -167,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,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

@ -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",
@ -40,25 +59,35 @@
"studyAgain": "Study Again",
"downloadCertificate": "Download Certificate",
"completed": "Completed",
"includes": "Course includes",
"fullLifetimeAccess": "Full lifetime access",
"accessOnMobile": "Access on mobile and TV",
"lifetimeAccess": "Lifetime access",
"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",
"videoNotSupported": "Your browser does not support the video tag"
"videoNotSupported": "Your browser does not support the video tag",
"aboutCourse": "About Course",
"lessonDetails": "Lesson Details",
"courseStats": {
"level": "Level",
"duration": "Duration",
"lessons": "Lessons",
"students": "Students"
},
"certificatePreview": "Certificate Preview",
"certificateDesc": "Upon completion and passing criteria",
"includes": "This course includes",
"fullLifetimeAccess": "Full lifetime access",
"accessOnMobile": "Access on mobile and tablet",
"buyNow": "Buy Now"
},
"sidebar": {
"overview": "Home",
"myCourses": "My Courses",
"browseCourses": "Browse Courses",
"onlineCourses": "Online Courses",
"onlineCourses": "All Courses",
"recommendedCourses": "Recommended Courses",
"announcements": "Announcements",
"profile": "My Profile"
@ -76,9 +105,16 @@
"showAll": "Show All",
"loadMore": "Load More",
"backToCatalog": "Back to Catalog",
"selectable": "Selected"
"selectable": "Selected",
"foundTotal": "Found Total",
"items": "items",
"subtitle": "Choose to learn new skills from our curated quality courses",
"searchBtn": "Search"
},
"myCourses": {
"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",
@ -109,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",
@ -258,5 +296,16 @@
"statusNotStarted": "Not Started",
"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": "เรียนต่อจากเดิม",
@ -40,26 +59,36 @@
"studyAgain": "ทบทวนบทเรียน",
"downloadCertificate": "ดาวน์โหลดประกาศนียบัตร",
"completed": "เรียนจบเรียบร้อย",
"includes": "สิ่งที่รวมอยู่ในคอร์ส",
"fullLifetimeAccess": "เข้าเรียนได้ตลอดชีพ",
"accessOnMobile": "เรียนได้บนมือถือและแท็บเล็ต",
"lifetimeAccess": "เข้าเรียนได้ตลอดชีพ",
"unlimitedQuizzes": "ทำแบบทดสอบไม่จำกัด",
"satisfactionGuarantee": "รับประกันความพึงพอใจ คืนเงินภายใน 7 วัน",
"noContent": "ยังไม่มีเนื้อหาในขณะนี้",
"buyNow": "ซื้อคอร์สเรียนนี้",
"enrollFree": "ลงทะเบียนเรียนฟรี",
"loginToEnroll": "เข้าสู่ระบบเพื่อลงทะเบียน",
"minutes": "นาที",
"noVideoPreview": "วิดีโอตัวอย่างยังไม่พร้อมใช้งาน",
"videoNotSupported": "เบราว์เซอร์ของคุณไม่รองรับการเล่นวิดีโอ"
"videoNotSupported": "เบราว์เซอร์ของคุณไม่รองรับการเล่นวิดีโอ",
"aboutCourse": "เกี่ยวกับคอร์ส",
"lessonDetails": "รายละเอียดบทเรียน",
"courseStats": {
"level": "ระดับ",
"duration": "ระยะเวลา",
"lessons": "บทเรียน",
"students": "ผู้เรียน"
},
"certificatePreview": "ตัวอย่างใบประกาศนียบัตร",
"certificateDesc": "เมื่อเรียนจบและสอบผ่านตามเกณฑ์ที่กำหนด",
"includes": "สิ่งที่รวมอยู่ในคอร์ส",
"fullLifetimeAccess": "เข้าเรียนได้ตลอดชีพ",
"accessOnMobile": "เรียนได้บนมือถือและแท็บเล็ต",
"buyNow": "ซื้อคอร์สนี้"
},
"sidebar": {
"overview": "หน้าหลัก",
"myCourses": "คอร์สของฉัน",
"browseCourses": "ค้นหาคอร์ส",
"onlineCourses": "คอร์สออนไลน์",
"recommendedCourses": "คอร์สเรียนออนไลน์แนะนำ",
"onlineCourses": "คอร์สเรียนทั้งหมด",
"recommendedCourses": "คอร์สเรียนแนะนำ",
"announcements": "ข่าวประกาศ",
"profile": "บัญชีผู้ใช้"
},
@ -76,9 +105,16 @@
"showAll": "แสดงทั้งหมด",
"loadMore": "โหลดเพิ่มเติม",
"backToCatalog": "กลับหน้ารายการคอร์ส",
"selectable": "รายการที่เลือก"
"selectable": "รายการที่เลือก",
"foundTotal": "พบทั้งหมด",
"items": "รายการ",
"subtitle": "เลือกเรียนรู้ทักษะใหม่ๆ จากหลักสูตรคุณภาพที่คัดสรรมาเพื่อคุณ",
"searchBtn": "ค้นหา"
},
"myCourses": {
"title": "คอร์สของฉัน",
"subtitle": "ติดตามความคืบหน้าและเรียนรู้ต่อจากจุดที่ค้างไว้",
"searchPlaceholder": "ค้นหาชื่อคอร์สของฉัน...",
"filterAll": "ทั้งหมด",
"filterProgress": "กำลังเรียน",
"filterCompleted": "เรียนจบแล้ว",
@ -109,6 +145,8 @@
"email": "อีเมล",
"phone": "เบอร์โทรศัพท์",
"joinedAt": "สมัครสมาชิกเมื่อ",
"generalInfo": "ข้อมูลทั่วไป",
"accountDetails": "รายละเอียดบัญชี",
"editPersonalDesc": "แก้ไขข้อมูลส่วนตัว",
"yourAvatar": "รูปโปรไฟล์ของคุณ",
"avatarHint": "เฉพาะไฟล์ png , jpg",
@ -153,7 +191,7 @@
"logout": "ออกจากระบบ"
},
"landing": {
"allCourses": "คอร์สทั้งหมด",
"allCourses": "คอร์สเรียนทั้งหมด",
"discovery": "ค้นพบ",
"goToDashboard": "เข้าสู่หน้าจัดการเรียน"
},
@ -258,5 +296,16 @@
"statusNotStarted": "ยังไม่ทำ",
"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,23 +8,43 @@
// 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">
<!-- Header -->
<q-header
class="bg-white/80 dark:!bg-[#0f172a]/80 backdrop-blur-md text-slate-900 dark:!text-white"
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
:width="280"
@ -33,6 +53,112 @@ const toggleLeftDrawer = () => {
<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">
@ -40,13 +166,7 @@ const toggleLeftDrawer = () => {
</q-page>
</q-page-container>
<!-- Mobile Bottom Nav -->
<q-footer
v-if="$q.screen.lt.md"
class="!bg-white dark:!bg-[#1e293b] text-primary"
>
<MobileNav />
</q-footer>
</q-layout>
</template>

View file

@ -66,6 +66,7 @@ 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

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>
<!-- 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>
<!-- Divider -->
<div class="my-8 flex items-center gap-4">
<div class="h-px bg-slate-200 flex-1"></div>
<span class="text-slate-400 text-xs font-medium uppercase tracking-wider">หร</span>
<div class="h-px bg-slate-200 flex-1"></div>
</div>
<!-- Register Link -->
<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,29 +33,31 @@ 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;
// If more than 1 category is selected, we still do client-side filtering
// If more than 1 category is selected, we still do client-side filtering
// because the API currently only supports one category_id at a time.
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();
@ -75,17 +76,21 @@ const loadCategories = async () => {
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) {
courses.value = res.data || [];
totalPages.value = res.totalPages || 1;
@ -108,33 +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 || t('enrollment.error'),
position: 'top',
timeout: 3000,
actions: [{ icon: 'close', color: 'white' }]
})
type: "negative",
message: res.error || t("enrollment.error"),
position: "top",
timeout: 3000,
actions: [{ icon: "close", color: "white" }],
});
}
isEnrolling.value = false;
};
// Watch for category selection changes to reload courses
watch(selectedCategoryIds, () => {
currentPage.value = 1;
loadCourses(1);
}, { deep: true });
watch(
selectedCategoryIds,
() => {
currentPage.value = 1;
loadCourses(1);
},
{ deep: true },
);
const toggleCategory = (id: number) => {
const index = selectedCategoryIds.value.indexOf(id)
const index = selectedCategoryIds.value.indexOf(id);
if (index === -1) {
selectedCategoryIds.value.push(id)
selectedCategoryIds.value.push(id);
} else {
selectedCategoryIds.value.splice(index, 1)
selectedCategoryIds.value.splice(index, 1);
}
}
};
onMounted(() => {
loadCategories();
@ -144,122 +153,168 @@ onMounted(() => {
<template>
<div class="page-container">
<!-- CATALOG VIEW: Browse courses -->
<div v-if="!showDetail">
<!-- Top Header Area -->
<div class="flex flex-col gap-6 mb-10">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
<!-- 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>
<!-- Right Side: Search -->
<div class="flex items-center gap-3 w-full md:w-auto">
<q-input
v-model="searchQuery"
dense
outlined
rounded
:placeholder="$t('discovery.searchPlaceholder')"
class="w-full md:w-72 search-input shadow-sm"
bg-color="transparent"
>
<template v-slot:prepend>
<q-icon name="search" class="text-slate-400" />
</template>
</q-input>
</div>
</div>
<!-- Unified Filter Section: Categories -->
<div class="flex flex-wrap items-center gap-2">
<q-btn
flat
rounded
dense
class="px-4 font-bold transition-all text-xs uppercase tracking-widest"
:class="selectedCategoryIds.length === 0 ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' : 'bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-white/5'"
@click="selectedCategoryIds = []"
:label="$t('discovery.showAll')"
/>
<q-btn
v-for="cat in categories"
:key="cat.id"
flat
rounded
dense
class="px-4 font-bold transition-all text-xs uppercase tracking-widest"
:class="selectedCategoryIds.includes(cat.id) ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' : 'bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-white/5'"
@click="toggleCategory(cat.id)"
:label="getLocalizedText(cat.name)"
/>
<p class="text-slate-500 dark:text-slate-400 font-medium mb-8">
{{ $t("discovery.subtitle") }}
</p>
<div class="flex flex-col md:flex-row gap-4">
<!-- 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('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)"
/>
</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
@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 lg:grid-cols-3 xl:grid-cols-4 gap-8">
<CourseCard
v-for="course in filteredCourses"
:key="course.id"
v-bind="{ ...course, image: course.thumbnail_url }"
show-view-details
@view-details="selectCourse(course.id)"
/>
</div>
<!-- Pagination Controls -->
<div v-if="totalPages > 1" class="flex justify-center pb-10">
<q-pagination
v-model="currentPage"
:max="totalPages"
:max-pages="6"
boundary-numbers
direction-links
color="primary"
flat
active-design="unelevated"
active-color="primary"
@update:model-value="loadCourses"
/>
</div>
</div>
<!-- Empty State -->
<div v-if="filteredCourses.length > 0" class="flex flex-col gap-12">
<div
v-else
class="flex flex-col items-center justify-center py-20 bg-white dark:bg-slate-800/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-700 shadow-sm"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8"
>
<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') }}
</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>
<CourseCard
v-for="course in filteredCourses"
:key="course.id"
v-bind="{ ...course, image: course.thumbnail_url }"
show-view-details
@view-details="selectCourse(course.id)"
/>
</div>
</div>
<!-- Pagination Controls -->
<div v-if="totalPages > 1" class="flex justify-center pb-10">
<q-pagination
v-model="currentPage"
:max="totalPages"
:max-pages="6"
boundary-numbers
direction-links
color="primary"
flat
active-design="unelevated"
active-color="primary"
@update:model-value="loadCourses"
/>
</div>
</div>
<!-- Empty State -->
<div
v-else
class="flex flex-col items-center justify-center py-20 bg-white dark:bg-slate-800/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-700 shadow-sm"
>
<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") }}
</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>
</div>
</div>
</div>
<!-- COURSE DETAIL VIEW: Detailed information about a specific course -->
<div v-else>
<button
@click="showDetail = false"
<button
@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">
<q-spinner size="3rem" color="primary" />
<q-spinner size="3rem" color="primary" />
</div>
<CourseDetailView
@ -298,4 +353,3 @@ onMounted(() => {
box-shadow: none !important;
}
</style>

View file

@ -143,7 +143,7 @@ const filteredCourses = computed(() => {
<!-- 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>
คอรสเรยน<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;">
@ -163,30 +163,42 @@ const filteredCourses = computed(() => {
<!-- 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-8">
<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>
<!-- Search Bar (Compact) -->
<div class="relative max-w-md w-full">
<div class="relative group">
<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="ค้นหาบทเรียน..."
>
<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>
<!-- 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 -->

View file

@ -113,7 +113,7 @@ const filteredCourses = computed(() => {
<!-- 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>
คอรสเรยน<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;">
@ -130,27 +130,37 @@ const filteredCourses = computed(() => {
<!-- 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-8">
<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>
<!-- Search Bar (Compact) -->
<div class="relative max-w-md w-full">
<div class="relative group">
<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="ค้นหาคอร์สแนะนำ..."
>
<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>
<!-- 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>

View file

@ -90,6 +90,7 @@ const isPlaying = ref(false)
const videoProgress = ref(0)
const currentTime = ref(0)
const duration = ref(0)
const activeTab = ref('content')
@ -604,60 +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>
<!-- Exit/Back Button -->
<q-btn
flat
rounded
no-caps
color="primary"
class="mr-4 bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white font-bold hover:bg-slate-200"
@click="handleExit('/dashboard/my-courses')"
>
<q-icon name="close" size="18px" class="mr-1.5" />
<span class="hidden sm:inline">{{ $t('common.close') }}</span>
<q-tooltip>{{ $t('classroom.backToDashboard') }}</q-tooltip>
</q-btn>
<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
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>
<!-- Course Title -->
<div class="flex flex-col">
<!-- Sidebar Toggle (Clearer for Course Content) -->
<q-btn
flat
rounded
no-caps
class="mr-2 text-slate-900 dark:text-white hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors font-bold px-3"
@click="toggleSidebar"
>
<q-icon name="format_list_bulleted" size="18px" class="mr-1.5" />
<span class="hidden md:inline">{{ $t('classroom.curriculum') }}</span>
</q-btn>
<q-toolbar-title class="text-base font-bold text-left truncate text-slate-900 dark:text-white">
{{ courseData ? getLocalizedText(courseData.course.title) : $t('classroom.loadingTitle') }}
</q-toolbar-title>
<h1 class="text-base md:text-lg font-bold text-slate-900 dark:text-white truncate max-w-[200px] md:max-w-md leading-tight">
{{ courseData ? getLocalizedText(courseData.course.title) : $t('classroom.loadingTitle') }}
</h1>
</div>
</div>
<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
class="text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
@click="toggleSidebar"
>
<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
icon="campaign"
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"
class="text-slate-600 dark:text-slate-300 hover:text-blue-600 transition-colors"
>
<q-badge v-if="hasUnreadAnnouncements" color="red" floating rounded />
<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"
@ -672,7 +680,7 @@ 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 -->

View file

@ -5,231 +5,398 @@
*/
definePageMeta({
layout: 'default',
middleware: 'auth'
})
layout: "default",
middleware: "auth",
});
useHead({
title: 'Dashboard - FutureSkill Clone'
})
title: "Dashboard - FutureSkill Clone",
});
const { currentUser } = useAuth()
const { fetchCourses, fetchEnrolledCourses, getLocalizedText } = useCourse()
const { fetchCategories } = useCategory()
const { t } = useI18n()
const { currentUser } = useAuth();
const { fetchCourses, fetchEnrolledCourses, getLocalizedText } = useCourse();
const { fetchCategories } = useCategory();
const { t } = useI18n();
// State
const enrolledCourses = ref<any[]>([])
const recommendedCourses = ref<any[]>([])
const libraryCourses = ref<any[]>([])
const categories = ref<any[]>([])
const enrolledCourses = ref<any[]>([]);
const recommendedCourses = ref<any[]>([]);
const libraryCourses = ref<any[]>([]);
const categories = ref<any[]>([]);
const isLoading = ref(true)
const isLoading = ref(true);
// Initial Data Fetch
onMounted(async () => {
isLoading.value = true
isLoading.value = true;
try {
const [catRes, enrollRes, courseRes] = await Promise.all([
fetchCategories(),
fetchEnrolledCourses({ limit: 3, status: 'IN_PROGRESS' }), // Fetch recent enrolled
fetchCourses({ limit: 3, random: true, forceRefresh: true, is_recommended: true }) // Fetch 3 Recommended Courses
])
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) {
categories.value = catRes.data || []
categories.value = catRes.data || [];
}
const catMap = new Map()
categories.value.forEach((c: any) => catMap.set(c.id, c.name))
const catMap = new Map();
categories.value.forEach((c: any) => catMap.set(c.id, c.name));
// Map Enrolled Courses
if (enrollRes.success && enrollRes.data) {
enrolledCourses.value = enrollRes.data.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))
}))
// 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/Library Courses
// Map Recommended Courses
if (courseRes.success && courseRes.data) {
// Use fetched courses for recommended section
recommendedCourses.value = courseRes.data.map((c: any) => ({
id: c.id,
title: c.title,
category: catMap.get(c.category_id),
description: c.description,
lessons: c.total_lessons || 0,
image: c.thumbnail_url || '',
rating: c.rating,
price: c.price,
is_free: c.is_free
}))
// Just for demo, use same data for library if needed or fetch separately
libraryCourses.value = courseRes.data.slice(0, 2)
recommendedCourses.value = courseRes.data.map((c: any) => ({
id: c.id,
title: c.title,
category: catMap.get(c.category_id),
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)
console.error("Failed to load dashboard data", err);
} finally {
isLoading.value = false
isLoading.value = false;
}
})
});
// Helper for "Continue Learning" Hero Card
const heroCourse = computed(() => enrolledCourses.value[0] || null)
const sideCourses = computed(() => enrolledCourses.value.slice(1, 3))
const heroCourse = computed(() => enrolledCourses.value[0] || null);
const sideCourses = computed(() => enrolledCourses.value.slice(1, 3));
</script>
<template>
<div class="bg-[#F8F9FA] min-h-screen font-inter pb-20">
<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>
<div class="max-w-7xl mx-auto px-4 md:px-12 space-y-16 mt-10">
<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>
<!-- 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]">เรยนตอกบคอรสของค</h2>
<NuxtLink to="/dashboard/my-courses" class="text-blue-600 hover:text-blue-700 font-medium text-sm flex items-center gap-1">
คอรสเรยนของฉ <q-icon name="arrow_forward" size="16px" />
</NuxtLink>
</div>
<div class="flex flex-wrap justify-center gap-4 pt-4">
<q-btn
unelevated
rounded
color="primary"
:label="$t('dashboard.goToMyCourses')"
class="px-8 h-[48px] font-bold no-caps shadow-lg shadow-blue-500/10 hover:-translate-y-0.5 transition-all text-sm"
to="/dashboard/my-courses"
/>
<q-btn
outline
rounded
color="primary"
:label="$t('dashboard.searchNewCourses')"
class="px-8 h-[48px] font-bold no-caps hover:bg-white dark:hover:bg-slate-800 transition-all border-1 text-sm dark:text-white dark:border-slate-600"
style="border-width: 1.5px"
to="/browse/discovery"
/>
</div>
</div>
</section>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Hero Card (Left) -->
<div v-if="heroCourse" class="relative group cursor-pointer rounded-2xl overflow-hidden bg-white shadow-sm border border-gray-100 hover:shadow-md transition-all h-[320px]">
<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">
<div class="bg-blue-600 text-white text-xs font-bold px-3 py-1 rounded w-fit mb-3">COURSE</div>
<h3 class="text-white text-2xl font-bold mb-4 line-clamp-2 leading-snug">{{ 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 bg-blue-500 rounded-full" :style="{ width: `${heroCourse.progress}%` }"></div>
</div>
<div class="mt-4 flex justify-end">
<span class="text-white font-bold text-sm hover:underline">{{ heroCourse.progress === 100 ? 'เรียนอีกครั้ง' : 'เรียนต่อ' }}</span>
</div>
</div>
</div>
<!-- 2. Continue Learning Section -->
<section v-if="enrolledCourses.length > 0">
<div class="flex justify-between items-end mb-6">
<h2 class="text-xl md:text-2xl font-bold text-[#2D2D2D] dark:text-white transition-colors">
{{ $t("dashboard.continueLearningTitle") }}
</h2>
<NuxtLink
to="/dashboard/my-courses"
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 font-medium text-sm flex items-center gap-1 transition-colors"
>
{{ $t("dashboard.myCourses") }}
<q-icon name="arrow_forward" size="16px" />
</NuxtLink>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
<!-- Hero Card (Left) -->
<div
v-if="heroCourse"
class="relative group cursor-pointer rounded-2xl overflow-hidden bg-white dark:bg-[#1e293b] shadow-sm border border-gray-100 dark:border-slate-700 hover:shadow-md transition-all h-[260px] md:h-[320px]"
@click="
navigateTo(`/classroom/learning?course_id=${heroCourse.id}`)
"
>
<img
:src="heroCourse.thumbnail_url"
class="w-full h-full object-cover brightness-75 group-hover:brightness-90 transition-all duration-500"
/>
<div
class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent p-8 flex flex-col justify-end"
>
<h3
class="text-white text-2xl font-bold mb-4 line-clamp-2 leading-snug shadow-black/50 drop-shadow-sm"
>
{{ getLocalizedText(heroCourse.title) }}
</h3>
<!-- Progress -->
<div class="w-full">
<div class="flex justify-end text-gray-300 text-xs mb-2">
<span>{{ heroCourse.progress }}%</span>
</div>
<!-- Side List (Right) -->
<div class="flex flex-col gap-4 h-[320px]">
<div v-for="course in sideCourses" :key="course.id" class="flex-1 bg-white rounded-2xl p-4 border border-gray-100 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 font-bold text-sm line-clamp-2 mb-2">{{ getLocalizedText(course.title) }}</h4>
<div class="mt-auto">
<div class="h-1.5 w-full bg-gray-100 rounded-full overflow-hidden mb-2">
<div class="h-full bg-blue-600 rounded-full" :style="{ width: `${course.progress}%` }"></div>
</div>
<div class="flex justify-end items-center text-xs">
<span class="text-blue-600 font-bold cursor-pointer hover:underline" @click="navigateTo(`/classroom/learning?course_id=${course.id}`)">{{ course.progress === 100 ? 'เรียนอีกครั้ง' : 'เรียนต่อ' }}</span>
</div>
</div>
</div>
</div>
<!-- Empty State Placeholder if less than 2 side courses -->
<div v-if="sideCourses.length < 2" class="flex-1 bg-gray-50 rounded-2xl border border-dashed border-gray-200 flex items-center justify-center text-gray-400 text-sm">
เรมเรยนคอรสใหม เพอเตมเตมสวนน
</div>
</div>
</div>
</section>
<!-- 3. Knowledge Library -->
<section>
<div class="mb-6">
<h2 class="text-xl md:text-2xl font-bold text-[#2D2D2D] mb-1">คลงความร</h2>
<p class="text-gray-500 text-sm">ณสามารถเลอกเรยนคอรสเรยนทณเปนเจาของ</p>
</div>
<!-- Content when courses exist -->
<div v-if="libraryCourses.length > 0" class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Course Cards -->
<CourseCard
v-for="course in libraryCourses"
:key="course.id"
v-bind="course"
:image="course.thumbnail_url"
class="h-full md:col-span-1"
/>
<!-- CTA Card (Large) -->
<div class="bg-white rounded-3xl border border-gray-100 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 font-medium mb-6 mt-4">เลอกเรยนคอรสในคลงความรของค</p>
<q-btn
flat
rounded
no-caps
class="text-blue-600 hover:bg-blue-50 px-6 py-2 font-bold group-hover:scale-105 transition-transform"
to="/dashboard/my-courses"
>
งหมด <q-icon name="arrow_forward" size="18px" class="ml-2" />
</q-btn>
</div>
</div>
<!-- Empty State when no courses -->
<div v-else class="bg-white rounded-3xl border border-dashed border-gray-200 p-12 flex flex-col items-center justify-center text-center min-h-[300px]">
<div class="bg-blue-50 p-6 rounded-full mb-6">
<q-icon name="school" size="48px" class="text-blue-200" />
</div>
<h3 class="text-xl font-bold text-gray-800 mb-2">งไมคอรสเรยนในคล</h3>
<p class="text-gray-500 mb-8 max-w-md">เรมเรยนรงใหม นน เลอกดคอรสเรยนทาสนใจเพอพฒนาทกษะของค</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"
<div
class="h-1.5 w-full bg-white/20 rounded-full overflow-hidden"
>
คอรสเรยนทงหมด
</q-btn>
<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>
</section>
</div>
<!-- 5. Recommended Courses -->
<section class="pb-20">
<div class="mb-6">
<h2 class="text-xl md:text-2xl font-bold text-[#2D2D2D] text-left">คอรสแนะนำ</h2>
</div>
<!-- Recommended Grid (3 columns) -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 animate-fade-in">
<CourseCard
v-for="course in recommendedCourses"
:key="course.id"
v-bind="course"
<!-- 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>
<!-- Loading State -->
<div v-if="recommendedCourses.length === 0 && !isLoading" class="flex justify-center py-10 opacity-50">
<div class="text-gray-400">ไมพบขอมลคอรสแนะนำ</div>
</div>
</section>
</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 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 (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"
v-bind="course"
/>
</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>
@ -240,11 +407,17 @@ const sideCourses = computed(() => enrolledCourses.value.slice(1, 3))
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
:deep(.q-btn) {
text-transform: none; /* Prevent uppercase in Q-Btns */
text-transform: none; /* Prevent uppercase in Q-Btns */
}
</style>

View file

@ -11,8 +11,10 @@ definePageMeta({
middleware: 'auth'
})
const { t, locale } = useI18n()
useHead({
title: 'คอร์สของฉัน - e-Learning'
title: `${t('sidebar.myCourses')} - e-Learning`
})
const route = useRoute()
@ -28,7 +30,6 @@ onMounted(() => {
}
})
const { locale } = useI18n()
// Helper to get localized text
const getLocalizedText = (text: string | { th: string; en: string } | undefined) => {
@ -151,33 +152,43 @@ const validCourseId = computed(() => {
<!-- Page Header & Filters (Unified Layout) -->
<div class="flex flex-col gap-6 mb-10">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
<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('sidebar.myCourses') }}
</h1>
<!-- Search Input -->
<div class="flex items-center gap-3 w-full md:w-auto">
<q-input
v-model="searchQuery"
dense
outlined
rounded
:placeholder="$t('discovery.searchPlaceholder')"
class="w-full md:w-72 search-input shadow-sm"
bg-color="transparent"
>
<template v-slot:prepend>
<q-icon name="search" class="text-slate-400" />
</template>
</q-input>
<!-- 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>
<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>
</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="flex flex-wrap items-center gap-2">
<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"
@ -185,8 +196,8 @@ const validCourseId = computed(() => {
flat
rounded
dense
class="px-4 font-bold transition-all text-xs uppercase tracking-widest"
:class="activeFilter === filter ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' : 'bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-white/5'"
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>
@ -196,7 +207,7 @@ 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">
<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
@ -226,7 +237,7 @@ const validCourseId = computed(() => {
</div>
<!-- Empty State -->
<div v-if="!isLoading && filteredEnrolledCourses.length === 0" class="flex flex-col items-center justify-center py-20 bg-slate-50 dark:bg-slate-800/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-700 mt-4">
<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') }}

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,22 +203,25 @@ 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">
{{ (isHydrated && isEditing) ? $t('profile.editProfile') : $t('profile.myProfile') }}
</h1>
<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">
@ -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,61 +246,124 @@ 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>
<div class="info-group">
<span class="label">{{ $t('profile.joinedAt') }}</span>
<p class="value">{{ formatDate(userData.createdAt) }}</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>
</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>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-8 fade-in">
<ProfileEditForm
v-model="userData"
:loading="isProfileSaving"
:verifying="isSendingVerify"
@submit="handleUpdateProfile"
@upload="handleFileUpload"
@verify="handleSendVerifyEmail"
/>
<PasswordChangeForm
v-model="passwordForm"
:loading="isPasswordSaving"
@submit="handleUpdatePassword"
/>
<!-- EDIT MODE: Tabs and Forms (Clean Layout) -->
<div v-else class="fade-in">
<!-- Tab Selector -->
<div class="flex justify-center mb-8">
<div class="bg-white dark:!bg-slate-900/50 p-1.5 rounded-2xl flex items-center gap-1 border border-slate-200 dark:border-white/5 shadow-sm">
<button
@click="activeTab = 'general'"
class="px-6 md:px-8 py-3 rounded-xl font-black text-xs uppercase tracking-widest transition-all flex items-center gap-2"
:class="activeTab === 'general' ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 shadow-sm scale-100' : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 scale-95 opacity-70'"
>
<q-icon name="person_outline" size="18px" /> {{ $t('profile.generalInfo') }}
</button>
<button
@click="activeTab = 'security'"
class="px-6 md:px-8 py-3 rounded-xl font-black text-xs uppercase tracking-widest transition-all flex items-center gap-2"
:class="activeTab === 'security' ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 shadow-sm scale-100' : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 scale-95 opacity-70'"
>
<q-icon name="lock_open" size="18px" /> {{ $t('profile.security') }}
</button>
</div>
</div>
<!-- Edit Content -->
<div 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"
:verifying="isSendingVerify"
@submit="handleUpdateProfile"
@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>
@ -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

@ -13,37 +13,14 @@ 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 stepOneCards = [
{ title: 'AI Foundations', desc: 'เข้าใจพื้นฐาน AI ใช้งานจริงได้ทุกสายงาน', bgClass: 'bg-slate-900', textClass: 'text-white', arrowClass: 'text-white/60 border-white/20 group-hover:text-slate-900', categorySlug: 'programming' },
{ title: 'Data Analyst', desc: 'เรียนจนทำ Dashboard วิเคราะห์ Data ได้เลย', bgClass: 'bg-amber-500', textClass: 'text-slate-900', arrowClass: 'text-slate-900/40 border-slate-900/10 group-hover:text-amber-500', categorySlug: 'business' },
{ title: 'Front-End Web Developer', desc: 'เขียนเว็บสวย ใช้งานได้จริงตั้งแต่หน้าแรก', bgClass: 'bg-orange-500', textClass: 'text-white', arrowClass: 'text-white/60 border-white/20 group-hover:text-orange-500', categorySlug: 'programming' },
{ title: 'UX/UI Designer', desc: 'ต่อยอดทำ Portfolio ไม่มีประสบการณ์ก็เรียนได้', bgClass: 'bg-pink-600', textClass: 'text-white', arrowClass: 'text-white/60 border-white/20 group-hover:text-pink-600', categorySlug: 'design' },
{ title: 'Product Manager', desc: 'เก็บทุกทักษะ ปั้น Product วางแผนแบบมือโปร', bgClass: 'bg-teal-500', textClass: 'text-white', arrowClass: 'text-white/60 border-white/20 group-hover:text-teal-500', categorySlug: 'business' },
{ title: 'Back-End Developer', desc: 'เข้าใจโครงสร้างระบบและฐานข้อมูลหลังบ้าน', bgClass: 'bg-blue-600', textClass: 'text-white', arrowClass: 'text-white/60 border-white/20 group-hover:text-blue-600', categorySlug: 'programming' },
{ title: 'Supply Chain & Logistics', desc: 'ใช้ Data วางแผนโลจิสติกส์ได้อย่างมีประสิทธิภาพ', bgClass: 'bg-slate-700', textClass: 'text-white', arrowClass: 'text-white/60 border-white/20 group-hover:text-slate-700', categorySlug: 'business' }
]
const learningStyles = [
{
title: 'คอร์สออนไลน์', icon: 'desktop_windows', type: 'ONLINE',
subtitle: 'เรียนได้ทุกที่ ทุกเวลา', desc: 'คัดสรรเนื้อหาคุณภาพจากผู้เชี่ยวชาญ\nพร้อมให้คุณเริ่มต้นเรียนรู้ได้ทันที',
time: 'เข้าถึงได้ตลอดชีพ',
features: ['เนื้อหาครบทุกประเด็นสำคัญ', 'โจทย์ตัวอย่างและแบบฝึกหัด', 'เรียนซ้ำได้ไม่จำกัด', 'ใบเซอร์ทิฟิเคตหลังเรียนจบ'],
iconBg: 'bg-blue-50', iconColor: 'text-blue-600', titleClass: 'text-blue-700',
btnClass: 'bg-indigo-900 text-white hover:bg-indigo-800'
}
]
const promoCategories = [
{ title: 'Data', desc: 'เรียนรู้และฝึกฝนกระบวนการคิดสร้างมูลค่าให้ธุรกิจด้วยข้อมูล', icon: 'analytics' },
{ title: 'Design', desc: 'ออกแบบ Digital Product เพื่อให้ผู้ใช้งานได้รับประสบการณ์ที่ดีที่สุด', icon: 'palette' },
{ title: 'Tech', desc: 'พร้อมเป็นที่ต้องการของตลาดแรงงานด้วยทักษะการเขียนโปรแกรม', icon: 'code' },
{ title: 'Business', desc: 'พลิกโฉมธุรกิจในยุคดิจิทัลด้วยการเข้าถึงลูกค้าในช่องทางและเวลาที่เหมาะสม', icon: 'trending_up' }
]
const categoryCards = CATEGORY_CARDS
const whyChooseUs = WHY_CHOOSE_US
const categories = ref<any[]>([])
const topCourses = ref<any[]>([])
@ -173,128 +150,73 @@ onMounted(() => {
</div>
</header>
<section class="pt-16 pb-12 md:pt-24 md:pb-20 bg-white">
<!-- 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-bold text-slate-900 mb-6 px-4">
เพราะ าวแรก ของการพฒนาตวเอง าทายเสมอ
<h2 class="text-3xl md:text-5xl font-black text-slate-900 mb-6">
ทำไมตองเลอกแพลตฟอรมของเรา?
</h2>
<p class="text-slate-500 text-lg md:text-xl font-medium max-w-3xl mx-auto leading-relaxed">
เรางตงใจออกแบบบทเรยนให <span class="text-blue-600 font-bold">เขาใจงาย</span> และ <span class="text-blue-600 font-bold">นำไปใชไดจร</span> เพอใหกกาวของค นคงและไปถงเปาหมายไดสำเร
เราเครองมอและความเชยวชาญทจะชวยใหณประสบความสำเรจในการเปลยนสายอาชพและการสรางทกษะระดบมออาช
</p>
</div>
<!-- Grid Container (Bento Layout) -->
<div class="relative">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div v-for="(card, i) in stepOneCards" :key="i"
class="group cursor-pointer rounded-3xl p-6 flex flex-col justify-between transition-all hover:-translate-y-1 shadow-lg hover:shadow-2xl overflow-hidden relative"
:class="[
card.bgClass,
i === 0 ? 'lg:row-span-2 min-h-[380px]' : 'min-h-[220px]'
]"
@click="goBrowse(card.categorySlug)"
<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"
>
<!-- Background Accent -->
<div class="absolute top-0 right-0 w-32 h-32 bg-white/5 rounded-full -translate-y-1/2 translate-x-1/2" />
<div>
<span class="text-[10px] font-bold uppercase tracking-[0.15em] opacity-80 mb-3 block" :class="card.textClass === 'text-white' ? 'text-white/80' : 'text-slate-900/60'">าวแรกของ</span>
<h3 class="text-2xl font-bold leading-tight tracking-tight mb-2" :class="card.textClass">{{ card.title }}</h3>
</div>
<div class="space-y-4 relative z-10">
<p class="text-sm font-medium leading-relaxed opacity-90" :class="card.textClass">{{ card.desc }}</p>
<div class="flex justify-end">
<div class="w-10 h-10 rounded-full border border-white/20 flex items-center justify-center transition-all bg-white/10 group-hover:bg-white/20 group-hover:scale-105 backdrop-blur-sm"
:class="[i === 0 ? 'w-12 h-12' : '']">
<q-icon name="arrow_forward" :size="i === 0 ? '24px' : '20px'" :class="card.textClass" />
</div>
</div>
</div>
<q-icon :name="item.icon" size="32px" :class="item.iconColor" />
</div>
<h3 class="text-2xl font-black text-slate-900 mb-4 group-hover:text-blue-600 transition-colors">
{{ item.title }}
</h3>
<p class="text-slate-500 text-lg leading-relaxed font-medium">
{{ item.desc }}
</p>
</div>
</div>
</div>
</section>
<!-- Section 2: "Value Proposition" - Why Online Learning here? -->
<section class="pt-12 pb-12 md:pt-20 md:pb-20 bg-white relative overflow-hidden">
<!-- Decorative background blur -->
<div class="absolute top-1/2 left-0 -translate-y-1/2 w-96 h-96 bg-blue-100/50 rounded-full blur-[100px] -z-10" />
<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="grid grid-cols-1 lg:grid-cols-2 gap-20 items-center">
<!-- Left side: Visual representation -->
<div class="relative slide-up">
<div class="relative z-10 bg-gradient-to-br from-blue-600 to-indigo-700 rounded-[4rem] p-12 md:p-20 shadow-3xl overflow-hidden group">
<!-- Animated background shapes -->
<div class="absolute top-0 right-0 w-64 h-64 bg-white/10 rounded-full -translate-y-1/2 translate-x-1/2 group-hover:scale-110 transition-transform duration-1000" />
<div class="absolute bottom-0 left-0 w-48 h-48 bg-amber-400/20 rounded-full translate-y-1/2 -translate-x-1/2" />
<div class="relative z-20 flex flex-col items-center text-center text-white">
<div class="w-24 h-24 md:w-32 md:h-32 bg-white/20 backdrop-blur-md rounded-[2.5rem] flex items-center justify-center mb-8 shadow-inner">
<q-icon name="laptop_mac" size="64px" class="text-white" />
</div>
<h3 class="text-3xl md:text-5xl font-black leading-[1.2] md:leading-[1.15] tracking-tight mb-0 pt-1 overflow-visible">
คอรสออนไลน<br class="hidden md:block" />
ออกแบบมาสำหรบค
</h3>
<p class="mt-5 text-blue-100/90 text-base md:text-lg font-medium leading-relaxed max-w-md">
เรยนรกษะใหมจากผเชยวชาญตวจร พรอมเนอหาทเขมขนและใชงานไดจร
</p>
</div>
</div>
<!-- Floating Stats pill -->
<div class="absolute -bottom-10 -right-6 md:right-10 z-30 bg-white p-6 rounded-3xl shadow-2xl animate-float">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-2xl bg-amber-50 flex items-center justify-center text-amber-600">
<q-icon name="query_builder" size="28px" />
</div>
<div>
<div class="text-xs font-bold text-slate-400 uppercase tracking-tighter">Access Status</div>
<div class="text-xl font-black text-slate-900">เขาถงไดตลอดช</div>
</div>
</div>
</div>
</div>
<div class="mb-12 slide-up">
<h2 class="text-3xl md:text-4xl font-black text-slate-900 px-4">
เลอกเรยนตามเรองทณสนใจ
</h2>
</div>
<!-- Right side: Content & Benefits -->
<div class="slide-up" style="animation-delay: 0.2s;">
<div class="mb-12">
<span class="inline-flex items-center px-5 py-2 rounded-full bg-blue-50 text-blue-600 text-xs md:text-sm font-extrabold uppercase tracking-widest mb-5 border border-blue-100">
Premium Learning Experience
</span>
<h2 class="text-4xl md:text-6xl font-bold text-slate-900 leading-[1.2] md:leading-[1.2] tracking-tight mb-0 pt-1 overflow-visible">
าวขามทกขดจำก<br />
วยการเรยนร <span class="text-blue-600">สระ</span>
</h2>
<p class="mt-6 text-slate-500 text-lg md:text-xl font-medium leading-relaxed max-w-2xl">
เราคดสรรและคราฟตกคอรสเรยนเพอใหนใจวาคณจะไดบประสบการณการเรยนร ไมาจะอยไหนหรอเวลาใดกตาม
<!-- 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-2 lg:grid-cols-4 gap-6 mb-12">
<div v-for="(feature, f) in learningStyles[0].features" :key="f"
class="flex items-center gap-4 p-5 rounded-3xl bg-slate-50 border border-slate-100 group hover:border-blue-200 hover:bg-white hover:shadow-xl transition-all duration-300"
>
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-white flex items-center justify-center text-green-500 shadow-sm group-hover:bg-green-500 group-hover:text-white transition-colors">
<q-icon name="check" size="20px" />
</div>
<span class="text-base font-black text-slate-700 leading-snug">{{ feature }}</span>
</div>
<!-- Arrow -->
<div class="flex-shrink-0 text-slate-300 group-hover:text-blue-600 transition-colors transform group-hover:translate-x-1 duration-300">
<q-icon name="chevron_right" size="24px" />
</div>
<q-btn
unelevated
rounded
color="primary"
label="เริ่มต้นบทเรียนแรกของคุณ"
class="px-12 h-20 font-black text-xl md:text-2xl transition-all shadow-xl hover:shadow-2xl hover:-translate-y-1"
no-caps
:to="user ? '/browse' : '/auth/login'"
/>
</div>
</div>
</div>
@ -302,6 +224,7 @@ onMounted(() => {
<!-- 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">

View file

@ -6,7 +6,7 @@
*/
definePageMeta({
layout: 'default'
layout: 'auth'
})
const route = useRoute()
@ -68,8 +68,8 @@ const navigateToHome = () => {
<!-- 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">

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

@ -33,3 +33,39 @@
- แก้ไขไฟล์ `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,187 +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) (v`^3.11.2`), TypeScript `^5.4.5`
- **UI Framework:** Quasar Framework `^2.15.2` (via `nuxt-quasar-ui ^3.0.0`)
- **Styling:** Tailwind CSS `^6.12.0` (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 หลัก (ใช้ร่วมกับ AppSidebar)
- `FormInput.vue`: Input field มาตรฐาน
- **Layout (`components/layout/`):**
- `AppSidebar.vue`: Sidebar หลักสำหรับ Dashboard (Collapsible)
- `LandingHeader.vue`: Header เฉพาะสำหรับหน้า Landing Page
- **Course (`components/course/`):**
- `CourseCard.vue`: การ์ดแสดงผลคอร์ส รองรับ Progress และ **Glassmorphism** ในโหมดมืด
- **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 และ YouTube Support
- **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
- `fetchCourseAnnouncements`: ดึงข้อมูลประกาศของคอร์ส
- `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. **Classroom & UX Optimization (Mid-February 2026):**
- **SPA Navigation for Learning:** เปลี่ยนระบบเลือกบทเรียนจากการ Reload หน้าเป็น SPA Navigation ทำให้เปลี่ยนวิดีโอ/บทเรียนได้ทันทีโดยไม่ต้อง Refresh หน้าจอ
- **Announcement Persistence:** เพิ่มระบบเช็กสถานะการอ่านประกาศ (Unread Badge) โดยบันทึกสถานะล่าสุดลง LocalStorage แยกตามผู้ใช้และคอร์ส
- **YouTube Resume:** รองรับการเรียนต่อจากจุดเดิมสำหรับวิดีโอ YouTube (Time Seeking via URL parameter)
8. **Quiz System Enhancements:**
- **Answer Review Mode:** เพิ่มโหมดเฉลยข้อสอบหลังทำเสร็จ พร้อมการไฮไลท์สีที่ชัดเจน (เขียว = ถูก, แดง = ตอบผิด)
- **Shuffle Logic:** เพิ่มการสลับคำถามและตัวเลือก (Shuffle) เพื่อความโปร่งใสในการสอบ
- **Enhanced Feedback:** ปรับปรุง UI ผลลัพธ์การสอบให้มีความ Premium และเข้าใจง่ายขึ้น
9. **Security & Registration Polish:**
- **Phone Validation:** เพิ่มระบบตรวจสอบเบอร์โทรศัพท์ในหน้าสมัครสมาชิก (ต้องเป็นตัวเลขและยาวไม่เกิน 10 หลัก)
- **Enrollment Alert Logic:** ปรับปรุง Logic การสมัครเรียนให้ตรวจสอบสถานะ Enrollment เดิมก่อน เพื่อป้องกัน API Error และการเรียก request ซ้ำซ้อน
10. **Profile & Certificates:**
- **Verification Badge:** เพิ่มการแสดงผลสถานะการยืนยันอีเมลในหน้าโปรไฟล์ พร้อมปุ่มส่งอีเมลยืนยันหากยังไม่ได้ทำ
- **Certificate Flow:** ปรับปรุงระบบดาวน์โหลดใบประกาศนียบัตรให้รองรับทั้งการดึงไฟล์เดิมและสั่ง Generate ใหม่หากยังไม่มี
---

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

@ -63,7 +63,7 @@
</NuxtLink>
<NuxtLink
to="/admin/audit-logs"
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"
>
@ -91,11 +91,29 @@
</template>
<script setup lang="ts">
import { useQuasar } from 'quasar';
const $q = useQuasar();
const authStore = useAuthStore();
const router = useRouter();
const handleLogout = () => {
authStore.logout();
router.push('/login');
$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 = () => {
authStore.logout();
router.push('/login');
$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

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,33 +31,48 @@
</div>
</div>
<!-- Search -->
<!-- Search & View Toggle -->
<div class="bg-white rounded-xl shadow-sm p-4 mb-6">
<q-input
v-model="searchQuery"
placeholder="ค้นหาชื่อคอร์ส, ผู้สอน..."
outlined
dense
bg-color="grey-1"
>
<template v-slot:prepend>
<q-icon name="search" />
</template>
<template v-slot:append v-if="searchQuery">
<q-icon name="close" class="cursor-pointer" @click="searchQuery = ''" />
</template>
</q-input>
</div>
<div class="flex gap-4 items-center">
<div class="flex-1">
<q-input
v-model="searchQuery"
placeholder="ค้นหาชื่อคอร์ส, ผู้สอน..."
outlined
dense
bg-color="grey-1"
>
<template v-slot:prepend>
<q-icon name="search" />
</template>
<template v-slot:append v-if="searchQuery">
<q-icon name="close" class="cursor-pointer" @click="searchQuery = ''" />
</template>
</q-input>
</div>
<div class="flex justify-end mb-6">
<q-btn-toggle
<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

@ -104,7 +104,7 @@
<!-- View Details Dialog -->
<q-dialog v-model="showDialog" maximized transition-show="slide-up" transition-hide="slide-down">
<q-card>
<q-bar class="bg-primary text-white">
<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>
@ -153,7 +153,7 @@
<!-- Category -->
<div class="bg-gray-50 p-4 rounded-lg gap-2">
<div class="font-bold mb-2">หมวดหม (Category):</div>
<div class="mb-2">{{ selectedCourse.category.name.th }} ({{ selectedCourse.category.name.en }})</div>
<div class="text-gray-700 mb-2">{{ selectedCourse.category.name.th }} ({{ selectedCourse.category.name.en }})</div>
</div>
<!-- Instructors -->
@ -164,7 +164,7 @@
<q-icon name="person" />
</q-avatar>
<div>
<div class="font-medium">{{ inst.user.username }}</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>
@ -183,6 +183,34 @@
</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 -->
@ -237,7 +265,7 @@ const columns = [
{ 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 },
{ name: 'actions', label: 'Actions', field: 'actions', align: 'center' as const },
];
const fetchCourses = async () => {
@ -248,7 +276,8 @@ const fetchCourses = async () => {
console.error('Error fetching courses:', error);
$q.notify({
type: 'negative',
message: 'ไม่สามารถโหลดข้อมูลคอร์สได้'
message: 'ไม่สามารถโหลดข้อมูลคอร์สได้',
position: 'top'
});
} finally {
loading.value = false;
@ -265,7 +294,8 @@ const viewCourse = async (id: number) => {
console.error('Error fetching course details:', error);
$q.notify({
type: 'negative',
message: 'ไม่สามารถโหลดข้อมูลรายละเอียดคอร์สได้'
message: 'ไม่สามารถโหลดข้อมูลรายละเอียดคอร์สได้',
position: 'top'
});
showDialog.value = false;
} finally {
@ -278,7 +308,8 @@ const handleToggleRecommendation = async (course: RecommendedCourse, isRecommend
await adminService.toggleCourseRecommendation(course.id, isRecommended);
$q.notify({
type: 'positive',
message: isRecommended ? 'เพิ่มคอร์สแนะนำสำร็จ' : 'ยกเลิกคอร์สแนะนำสำเร็จ'
message: isRecommended ? 'เพิ่มคอร์สแนะนำสำร็จ' : 'ยกเลิกคอร์สแนะนำสำเร็จ',
position: 'top'
});
} catch (error) {
console.error('Error toggling recommendation:', error);
@ -286,7 +317,8 @@ const handleToggleRecommendation = async (course: RecommendedCourse, isRecommend
course.is_recommended = !isRecommended;
$q.notify({
type: 'negative',
message: 'เกิดข้อผิดพลาดในการบันทึกข้อมูล'
message: 'เกิดข้อผิดพลาดในการบันทึกข้อมูล',
position: 'top'
});
}
};

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

@ -39,8 +39,8 @@
<!-- 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="ค้นหาหลักสูตร..."
@ -62,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>
@ -82,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"
@ -134,15 +159,6 @@
>
<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>
@ -167,6 +183,94 @@
</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">
@ -196,6 +300,47 @@
</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>
@ -215,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 = [
@ -222,7 +378,8 @@ const statusOptions = [
{ label: 'เผยแพร่แล้ว', value: 'APPROVED' },
{ label: 'รอตรวจสอบ', value: 'PENDING' },
{ label: 'แบบร่าง', value: 'DRAFT' },
{ label: 'ถูกปฏิเสธ', value: 'REJECTED' }
{ label: 'ถูกปฏิเสธ', value: 'REJECTED' },
//{ label: '', value: 'ARCHIVED' }
];
// Stats
@ -234,7 +391,7 @@ const stats = computed(() => ({
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;
@ -246,10 +403,6 @@ const filteredCourses = computed(() => {
);
}
if (filterStatus.value) {
result = result.filter(course => course.status === filterStatus.value);
}
return result;
});
@ -257,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',
@ -269,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';
};
@ -284,7 +443,8 @@ const getStatusLabel = (status: string) => {
APPROVED: 'เผยแพร่แล้ว',
PENDING: 'รอตรวจสอบ',
DRAFT: 'แบบร่าง',
REJECTED: 'ถูกปฏิเสธ'
REJECTED: 'ถูกปฏิเสธ',
ARCHIVED: 'เก็บถาวร'
};
return labels[status] || status;
};
@ -296,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) => {
$q.notify({
type: 'info',
message: `กำลังทำสำเนา "${course.title.th}"...`,
position: 'top'
});
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: '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: 'ยืนยันการลบ',

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

View file

@ -296,6 +296,15 @@ export interface RecommendedCourse {
};
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 {
@ -311,7 +320,25 @@ const getAuthToken = (): string => {
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();

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;
},
@ -305,6 +309,18 @@ export const instructorService = {
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,