Compare commits

...

22 commits

Author SHA1 Message Date
supalerk-ar66
aceeb80d9a feat: Add initial landing page with hero section, features, and category browsing, along with new landing layout components.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 1m52s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 17s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 2s
2026-02-26 15:53:04 +07:00
supalerk-ar66
3a9da1007b feat: Implement initial e-learning platform frontend including landing page, course discovery, dashboard, and foundational UI components with i18n. 2026-02-26 15:20:44 +07:00
supalerk-ar66
5b9cf72046 refactor: rename get_all_users to _get_all_users 2026-02-25 10:15:04 +07:00
Missez
9dc8636d31 feat: Implement admin user and pending course management, instructor course listing, and a dedicated admin service.
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 42s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 4s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 1s
2026-02-24 14:43:06 +07:00
supalerk-ar66
5ad7184e6c feat: Introduce keyboard shortcut to focus chat input and prevent message submission during text composition. 2026-02-24 11:56:21 +07:00
supalerk-ar66
c697a15525 refactor: Extract chat input state management into a custom hook. 2026-02-24 11:49:24 +07:00
supalerk-ar66
8cbef76b1e Please provide the file changes to generate a commit message.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 43s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 3s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 2s
2026-02-24 11:17:33 +07:00
supalerk-ar66
797e3db644 feat: Implement initial core features including course browsing, authentication, user dashboard, and internationalization. 2026-02-24 11:12:26 +07:00
Missez
031ca5c984 feat: Add initial e-learning frontend setup including admin and instructor services, layouts, and pages.
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 46s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 6s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 1s
2026-02-24 09:25:02 +07:00
supalerk-ar66
01d249c19a feat: add initial frontend pages for course browsing, recommendations, and user dashboard.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 38s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 3s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
2026-02-23 17:44:02 +07:00
JakkrapartXD
0588ad7acd feat: Reduce minimum audit log deletion period to 6 days and update enrollment last access only for active enrollments.
All checks were successful
Build and Deploy Backend / Build Backend Docker Image (push) Successful in 26s
Build and Deploy Backend / Deploy E-learning Backend to Dev Server (push) Successful in 3s
Build and Deploy Backend / Notify Deployment Status (push) Successful in 1s
2026-02-23 13:54:03 +07:00
JakkrapartXD
ce2a472cac feat: Update enrollment last accessed timestamp on course content access and correct k6 test comment typo.
All checks were successful
Build and Deploy Backend / Build Backend Docker Image (push) Successful in 27s
Build and Deploy Backend / Deploy E-learning Backend to Dev Server (push) Successful in 4s
Build and Deploy Backend / Notify Deployment Status (push) Successful in 1s
2026-02-23 13:18:38 +07:00
supalerk-ar66
096b5bbc52 feat: Add useCourse composable for course data management and CourseDetailView component for displaying course details. 2026-02-20 16:47:27 +07:00
supalerk-ar66
13ad2097df feat: Implement default authenticated user layout and initial dashboard pages for 'My Courses' and 'Profile'.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 48s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 3s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 2s
2026-02-20 15:18:30 +07:00
JakkrapartXD
45b9c6516b feat: Add user role retrieval, enhance recommended course filtering and detail, and introduce new k6 load tests. 2026-02-20 15:16:43 +07:00
supalerk-ar66
e3873f616e feat: Add initial pages and components for user dashboard, profile, course discovery, and classroom learning with i18n support.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 47s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
2026-02-20 14:58:18 +07:00
Missez
f26a94076c feat: Introduce comprehensive course management features for admin, including recommended, pending, and detailed course views, and instructor course listing with a lesson preview component.
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 46s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 4s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 2s
2026-02-20 14:33:08 +07:00
supalerk-ar66
0f92f0d00c feat: Implement user profile management, course browsing, and dashboard structure with new components and layouts.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 45s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
2026-02-19 17:37:28 +07:00
JakkrapartXD
c118e5c3dc feat: Add k6 video watching load test and remove optional comment body from admin course approval.
All checks were successful
Build and Deploy Backend / Build Backend Docker Image (push) Successful in 28s
Build and Deploy Backend / Deploy E-learning Backend to Dev Server (push) Successful in 3s
Build and Deploy Backend / Notify Deployment Status (push) Successful in 1s
2026-02-19 15:20:34 +07:00
supalerk-ar66
743d3b8c2f feat: introduce LandingHeader component with scroll-adaptive styling and mobile navigation, and a new LandingFooter component.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 41s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 3s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
2026-02-19 13:49:44 +07:00
supalerk-ar66
0f88aeb06f feat: create responsive LandingHeader component with scroll-triggered glass effect and mobile drawer menu. 2026-02-19 13:33:39 +07:00
supalerk-ar66
76b64a30ae feat: Initialize project with core Nuxt configuration, Quasar layouts, global Tailwind CSS, and essential components. 2026-02-19 13:12:14 +07:00
67 changed files with 4311 additions and 2284 deletions

View file

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

View file

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

View file

@ -20,10 +20,14 @@ export class RecommendedCoursesController {
@SuccessResponse('200', 'Approved courses retrieved successfully') @SuccessResponse('200', 'Approved courses retrieved successfully')
@Response('401', 'Unauthorized') @Response('401', 'Unauthorized')
@Response('403', 'Forbidden - Admin only') @Response('403', 'Forbidden - Admin only')
public async listApprovedCourses(@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 ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
return await RecommendedCoursesService.listApprovedCourses(token); return await RecommendedCoursesService.listApprovedCourses(token, { search, categoryId });
} }
/** /**

View file

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

View file

@ -113,7 +113,7 @@ export class AdminCourseApprovalService {
/** /**
* Get course details for admin review * Get course details for admin review
*/ */
static async getCourseDetail(token: string,courseId: number): Promise<GetCourseDetailForAdminResponse> { static async getCourseDetail(token: string, courseId: number): Promise<GetCourseDetailForAdminResponse> {
try { try {
const course = await prisma.course.findUnique({ const course = await prisma.course.findUnique({
where: { id: courseId }, where: { id: courseId },
@ -133,7 +133,11 @@ export class AdminCourseApprovalService {
}, },
chapters: { chapters: {
orderBy: { sort_order: 'asc' }, orderBy: { sort_order: 'asc' },
include: { select: {
id: true,
title: true,
sort_order: true,
is_published: true,
lessons: { lessons: {
orderBy: { sort_order: 'asc' }, orderBy: { sort_order: 'asc' },
select: { select: {

View file

@ -340,6 +340,19 @@ export class CoursesStudentService {
throw new ForbiddenError('You are not enrolled in this course'); throw new ForbiddenError('You are not enrolled in this course');
} }
// Update last_accessed_at (fire-and-forget — ไม่ block response)
if (enrollment.status === 'ENROLLED') {
prisma.enrollment.update({
where: {
unique_enrollment: {
user_id: decoded.id,
course_id,
},
},
data: { last_accessed_at: new Date() },
}).catch(err => logger.warn(`Failed to update last_accessed_at: ${err}`));
}
// Get all lesson progress for this user and course // Get all lesson progress for this user and course
const lessonIds = course.chapters.flatMap(ch => ch.lessons.map(l => l.id)); const lessonIds = course.chapters.flatMap(ch => ch.lessons.map(l => l.id));
const lessonProgress = await prisma.lessonProgress.findMany({ const lessonProgress = await prisma.lessonProgress.findMany({

View file

@ -8,7 +8,8 @@ import {
ListApprovedCoursesResponse, ListApprovedCoursesResponse,
GetCourseByIdResponse, GetCourseByIdResponse,
ToggleRecommendedResponse, ToggleRecommendedResponse,
RecommendedCourseData RecommendedCourseData,
RecommendedCourseDetailData
} from '../types/RecommendedCourses.types'; } from '../types/RecommendedCourses.types';
import { auditService } from './audit.service'; import { auditService } from './audit.service';
import { AuditAction } from '@prisma/client'; import { AuditAction } from '@prisma/client';
@ -18,10 +19,24 @@ export class RecommendedCoursesService {
/** /**
* List all approved courses (for admin to manage recommendations) * 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 { try {
const { search, categoryId } = filters ?? {};
const courses = await prisma.course.findMany({ 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: [ orderBy: [
{ is_recommended: 'desc' }, { is_recommended: 'desc' },
{ updated_at: 'desc' } { updated_at: 'desc' }
@ -40,9 +55,9 @@ export class RecommendedCoursesService {
} }
} }
}, },
chapters: { _count: {
include: { select: {
lessons: true chapters: true
} }
} }
} }
@ -81,8 +96,7 @@ export class RecommendedCoursesService {
is_primary: i.is_primary, is_primary: i.is_primary,
user: i.user user: i.user
})), })),
chapters_count: course.chapters.length, chapters_count: course._count.chapters,
lessons_count: course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0)
} as RecommendedCourseData; } as RecommendedCourseData;
})); }));
@ -158,7 +172,7 @@ export class RecommendedCoursesService {
} }
} }
const data: RecommendedCourseData = { const data: RecommendedCourseDetailData = {
id: course.id, id: course.id,
title: course.title as { th: string; en: string }, title: course.title as { th: string; en: string },
slug: course.slug, slug: course.slug,
@ -181,8 +195,15 @@ export class RecommendedCoursesService {
is_primary: i.is_primary, is_primary: i.is_primary,
user: i.user user: i.user
})), })),
chapters_count: course.chapters.length, chapters: course.chapters.map(ch => ({
lessons_count: course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0) 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 { return {

View file

@ -103,20 +103,55 @@ export class CoursesService {
const course = await prisma.course.findFirst({ const course = await prisma.course.findFirst({
where: { where: {
id, id,
status: 'APPROVED' // Only show approved courses to students status: 'APPROVED'
}, },
include: { include: {
chapters: { creator: {
select: { select: {
id: true, id: true,
title: true, username: true,
lessons: { email: true,
profile: {
select: { select: {
id: true, first_name: true,
title: true, last_name: true,
avatar_url: true
} }
} }
} }
},
instructors: {
include: {
user: {
select: {
id: true,
username: true,
email: true,
profile: {
select: {
first_name: true,
last_name: true,
avatar_url: true
}
}
}
}
}
},
category: {
select: { id: true, name: true }
},
chapters: {
orderBy: { sort_order: 'asc' },
select: {
id: true,
title: true,
sort_order: true,
lessons: {
orderBy: { sort_order: 'asc' },
select: { id: true, title: true }
}
}
} }
} }
}); });
@ -138,12 +173,69 @@ export class CoursesService {
logger.warn(`Failed to generate presigned URL for thumbnail: ${err}`); logger.warn(`Failed to generate presigned URL for thumbnail: ${err}`);
} }
} }
// Generate presigned URL for creator avatar
let creator_avatar_url: string | null = null;
if (course.creator.profile?.avatar_url) {
try {
creator_avatar_url = await getPresignedUrl(course.creator.profile.avatar_url, 3600);
} catch (err) {
logger.warn(`Failed to generate presigned URL for creator avatar: ${err}`);
}
}
// Generate presigned URLs for instructor avatars
const instructorsWithAvatar = await Promise.all(course.instructors.map(async (i) => {
let avatar_url: string | null = null;
if (i.user.profile?.avatar_url) {
try {
avatar_url = await getPresignedUrl(i.user.profile.avatar_url, 3600);
} catch (err) {
logger.warn(`Failed to generate presigned URL for instructor avatar: ${err}`);
}
}
return {
user_id: i.user_id,
is_primary: i.is_primary,
user: {
...i.user,
profile: i.user.profile ? {
...i.user.profile,
avatar_url
} : null
}
};
}));
return { return {
code: 200, code: 200,
message: 'Course fetched successfully', message: 'Course fetched successfully',
data: { data: {
...course, ...course,
title: course.title as { th: string; en: string },
description: course.description as { th: string; en: string },
thumbnail_url: thumbnail_presigned_url, thumbnail_url: thumbnail_presigned_url,
creator: {
...course.creator,
profile: course.creator.profile ? {
...course.creator.profile,
avatar_url: creator_avatar_url
} : null
},
instructors: instructorsWithAvatar,
category: course.category ? {
id: course.category.id,
name: course.category.name as { th: string; en: string }
} : null,
chapters: course.chapters.map(ch => ({
id: ch.id,
title: ch.title as { th: string; en: string },
sort_order: ch.sort_order,
lessons: ch.lessons.map(l => ({
id: l.id,
title: l.title as { th: string; en: string }
}))
}))
}, },
}; };
} catch (error) { } catch (error) {

View file

@ -14,7 +14,8 @@ import {
updateAvatarRequest, updateAvatarRequest,
updateAvatarResponse, updateAvatarResponse,
SendVerifyEmailResponse, SendVerifyEmailResponse,
VerifyEmailResponse VerifyEmailResponse,
rolesResponse
} from '../types/user.types'; } from '../types/user.types';
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler'; import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
@ -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 * Upload avatar picture to MinIO
*/ */

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import { Course } from '@prisma/client'; import { Course } from '@prisma/client';
import { MultiLanguageText } from './index';
export interface ListCoursesInput { export interface ListCoursesInput {
category_id?: number; category_id?: number;
@ -18,8 +19,47 @@ export interface listCourseResponse {
totalPages: number; totalPages: number;
} }
export interface CourseDetail extends Omit<Course, 'title' | 'description'> {
title: MultiLanguageText;
description: MultiLanguageText;
creator: {
id: number;
username: string;
email: string;
profile: {
first_name: string;
last_name: string;
avatar_url: string | null;
} | null;
};
instructors: {
user_id: number;
is_primary: boolean;
user: {
id: number;
username: string;
email: string;
profile: {
first_name: string;
last_name: string;
avatar_url: string | null;
} | null;
};
}[];
category: { id: number; name: MultiLanguageText } | null;
chapters: {
id: number;
title: MultiLanguageText;
sort_order: number;
lessons: {
id: number;
title: MultiLanguageText;
}[];
}[];
}
export interface getCourseResponse { export interface getCourseResponse {
code: number; code: number;
message: string; message: string;
data: 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 { export interface ChangePasswordRequest {
old_password: string; 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: { thresholds: {
http_req_duration: ['p(95)<2000'], // 95% of requests < 2s http_req_duration: ['p(95)<2000'], // 95% of requests < 2s
errors: ['rate<0.1'], // Error rate < 10% errors: ['rate<0.1'], // Error rate < 10%
login_duration: ['p(95)<2000'], // 95% 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> <script setup lang="ts">
// Authentication /**
const { fetchUserProfile, isAuthenticated } = useAuth() * @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(() => { onMounted(() => {
// 1. Login ( Token) Profile // 1. Fetch user profile if tokens exist
if (isAuthenticated.value) { if (isAuthenticated.value) {
fetchUserProfile() fetchUserProfile()
} }
// 2. Theme (Dark/Light) LocalStorage // 2. Initialize theme from persistent storage or system preference
const savedTheme = localStorage.getItem('theme') const savedTheme = localStorage.getItem('theme')
if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) { if (savedTheme) {
document.documentElement.classList.add('dark') setTheme(savedTheme === 'dark')
} else { } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.remove('dark') setTheme(true)
} }
}) })
</script> </script>

View file

@ -27,7 +27,7 @@
/* Typography */ /* Typography */
/* Typography */ /* Typography */
--font-main: --font-main:
"Prompt", "Sarabun", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Prompt", "Inter", "Sarabun", -apple-system, BlinkMacSystemFont, "Segoe UI",
"Roboto", "Helvetica Neue", Arial, sans-serif; "Roboto", "Helvetica Neue", Arial, sans-serif;
/* Layout */ /* Layout */
@ -113,9 +113,9 @@ body {
background-attachment: fixed; background-attachment: fixed;
} }
a { /* a {
text-decoration: none; text-decoration: none;
color: #3b82f6; color: #2563eb;
transition: color 0.2s; transition: color 0.2s;
} }
@ -129,7 +129,7 @@ a:hover {
.dark a:hover { .dark a:hover {
color: #93c5fd; color: #93c5fd;
} } */
ul { ul {
list-style: none; list-style: none;
@ -634,6 +634,7 @@ ul {
} }
.font-bold { .font-bold {
font-weight: 700; font-weight: 700;
letter-spacing: normal;
} }
.w-full { .w-full {
width: 100%; width: 100%;
@ -644,9 +645,9 @@ ul {
.rounded { .rounded {
border-radius: var(--radius-md); border-radius: var(--radius-md);
} }
.border-b { /* .border-b {
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
} } */
.load-more-wrap { .load-more-wrap {
display: flex; display: flex;
justify-content: center; justify-content: center;

View file

@ -21,15 +21,40 @@ const emit = defineEmits<{
const { locale } = useI18n() const { locale } = useI18n()
// State for expansion items
const chapterOpenState = ref<Record<string, boolean>>({})
// Helper for localization // Helper for localization
const getLocalizedText = (text: any) => { const getLocalizedText = (text: any) => {
if (!text) return '' if (!text) return ''
if (typeof text === 'string') return text if (typeof text === 'string') return text
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 || '' return text[currentLocale] || text.th || text.en || ''
} }
// Helper: Check if lesson is completed
const isLessonCompleted = (lesson: any) => {
return lesson.is_completed === true || lesson.progress?.is_completed === true
}
// Reactive Chapter Completion Status
// Computes a map of chapterId -> boolean (true if all lessons are completed)
const chapterCompletionStatus = computed(() => {
const status: Record<string, boolean> = {}
if (!props.courseData || !props.courseData.chapters) return status
props.courseData.chapters.forEach((chapter: any) => {
if (chapter.lessons && chapter.lessons.length > 0) {
status[chapter.id] = chapter.lessons.every((l: any) => isLessonCompleted(l))
} else {
status[chapter.id] = false
}
})
return status
})
// Local Progress Calculation // Local Progress Calculation
const progressPercentage = computed(() => { const progressPercentage = computed(() => {
if (!props.courseData || !props.courseData.chapters) return 0 if (!props.courseData || !props.courseData.chapters) return 0
@ -38,11 +63,34 @@ const progressPercentage = computed(() => {
props.courseData.chapters.forEach((c: any) => { props.courseData.chapters.forEach((c: any) => {
c.lessons.forEach((l: any) => { c.lessons.forEach((l: any) => {
total++ total++
if (l.is_completed || l.progress?.is_completed) completed++ if (isLessonCompleted(l)) completed++
}) })
}) })
return total > 0 ? Math.round((completed / total) * 100) : 0 return total > 0 ? Math.round((completed / total) * 100) : 0
}) })
// Auto-expand chapter containing current lesson
watch(() => props.currentLessonId, (newId) => {
if (newId && props.courseData?.chapters) {
props.courseData.chapters.forEach((chapter: any) => {
const hasLesson = chapter.lessons.some((l: any) => l.id === newId)
if (hasLesson) {
chapterOpenState.value[chapter.id] = true
}
})
}
}, { immediate: true })
// Initialize all chapters as open by default on load
watch(() => props.courseData, (newData) => {
if (newData?.chapters) {
newData.chapters.forEach((chapter: any) => {
if (chapterOpenState.value[chapter.id] === undefined) {
chapterOpenState.value[chapter.id] = true
}
})
}
}, { immediate: true })
</script> </script>
<template> <template>
@ -51,70 +99,110 @@ const progressPercentage = computed(() => {
@update:model-value="(val) => emit('update:modelValue', val)" @update:model-value="(val) => emit('update:modelValue', val)"
show-if-above show-if-above
bordered bordered
side="left" side="right"
:width="280" :width="300"
:breakpoint="1024" :breakpoint="1024"
class="bg-slate-50 dark:bg-slate-900 shadow-xl" class="bg-slate-50 dark:!bg-slate-900 shadow-xl"
content-class="flex flex-col h-full"
> >
<div v-if="courseData" class="flex flex-col h-full overflow-hidden"> <!-- Main Container: Enforce Column Layout and Full Width -->
<!-- Course Progress Header --> <div v-if="courseData" class="flex flex-col w-full h-full overflow-hidden text-slate-900 dark:!text-white relative bg-slate-50 dark:!bg-slate-900">
<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"> <!-- 1. Header Section (Fixed at Top) -->
<span class="text-xs font-black uppercase tracking-widest text-slate-500 dark:text-slate-400">{{ $t('course.progress') }}</span> <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">
<span class="text-sm font-black text-blue-600 dark:text-blue-400">{{ progressPercentage }}%</span> <h2 class="text-sm font-bold mb-4 line-clamp-2 leading-snug block w-full text-slate-900 dark:!text-white">{{ 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>
<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 <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}%` }" :style="{ width: `${progressPercentage}%` }"
></div> ></div>
</div> </div>
</div> </div>
<div class="flex-grow scroll"> <!-- 2. Curriculum List (Scrollable Area) -->
<q-list padding class="py-2"> <div class="flex-1 overflow-y-auto bg-slate-50 dark:!bg-slate-900 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">
<template v-for="chapter in courseData.chapters" :key="chapter.id"> <!-- Chapter Accordion -->
<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"> <q-expansion-item
{{ getLocalizedText(chapter.title) }} v-model="chapterOpenState[chapter.id]"
</q-item-label> class="bg-white dark:!bg-slate-800 rounded-xl overflow-hidden shadow-sm border border-slate-200 dark:border-slate-700 w-full"
header-class="rounded-t-xl w-full text-slate-900 dark:!text-white"
<q-item expand-icon-class="text-slate-400 dark:!text-slate-300"
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"> <template v-slot:header>
<q-icon name="lock" size="xs" color="grey" /> <div class="flex items-center w-full py-3 text-slate-900 dark:!text-white">
</q-item-section> <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-700'">
<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>
<q-item-section> <!-- Lessons List -->
<q-item-label <div class="bg-slate-50 dark:!bg-slate-800/50 border-t border-slate-100 dark:border-slate-700 w-full">
class="text-sm font-bold line-clamp-2 transition-colors" <div
:class="currentLessonId === lesson.id ? 'text-blue-700 dark:text-blue-300' : 'text-slate-700 dark:text-slate-300'" 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-700/50 w-full"
:class="currentLessonId === lesson.id
? 'border-blue-600 bg-blue-50 dark:!bg-blue-900/40'
: '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 dark:!text-slate-500 opacity-70"
size="18px"
/>
<!-- Not Started -->
<div v-else class="w-[18px] h-[18px] rounded-full border-2 border-slate-300 dark:border-slate-600"></div>
</div>
<div class="flex-1 min-w-0 overflow-hidden">
<div class="text-xs font-bold truncate leading-snug block w-full"
:class="currentLessonId === lesson.id ? 'text-blue-700 dark:!text-blue-300' : 'text-slate-600 dark:!text-slate-300'"
> >
{{ getLocalizedText(lesson.title) }} {{ 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> </div>
</q-item-section>
</q-item> </div>
</template> </div>
</div>
</q-expansion-item>
</div>
</q-list> </q-list>
</div> </div>
</div> </div>
@ -126,31 +214,18 @@ const progressPercentage = computed(() => {
</template> </template>
<style scoped> <style scoped>
.active-lesson-indicator { /* Custom scrollbar for better aesthetics */
position: relative; ::-webkit-scrollbar {
}
.active-lesson-indicator::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: #2563eb; /* blue-600 */
border-radius: 0 4px 4px 0;
}
.scroll::-webkit-scrollbar {
width: 4px; width: 4px;
} }
.scroll::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
.scroll::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.05); background: rgba(0, 0, 0, 0.1);
border-radius: 10px; border-radius: 4px;
} }
.dark .scroll::-webkit-scrollbar-thumb { .dark ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.1);
} }
</style> </style>

View file

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

View file

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

View file

@ -1,98 +1,116 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file AppHeader.vue * @file AppHeader.vue
* @description The main header for the authenticated application dashboard. * @description The main header for the EduLearn application dashboard.
* Uses Quasar QToolbar.
*/ */
defineProps<{ const props = defineProps<{
/** Controls visibility of the search bar */ /** Controls visibility of the sidebar toggle button */
showSearch?: boolean showSidebarToggle?: boolean;
}>() }>();
const emit = defineEmits<{ const emit = defineEmits<{
/** Emitted when the hamburger menu is clicked */ /** Emitted when the hamburger menu is clicked */
toggleSidebar: [] toggleSidebar: [];
}>() }>();
const searchText = ref('') const { currentUser } = useAuth();
const { locale, setLocale } = useI18n();
const { isDark, set: setTheme } = useThemeMode();
const toggleLanguage = () => {
setLocale(locale.value === 'th' ? 'en' : 'th');
};
const toggleTheme = () => {
setTheme(!isDark.value);
};
</script> </script>
<template> <template>
<q-toolbar class="bg-white text-slate-900 h-16 px-4 md:px-8"> <q-toolbar class="bg-white dark:!bg-[#020617] text-slate-900 dark:!text-white h-20 border-b border-slate-50 dark:border-slate-800/50 px-6">
<!-- Mobile Menu Toggle -->
<!-- Left: Hamburger Toggle -->
<q-btn <q-btn
flat flat
round round
dense dense
icon="menu" icon="menu"
class="lg:hidden mr-2 text-gray-500" class="text-slate-400 hover:text-blue-600 transition-colors"
size="16px"
@click="$emit('toggleSidebar')" @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 /> <q-space />
<!-- Right Actions --> <!-- Right Section -->
<div class="flex items-center gap-2 sm:gap-4 text-gray-500"> <div class="flex items-center gap-2 sm:gap-4 md:gap-6 no-wrap">
<!-- Search Icon -->
<q-btn flat round dense icon="search" class="hover:text-purple-600 transition-colors" />
<!-- Cart Icon --> <!-- Theme Toggle -->
<q-btn flat round dense icon="shopping_cart" class="hover:text-purple-600 transition-colors" /> <q-btn
flat
<!-- Notifications --> round
<q-btn flat round dense icon="notifications_none" class="hover:text-purple-600 transition-colors"> dense
<q-badge color="red" floating rounded dense v-if="false">2</q-badge> :icon="isDark ? 'dark_mode' : 'light_mode'"
:class="isDark ? 'text-blue-400' : 'text-amber-500'"
class="transition-all active:scale-90"
size="12px"
@click="toggleTheme"
>
<q-tooltip>{{ isDark ? 'โหมดกลางคืน' : 'โหมดกลางวัน' }}</q-tooltip>
</q-btn> </q-btn>
<!-- Language --> <!-- Language Switcher (Pill Style) -->
<LanguageSwitcher /> <div
@click="toggleLanguage"
class="flex items-center bg-slate-50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-800 rounded-xl p-0.5 sm:p-1 cursor-pointer hover:bg-slate-100 transition-all font-bold text-[11px] sm:text-[13px] select-none"
>
<div :class="locale === 'th' ? 'bg-white dark:bg-slate-700 shadow-sm text-blue-600' : 'text-slate-400'" class="px-2 sm:px-3 py-1 rounded-lg transition-all">TH</div>
<div class="w-[1px] h-3 bg-slate-200 dark:bg-slate-700 mx-0.5"></div>
<div :class="locale === 'en' ? 'bg-white dark:bg-slate-700 shadow-sm text-blue-600' : 'text-slate-400'" class="px-2 sm:px-3 py-1 rounded-lg transition-all">EN</div>
</div>
<!-- Divider -->
<div class="hidden sm:block w-[1px] h-8 bg-slate-100 dark:bg-slate-800"></div>
<!-- วนขอมลผใชงาน (User Profile) -->
<div class="flex items-center gap-3 cursor-pointer group" @click="navigateTo('/dashboard/profile')">
<!-- อและบทบาท (แสดงเฉพาะบนจอทใหญกว 600px) -->
<div class="user-info-text flex flex-col items-end text-right">
<span class="text-[15px] font-bold text-slate-900 dark:text-white leading-tight">
{{ currentUser?.firstName || 'User' }} {{ currentUser?.lastName || '' }}
</span>
<span class="text-[11px] text-slate-500 font-medium">{{ $t('common.student') }}</span>
</div>
<!-- ปโปรไฟลพรอมวงแหวน Gradient มนวล -->
<div class="relative p-[3px] rounded-full bg-gradient-to-tr from-[#FFD1D1] via-[#E2E8FF] to-[#D1F7FF] dark:from-slate-800 dark:to-slate-700 transition-transform group-hover:scale-105">
<div class="bg-white dark:bg-[#020617] p-[1.5px] rounded-full shadow-sm">
<UserAvatar
:photoURL="currentUser?.photoURL"
:firstName="currentUser?.firstName"
:lastName="currentUser?.lastName"
size="40"
class="w-[40px] h-[40px]"
/>
</div>
</div>
</div>
<!-- User Profile -->
<UserMenu />
</div> </div>
</q-toolbar> </q-toolbar>
</template> </template>
<style scoped> <style scoped>
.search-input :deep(.q-field__control) { /* Ensure toolbar height is consistent */
border-radius: 9999px; /* Full rounded */ :deep(.q-toolbar) {
min-height: 80px;
} }
.search-input :deep(.q-field__control:before) {
border-color: #e2e8f0; /* slate-200 */ /* Hide user name only on small mobile screens */
@media (max-width: 600px) {
.user-info-text {
display: none !important;
}
} }
</style> </style>

View file

@ -1,79 +1,145 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file AppSidebar.vue * @file AppSidebar.vue
* @description Sidebar navigation for the authenticated dashboard. * @description เมนานขางสำหรบการนำทาง (Sidebar Navigation)
* Uses Quasar QList for structure.
*/ */
const { sidebarItems } = useNavItems() // 1. Composables
import { useQuasar } from 'quasar'
const { t } = useI18n() const { t } = useI18n()
const navItems = sidebarItems const route = useRoute()
const $q = useQuasar()
const { logout } = useAuth()
const { isDark } = useThemeMode()
// 2. (Main Navigation)
const menuItems = computed(() => [
{ to: '/dashboard', icon: 'grid_view', label: t('sidebar.overview') },
{ to: '/dashboard/my-courses', icon: 'book', label: t('sidebar.myCourses') }
])
const handleNavigate = (path: string) => { // 3. (Account Navigation)
if (import.meta.client) { const accountItems = computed(() => [
window.location.href = path { to: '/dashboard/profile', icon: 'settings', label: t('userMenu.settings') }
} ])
// 4. (Logout Function)
const handleLogout = () => {
$q.dialog({
title: t('auth.logoutConfirmTitle'),
message: t('auth.logoutConfirmMessage'),
cancel: {
flat: true,
color: isDark.value ? 'grey-4' : 'grey-7',
label: t('common.cancel')
},
ok: {
flat: false,
color: 'red-500',
label: t('auth.logout'),
unelevated: true
},
dark: isDark.value,
class: 'p-4 rounded-2xl text-slate-900 dark:text-white',
persistent: true
}).onOk(async () => {
await logout()
})
} }
</script> </script>
<template> <template>
<div class="flex flex-col h-full bg-transparent border-r border-slate-200 dark:border-slate-800"> <div class="flex flex-col h-full bg-white dark:!bg-[#04091a] px-4 py-6 border-r border-slate-100 dark:border-slate-800">
<!-- Logo Section -->
<div class="flex items-center gap-3 px-2 mb-10 transition-transform active:scale-95 cursor-pointer" @click="navigateTo('/dashboard')">
<div class="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20">
<q-icon name="school" color="white" size="24px" />
</div>
<span class="text-[22px] font-black tracking-tight text-slate-800 dark:text-white">EduLearn</span>
</div>
<!-- Navigation Items --> <!-- Main Navigation -->
<q-list padding class="text-slate-600 dark:text-slate-400 flex-grow px-3 pt-6"> <div class="space-y-1 mb-8">
<q-item <NuxtLink
v-for="item in navItems" v-for="item in menuItems"
:key="item.to" :key="item.to"
clickable :to="item.to"
v-ripple class="flex items-center gap-4 px-4 py-3 rounded-2xl transition-all group relative nav-item"
@click="handleNavigate(item.to)" :class="route.path === item.to ? 'active' : 'text-slate-500 hover:bg-slate-50 dark:hover:bg-slate-800 hover:text-slate-900 dark:hover:text-slate-200'"
class="rounded-xl mb-1 text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-white/5"
:class="{ 'sidebar-item--active': $route.path === item.to || ($route.path === '/dashboard' && item.to === '/dashboard') }"
> >
<q-item-section avatar> <q-icon :name="item.icon" size="24px" class="transition-colors" />
<q-icon :name="item.icon" size="22px" /> <span class="font-bold text-[15px]">{{ item.label }}</span>
</q-item-section>
<q-item-section> <!-- Active Indicator -->
<q-item-label class="font-bold text-sm">{{ $t(item.labelKey) }}</q-item-label> <div v-if="route.path === item.to" class="absolute left-0 top-1/2 -translate-y-1/2 w-1.5 h-6 bg-blue-600 rounded-r-full shadow-[2px_0_8px_rgba(37,99,235,0.4)]"></div>
</q-item-section> </NuxtLink>
</q-item> </div>
</q-list>
<!-- Account Section -->
<div class="px-4 mb-4">
<span class="text-[12px] font-bold text-slate-400 uppercase tracking-widest">{{ $t('sidebar.accountGroup') }}</span>
</div>
<div class="space-y-1">
<NuxtLink
v-for="item in accountItems"
:key="item.to"
:to="item.to"
class="flex items-center gap-4 px-4 py-3 rounded-2xl transition-all group nav-item"
:class="route.path === item.to ? 'active' : 'text-slate-500 hover:bg-slate-50 dark:hover:bg-slate-800 hover:text-slate-900 dark:hover:text-slate-200'"
>
<q-icon :name="item.icon" size="24px" />
<span class="font-bold text-[15px]">{{ item.label }}</span>
</NuxtLink>
<!-- Logout Button -->
<button
@click="handleLogout"
class="w-full flex items-center gap-4 px-4 py-3 rounded-2xl transition-all text-red-500 hover:bg-red-50 dark:hover:bg-red-900/10 font-bold text-[15px] group"
>
<q-icon name="logout" size="24px" class="group-hover:translate-x-1 transition-transform" />
<span>{{ $t('userMenu.logout') }}</span>
</button>
</div>
<q-space />
<!-- Promo Card -->
<div class="mt-auto p-5 rounded-[2rem] bg-slate-50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-800 relative overflow-hidden group">
<div class="relative z-10">
<h4 class="font-black text-slate-800 dark:text-white text-sm mb-1">{{ $t('sidebar.promoTitle') }}</h4>
<p class="text-[11px] text-slate-500 dark:text-slate-400 mb-4">{{ $t('sidebar.promoSubtitle') }}</p>
<q-btn
unelevated
class="full-width rounded-xl bg-blue-600 hover:bg-blue-700 text-white font-bold py-2.5 no-caps transition-all active:scale-95 text-xs shadow-md shadow-blue-500/20"
@click="navigateTo('/browse/discovery')"
>
{{ $t('sidebar.learnMore') }}
</q-btn>
</div>
<!-- Subtle background decoration -->
<div class="absolute -right-2 -bottom-2 w-16 h-16 bg-blue-500/5 rounded-full blur-xl"></div>
</div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.sidebar-item--active { .nav-item.active {
background: #eff6ff !important; /* blue-50 */ background: #EFF6FF;
color: #1d4ed8 !important; /* blue-700 */ color: #2563EB;
position: relative;
} }
.sidebar-item--active::before { .dark .nav-item.active {
content: ''; background: rgba(37,99,235,0.1);
position: absolute; color: #60A5FA;
left: 0;
top: 15%;
height: 70%;
width: 4px;
background: #2563eb;
border-radius: 0 4px 4px 0;
} }
/* Dark Mode Active State Enhancement */ .nav-item.active .q-icon {
.dark .sidebar-item--active { color: #2563EB;
background: rgba(59, 130, 246, 0.12) !important;
color: #60a5fa !important; /* blue-400 */
} }
.dark .sidebar-item--active .q-icon { .dark .nav-item.active .q-icon {
color: #60a5fa !important; /* blue-400 */ color: #60A5FA;
}
.dark .sidebar-item--active::before {
background: #3b82f6;
} }
</style> </style>

View file

@ -0,0 +1,95 @@
<script setup lang="ts">
/**
* @file LandingFooter.vue
* @description Footer component for the landing page - Adjusted to Image 2 (E-Learning Platform Branding)
*/
</script>
<template>
<footer class="bg-white pt-16 pb-8 border-t border-slate-200">
<div class="container mx-auto px-6 md:px-12">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8 mb-12 text-left">
<!-- Brand -->
<div class="space-y-6">
<NuxtLink to="/" class="flex items-center gap-3 group">
<div class="bg-blue-600 text-white font-black rounded-full px-6 w-10 h-10 flex items-center justify-center group-hover:scale-110 transition-transform">
<q-icon name="o_school" size="24px" />
</div>
<div class="flex flex-col">
<span class="font-bold text-lg leading-none tracking-tight transition-colors text-slate-900">
EduLearn
</span>
<span class="text-[10px] text-slate-500 font-semibold uppercase tracking-[0.4em] leading-none mt-0.5 transition-colors">
Platform
</span>
</div>
</NuxtLink>
<p class="text-slate-500 text-sm leading-relaxed max-w-xs">
แพลตฟอรมการเรยนรออนไลนงเนนการพฒนาทกษะดลสำหรบคนรนใหม เรยนรไดกท กเวลา บผเชยวชาญตวจร
</p>
</div>
<!-- Links -->
<div class="lg:pl-8">
<h4 class="font-bold text-slate-900 mb-6 text-base tracking-tight">คอรสเรยน</h4>
<ul class="space-y-3 text-sm text-slate-500 flex flex-col gap-2">
<li class=""><NuxtLink to="/browse" class="hover:text-blue-600 transition-colors inline-block">คอรสทงหมด</NuxtLink></li>
<li><a href="#" class="hover:text-blue-600 transition-colors inline-block">โปรโมช</a></li>
<li><a href="#" class="hover:text-blue-600 transition-colors inline-block">สำหรบองคกร</a></li>
</ul>
</div>
<!-- Support -->
<div>
<h4 class="font-bold text-slate-900 mb-6 text-base">วยเหล</h4>
<ul class="space-y-3 text-sm text-slate-500 flex flex-col gap-2">
<li><a href="#" class="hover:text-blue-600 transition-colors inline-block">คำถามทพบบอย (FAQ)</a></li>
<li><a href="#" class="hover:text-blue-600 transition-colors inline-block">การใชงาน</a></li>
<li><a href="#" class="hover:text-blue-600 transition-colors inline-block">เงอนไขการใชงาน</a></li>
<li><a href="#" class="hover:text-blue-600 transition-colors inline-block">นโยบายความเปนสวนต</a></li>
</ul>
</div>
<!-- Contact (Bronco Hourse Data) -->
<div class="space-y-6">
<h4 class="font-bold text-slate-900 text-base">ดตอเรา</h4>
<div class="flex flex-col gap-5">
<!-- Location -->
<div class="flex flex-row items-start gap-4 flex-nowrap">
<q-icon name="o_location_on" size="20px" color="slate-800" />
<div class="flex flex-col gap-1 min-w-0">
<span class="font-semibold text-slate-800 text-sm leading-tight">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">
<q-icon name="o_phone" size="18px" color="slate-800" />
<a href="tel:052-076-025" class="font-semibold text-slate-800 text-sm 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">
<q-icon name="o_email" size="18px" color="slate-800" />
<a href="mailto:info@chamomind.com" class="font-semibold text-slate-800 text-sm hover:text-blue-600 font-semibold text-sm transition-colors truncate">
info@chamomind.com
</a>
</div>
</div>
</div>
</div>
<!-- Bottom Bar (Centered Copyright) -->
<div class="pt-8 border-t border-slate-200 text-center">
<p class="text-sm text-slate-400 font-medium tracking-wide">
Copyright © CHAMOMIND CO., LTD. 2023
</p>
</div>
</div>
</footer>
</template>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,43 @@
/**
* @file landing.ts
* @description Static data for the landing page.
*/
export const CATEGORY_CARDS = [
{
title: 'โปรแกรมมิ่ง',
desc: 'เชี่ยวชาญการเขียนโค้ดและพัฒนาซอฟต์แวร์',
icon: 'o_code',
slug: 'programming',
},
{
title: 'การออกแบบ',
desc: 'ทักษะ UI/UX และการออกแบบระดับมือโปร',
icon: 'o_palette',
slug: 'design',
},
{
title: 'ธุรกิจ',
desc: 'ทักษะการจัดการและความเป็นผู้นำสากล',
icon: 'o_business_center',
slug: 'business',
}
]
export const WHY_CHOOSE_US = [
{
title: 'ผู้สอนเชี่ยวชาญ',
desc: 'เรียนรู้จากผู้นำในอุตสาหกรรมที่มีประสบการณ์การทำงานหลายปีในบริษัทเทคโนโลยีชั้นนำระดับโลก',
icon: 'o_groups',
},
{
title: 'การเรียนรู้ที่ยืดหยุ่น',
desc: 'เรียนตามจังหวะของคุณเอง ได้ทุกที่ทุกเวลา เข้าถึงเนื้อหาคอร์สที่สมัครเรียนได้ตลอดชีพ',
icon: 'o_schedule',
},
{
title: 'ประกาศนียบัตรเมื่อเรียนจบ',
desc: 'รับวุฒิบัตรที่เป็นที่ยอมรับเพื่อเสริมพอร์ตโฟลิโอระดับมืออาชีพของคุณและแชร์ลง LinkedIn ได้โดยตรง',
icon: 'o_verified',
}
]

View file

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

View file

@ -5,7 +5,27 @@
}, },
"dashboard": { "dashboard": {
"welcomeTitle": "Welcome back", "welcomeTitle": "Welcome back",
"welcomeSubtitle": "Today is a great day to learn something new. Let's gain more knowledge!" "welcomeSubtitle": "Today is a great day to learn something new. Let's gain more knowledge!",
"heroTitle": "Continually upskill yourself",
"heroSubtitle": "to achieve your goals",
"heroDesc": "How many minutes have you learned today? Let's build a great learning habit. We have many new recommended courses waiting for you.",
"goToMyCourses": "Go to My Courses",
"searchNewCourses": "Find New Courses",
"continueLearningTitle": "Continue learning with your courses",
"myCourses": "My Courses",
"studyAgain": "Study Again",
"continue": "Continue",
"startNewCourse": "Start new courses to fill this section",
"knowledgeLibrary": "Knowledge Library",
"libraryDesc": "You can choose to learn from courses you own",
"chooseLibrary": "Choose to learn from your knowledge library",
"viewAll": "View All",
"emptyLibraryTitle": "No courses in library yet",
"emptyLibraryDesc": "Start learning new things today. Browse interesting courses to develop your skills.",
"viewAllCourses": "View All Courses",
"recommendedCourses": "Recommended Courses",
"noRecommended": "No recommended courses found",
"moreCourses": "More Courses"
}, },
"menu": { "menu": {
"continueLearning": "Continue Learning", "continueLearning": "Continue Learning",
@ -40,28 +60,42 @@
"studyAgain": "Study Again", "studyAgain": "Study Again",
"downloadCertificate": "Download Certificate", "downloadCertificate": "Download Certificate",
"completed": "Completed", "completed": "Completed",
"includes": "Course includes",
"fullLifetimeAccess": "Full lifetime access",
"accessOnMobile": "Access on mobile and TV",
"lifetimeAccess": "Lifetime access", "lifetimeAccess": "Lifetime access",
"unlimitedQuizzes": "Unlimited quizzes", "unlimitedQuizzes": "Unlimited quizzes",
"satisfactionGuarantee": "Satisfaction guarantee, 7-day refund", "satisfactionGuarantee": "Satisfaction guarantee, 7-day refund",
"noContent": "No content available yet", "noContent": "No content available yet",
"buyNow": "Buy this course",
"enrollFree": "Enroll for free", "enrollFree": "Enroll for free",
"loginToEnroll": "Log in to enroll", "loginToEnroll": "Log in to enroll",
"minutes": "Minutes", "minutes": "Minutes",
"noVideoPreview": "Video preview not available", "noVideoPreview": "Video preview not available",
"videoNotSupported": "Your browser does not support the video tag" "videoNotSupported": "Your browser does not support the video tag",
"aboutCourse": "About Course",
"lessonDetails": "Lesson Details",
"courseStats": {
"level": "Level",
"duration": "Duration",
"lessons": "Lessons",
"students": "Students"
},
"certificatePreview": "Certificate Preview",
"certificateDesc": "Upon completion and passing criteria",
"includes": "This course includes",
"fullLifetimeAccess": "Full lifetime access",
"accessOnMobile": "Access on mobile and tablet",
"buyNow": "Buy Now"
}, },
"sidebar": { "sidebar": {
"overview": "Home", "overview": "Home",
"myCourses": "My Courses", "myCourses": "My Courses",
"browseCourses": "Browse Courses", "browseCourses": "Browse Courses",
"onlineCourses": "Online Courses", "onlineCourses": "All Courses",
"recommendedCourses": "Recommended Courses", "recommendedCourses": "Recommended Courses",
"announcements": "Announcements", "announcements": "Announcements",
"profile": "My Profile" "profile": "My Profile",
"accountGroup": "Account",
"promoTitle": "Find the right course",
"promoSubtitle": "Level up your skills",
"learnMore": "Learn More"
}, },
"discovery": { "discovery": {
"title": "All Courses", "title": "All Courses",
@ -69,6 +103,9 @@
"sortRecent": "Sort by: Recent", "sortRecent": "Sort by: Recent",
"sortPopular": "Popular", "sortPopular": "Popular",
"categoryTitle": "Categories", "categoryTitle": "Categories",
"design": "Design",
"programming": "Programming",
"business": "Business",
"showMore": "Show More", "showMore": "Show More",
"showLess": "Show Less", "showLess": "Show Less",
"emptyTitle": "No courses found", "emptyTitle": "No courses found",
@ -76,15 +113,24 @@
"showAll": "Show All", "showAll": "Show All",
"loadMore": "Load More", "loadMore": "Load More",
"backToCatalog": "Back to Catalog", "backToCatalog": "Back to Catalog",
"selectable": "Selected" "selectable": "Selected",
"foundTotal": "Found Total",
"items": "items",
"subtitle": "Choose to learn new skills from our curated quality courses",
"searchBtn": "Search"
}, },
"myCourses": { "myCourses": {
"title": "My Courses",
"subtitle": "Track your progress and continue learning from where you left off",
"searchPlaceholder": "Search my courses...",
"filterAll": "All", "filterAll": "All",
"filterProgress": "In Progress", "filterProgress": "In Progress",
"filterCompleted": "Completed", "filterCompleted": "Completed",
"emptyTitle": "No courses in this category", "emptyTitle": "No courses in this category",
"emptyDesc": "You don't have any courses here yet. Browse our catalog to find interesting courses.", "emptyDesc": "You don't have any courses here yet. Browse our catalog to find interesting courses.",
"goToDiscovery": "Go to Courses" "goToDiscovery": "Go to Courses",
"searchNoResult": "No matching courses found",
"searchNoResultDesc": "Try changing category or your search term"
}, },
"enrollment": { "enrollment": {
"successTitle": "Enrollment Successful!", "successTitle": "Enrollment Successful!",
@ -109,9 +155,11 @@
"email": "Email", "email": "Email",
"phone": "Phone", "phone": "Phone",
"joinedAt": "Joined", "joinedAt": "Joined",
"generalInfo": "General Information",
"accountDetails": "Account Details",
"editPersonalDesc": "Edit Personal Information", "editPersonalDesc": "Edit Personal Information",
"yourAvatar": "Your Profile Photo", "yourAvatar": "Your Profile Photo",
"avatarHint": "PNG, JPG only", "avatarHint": "Only JPG, PNG",
"uploadNew": "Upload New Photo", "uploadNew": "Upload New Photo",
"changeAvatar": "Change Profile Photo", "changeAvatar": "Change Profile Photo",
"removeAvatar": "Remove Profile Photo", "removeAvatar": "Remove Profile Photo",
@ -142,7 +190,15 @@
"emailVerified": "Email Verified", "emailVerified": "Email Verified",
"myCertificates": "My Certificates", "myCertificates": "My Certificates",
"viewCertificate": "View Certificate", "viewCertificate": "View Certificate",
"issuedAt": "Issued at" "issuedAt": "Issued at",
"publicInfo": "Information visible to the public on the platform",
"uploading": "Uploading...",
"selectPrefix": "Select Prefix",
"verifyNow": "Click to verify email",
"verifying": "Sending...",
"saving": "Saving...",
"securitySubtitle": "Manage password and account access",
"password": "Password"
}, },
"userMenu": { "userMenu": {
"home": "Home", "home": "Home",
@ -165,7 +221,9 @@
"emailVerifiedDesc": "Your account has been successfully verified.", "emailVerifiedDesc": "Your account has been successfully verified.",
"invalidToken": "Invalid verification token", "invalidToken": "Invalid verification token",
"tokenExpired": "Token expired or invalid", "tokenExpired": "Token expired or invalid",
"logout": "Log Out" "logout": "Log Out",
"logoutConfirmTitle": "Confirm Logout",
"logoutConfirmMessage": "Are you sure you want to log out of the system?"
}, },
"language": { "language": {
"label": "Language / ภาษา", "label": "Language / ภาษา",
@ -176,6 +234,7 @@
"newBadge": "New", "newBadge": "New",
"popularBadge": "Popular", "popularBadge": "Popular",
"save": "Save", "save": "Save",
"saveChanges": "Save Changes",
"ok": "OK", "ok": "OK",
"close": "Close", "close": "Close",
"cancel": "Cancel", "cancel": "Cancel",
@ -189,7 +248,9 @@
"backToHome": "Back to Home", "backToHome": "Back to Home",
"error": "Error", "error": "Error",
"loading": "Loading", "loading": "Loading",
"items": "Items" "items": "Items",
"student": "Student",
"latest": "Latest"
}, },
"classroom": { "classroom": {
"backToDashboard": "Back to My Courses", "backToDashboard": "Back to My Courses",
@ -258,5 +319,16 @@
"statusNotStarted": "Not Started", "statusNotStarted": "Not Started",
"alertIncomplete": "Please answer all questions", "alertIncomplete": "Please answer all questions",
"yourAnswer": "Your Answer" "yourAnswer": "Your Answer"
},
"footer": {
"location": "LOCATION",
"connectWithUs": "CONNECT WITH US",
"broncoHorse": "Bronco Hourse",
"address": "123 อาคารสยามทาวเวอร์ ชั้น 15 เขตปทุมวัน กรุงเทพฯ 10330",
"emailLabel": "Email",
"emailValue": "info{'@'}chamomind.com",
"telLabel": "Tel",
"telValue": "02-123-4567",
"copyright": "© 2026 E-Learning Platform. All rights reserved."
} }
} }

View file

@ -5,7 +5,27 @@
}, },
"dashboard": { "dashboard": {
"welcomeTitle": "ยินดีต้อนรับกลับ", "welcomeTitle": "ยินดีต้อนรับกลับ",
"welcomeSubtitle": "วันนี้เป็นวันที่ดีสำหรับการเรียนรู้สิ่งใหม่ๆ มาเก็บความรู้เพิ่มกันเถอะ" "welcomeSubtitle": "วันนี้เป็นวันที่ดีสำหรับการเรียนรู้สิ่งใหม่ๆ มาเก็บความรู้เพิ่มกันเถอะ",
"heroTitle": "อัปสกิลของคุณต่อเนื่อง",
"heroSubtitle": "เพื่อเป้าหมายที่วางไว้",
"heroDesc": "วันนี้คุณเรียนไปกี่นาทีแล้ว? มาสร้างนิสัยการเรียนรู้ที่ยอดเยี่ยมกันเถอะ เรามีคอร์สแนะนำใหม่ๆ มากมายรอคุณอยู่",
"goToMyCourses": "ไปที่คอร์สเรียนของฉัน",
"searchNewCourses": "ค้นหาคอร์สใหม่",
"continueLearningTitle": "เรียนต่อกับคอร์สของคุณ",
"myCourses": "คอร์สเรียนของฉัน",
"studyAgain": "เรียนอีกครั้ง",
"continue": "เรียนต่อ",
"startNewCourse": "เริ่มเรียนคอร์สใหม่ๆ เพื่อเติมเต็มส่วนนี้",
"knowledgeLibrary": "คลังความรู้",
"libraryDesc": "คุณสามารถเลือกเรียนคอร์สเรียนที่คุณเป็นเจ้าของ",
"chooseLibrary": "เลือกเรียนคอร์สในคลังความรู้ของคุณ",
"viewAll": "ดูทั้งหมด",
"emptyLibraryTitle": "ยังไม่มีคอร์สเรียนในคลัง",
"emptyLibraryDesc": "เริ่มเรียนรู้สิ่งใหม่ๆ วันนี้ เลือกดูคอร์สเรียนที่น่าสนใจเพื่อพัฒนาทักษะของคุณ",
"viewAllCourses": "ดูคอร์สเรียนทั้งหมด",
"recommendedCourses": "คอร์สแนะนำ",
"noRecommended": "ไม่พบข้อมูลคอร์สแนะนำ",
"moreCourses": "คอร์สเพิ่มเติม"
}, },
"menu": { "menu": {
"continueLearning": "เรียนต่อจากเดิม", "continueLearning": "เรียนต่อจากเดิม",
@ -40,28 +60,42 @@
"studyAgain": "ทบทวนบทเรียน", "studyAgain": "ทบทวนบทเรียน",
"downloadCertificate": "ดาวน์โหลดประกาศนียบัตร", "downloadCertificate": "ดาวน์โหลดประกาศนียบัตร",
"completed": "เรียนจบเรียบร้อย", "completed": "เรียนจบเรียบร้อย",
"includes": "สิ่งที่รวมอยู่ในคอร์ส",
"fullLifetimeAccess": "เข้าเรียนได้ตลอดชีพ",
"accessOnMobile": "เรียนได้บนมือถือและแท็บเล็ต",
"lifetimeAccess": "เข้าเรียนได้ตลอดชีพ", "lifetimeAccess": "เข้าเรียนได้ตลอดชีพ",
"unlimitedQuizzes": "ทำแบบทดสอบไม่จำกัด", "unlimitedQuizzes": "ทำแบบทดสอบไม่จำกัด",
"satisfactionGuarantee": "รับประกันความพึงพอใจ คืนเงินภายใน 7 วัน", "satisfactionGuarantee": "รับประกันความพึงพอใจ คืนเงินภายใน 7 วัน",
"noContent": "ยังไม่มีเนื้อหาในขณะนี้", "noContent": "ยังไม่มีเนื้อหาในขณะนี้",
"buyNow": "ซื้อคอร์สเรียนนี้",
"enrollFree": "ลงทะเบียนเรียนฟรี", "enrollFree": "ลงทะเบียนเรียนฟรี",
"loginToEnroll": "เข้าสู่ระบบเพื่อลงทะเบียน", "loginToEnroll": "เข้าสู่ระบบเพื่อลงทะเบียน",
"minutes": "นาที", "minutes": "นาที",
"noVideoPreview": "วิดีโอตัวอย่างยังไม่พร้อมใช้งาน", "noVideoPreview": "วิดีโอตัวอย่างยังไม่พร้อมใช้งาน",
"videoNotSupported": "เบราว์เซอร์ของคุณไม่รองรับการเล่นวิดีโอ" "videoNotSupported": "เบราว์เซอร์ของคุณไม่รองรับการเล่นวิดีโอ",
"aboutCourse": "เกี่ยวกับคอร์ส",
"lessonDetails": "รายละเอียดบทเรียน",
"courseStats": {
"level": "ระดับ",
"duration": "ระยะเวลา",
"lessons": "บทเรียน",
"students": "ผู้เรียน"
},
"certificatePreview": "ตัวอย่างใบประกาศนียบัตร",
"certificateDesc": "เมื่อเรียนจบและสอบผ่านตามเกณฑ์ที่กำหนด",
"includes": "สิ่งที่รวมอยู่ในคอร์ส",
"fullLifetimeAccess": "เข้าเรียนได้ตลอดชีพ",
"accessOnMobile": "เรียนได้บนมือถือและแท็บเล็ต",
"buyNow": "ซื้อคอร์สนี้"
}, },
"sidebar": { "sidebar": {
"overview": "หน้าหลัก", "overview": "หน้าหลัก",
"myCourses": "คอร์สของฉัน", "myCourses": "คอร์สของฉัน",
"browseCourses": "ค้นหาคอร์ส", "browseCourses": "ค้นหาคอร์ส",
"onlineCourses": "คอร์สออนไลน์", "onlineCourses": "คอร์สเรียนทั้งหมด",
"recommendedCourses": "คอร์สเรียนออนไลน์แนะนำ", "recommendedCourses": "คอร์สเรียนแนะนำ",
"announcements": "ข่าวประกาศ", "announcements": "ข่าวประกาศ",
"profile": "บัญชีผู้ใช้" "profile": "บัญชีผู้ใช้",
"accountGroup": "บัญชี",
"promoTitle": "ค้นหาคอร์สที่ใช่",
"promoSubtitle": "ยกระดับทักษะของคุณ",
"learnMore": "เรียนรู้เพิ่มเติม"
}, },
"discovery": { "discovery": {
"title": "รายการคอร์สทั้งหมด", "title": "รายการคอร์สทั้งหมด",
@ -69,6 +103,9 @@
"sortRecent": "เรียงตาม: ล่าสุด", "sortRecent": "เรียงตาม: ล่าสุด",
"sortPopular": "ยอดนิยม", "sortPopular": "ยอดนิยม",
"categoryTitle": "หมวดหมู่", "categoryTitle": "หมวดหมู่",
"design": "การออกแบบ",
"programming": "การเขียนโปรแกรม",
"business": "ธุรกิจ",
"showMore": "แสดงเพิ่มเติม", "showMore": "แสดงเพิ่มเติม",
"showLess": "แสดงน้อยลง", "showLess": "แสดงน้อยลง",
"emptyTitle": "ไม่พบผลการค้นหา", "emptyTitle": "ไม่พบผลการค้นหา",
@ -76,15 +113,24 @@
"showAll": "แสดงทั้งหมด", "showAll": "แสดงทั้งหมด",
"loadMore": "โหลดเพิ่มเติม", "loadMore": "โหลดเพิ่มเติม",
"backToCatalog": "กลับหน้ารายการคอร์ส", "backToCatalog": "กลับหน้ารายการคอร์ส",
"selectable": "รายการที่เลือก" "selectable": "รายการที่เลือก",
"foundTotal": "พบทั้งหมด",
"items": "รายการ",
"subtitle": "เลือกเรียนรู้ทักษะใหม่ๆ จากหลักสูตรคุณภาพที่คัดสรรมาเพื่อคุณ",
"searchBtn": "ค้นหา"
}, },
"myCourses": { "myCourses": {
"title": "คอร์สของฉัน",
"subtitle": "ติดตามความคืบหน้าและเรียนรู้ต่อจากจุดที่ค้างไว้",
"searchPlaceholder": "ค้นหาชื่อคอร์สของฉัน...",
"filterAll": "ทั้งหมด", "filterAll": "ทั้งหมด",
"filterProgress": "กำลังเรียน", "filterProgress": "กำลังเรียน",
"filterCompleted": "เรียนจบแล้ว", "filterCompleted": "เรียนจบแล้ว",
"emptyTitle": "ยังไม่มีคอร์สในหมวดหมู่นี้", "emptyTitle": "ยังไม่มีคอร์สในหมวดหมู่นี้",
"emptyDesc": "คุณยังไม่มีคอร์สเรียนในส่วนนี้ ลองเลือกดูคอร์สที่น่าสนใจในระบบของเรา", "emptyDesc": "คุณยังไม่มีคอร์สเรียนในส่วนนี้ ลองเลือกดูคอร์สที่น่าสนใจในระบบของเรา",
"goToDiscovery": "ไปที่รายการคอร์ส" "goToDiscovery": "ไปที่รายการคอร์ส",
"searchNoResult": "ไม่พบคอร์สที่สอดคล้อง",
"searchNoResultDesc": "ลองเปลี่ยนหมวดหมู่หรือคำค้นหาของคุณ"
}, },
"enrollment": { "enrollment": {
"successTitle": "ลงทะเบียนสำเร็จ!", "successTitle": "ลงทะเบียนสำเร็จ!",
@ -109,9 +155,11 @@
"email": "อีเมล", "email": "อีเมล",
"phone": "เบอร์โทรศัพท์", "phone": "เบอร์โทรศัพท์",
"joinedAt": "สมัครสมาชิกเมื่อ", "joinedAt": "สมัครสมาชิกเมื่อ",
"generalInfo": "ข้อมูลทั่วไป",
"accountDetails": "รายละเอียดบัญชี",
"editPersonalDesc": "แก้ไขข้อมูลส่วนตัว", "editPersonalDesc": "แก้ไขข้อมูลส่วนตัว",
"yourAvatar": "รูปโปรไฟล์ของคุณ", "yourAvatar": "รูปโปรไฟล์ของคุณ",
"avatarHint": "เฉพาะไฟล์ png , jpg", "avatarHint": "เฉพาะไฟล์ JPG, PNG",
"uploadNew": "อัพโหลดรูปโปรไฟล์", "uploadNew": "อัพโหลดรูปโปรไฟล์",
"changeAvatar": "เปลี่ยนรูปโปรไฟล์", "changeAvatar": "เปลี่ยนรูปโปรไฟล์",
"removeAvatar": "ลบรูปโปรไฟล์", "removeAvatar": "ลบรูปโปรไฟล์",
@ -142,7 +190,15 @@
"emailVerified": "ยืนยันอีเมลเสร็จสิ้น", "emailVerified": "ยืนยันอีเมลเสร็จสิ้น",
"myCertificates": "ประกาศนียบัตรของฉัน", "myCertificates": "ประกาศนียบัตรของฉัน",
"viewCertificate": "ดูประกาศนียบัตร", "viewCertificate": "ดูประกาศนียบัตร",
"issuedAt": "ออกเมื่อ" "issuedAt": "ออกเมื่อ",
"publicInfo": "ข้อมูลที่แสดงต่อสาธารณะบนแพลตฟอร์ม",
"uploading": "กำลังอัปโหลด...",
"selectPrefix": "เลือกคำนำหน้า",
"verifyNow": "คลิกเพื่อยืนยันอีเมล",
"verifying": "กำลังส่ง...",
"saving": "กำลังบันทึก...",
"securitySubtitle": "จัดการรหัสผ่านและการเข้าถึงบัญชี",
"password": "รหัสผ่าน"
}, },
"userMenu": { "userMenu": {
"home": "หน้าหลัก", "home": "หน้าหลัก",
@ -153,7 +209,7 @@
"logout": "ออกจากระบบ" "logout": "ออกจากระบบ"
}, },
"landing": { "landing": {
"allCourses": "คอร์สทั้งหมด", "allCourses": "คอร์สเรียนทั้งหมด",
"discovery": "ค้นพบ", "discovery": "ค้นพบ",
"goToDashboard": "เข้าสู่หน้าจัดการเรียน" "goToDashboard": "เข้าสู่หน้าจัดการเรียน"
}, },
@ -165,7 +221,9 @@
"emailVerifiedDesc": "บัญชีของคุณได้รับการยืนยันเรียบร้อยแล้ว", "emailVerifiedDesc": "บัญชีของคุณได้รับการยืนยันเรียบร้อยแล้ว",
"invalidToken": "Token ยืนยันตัวตนไม่ถูกต้อง", "invalidToken": "Token ยืนยันตัวตนไม่ถูกต้อง",
"tokenExpired": "Token หมดอายุหรือล้มเหลว", "tokenExpired": "Token หมดอายุหรือล้มเหลว",
"logout": "ออกจากระบบ" "logout": "ออกจากระบบ",
"logoutConfirmTitle": "ยืนยันการออกจากระบบ",
"logoutConfirmMessage": "คุณแน่ใจหรือไม่ว่าต้องการออกจากระบบ?"
}, },
"language": { "language": {
"label": "ภาษา / Language", "label": "ภาษา / Language",
@ -176,6 +234,7 @@
"newBadge": "ใหม่", "newBadge": "ใหม่",
"popularBadge": "ยอดนิยม", "popularBadge": "ยอดนิยม",
"save": "บันทึก", "save": "บันทึก",
"saveChanges": "บันทึกการเปลี่ยนแปลง",
"ok": "ตกลง", "ok": "ตกลง",
"close": "ปิด", "close": "ปิด",
"cancel": "ยกเลิก", "cancel": "ยกเลิก",
@ -189,7 +248,9 @@
"backToHome": "กลับสู่หน้าหลัก", "backToHome": "กลับสู่หน้าหลัก",
"error": "เกิดข้อผิดพลาด", "error": "เกิดข้อผิดพลาด",
"loading": "กำลังโหลด", "loading": "กำลังโหลด",
"items": "รายการ" "items": "รายการ",
"student": "นักเรียน",
"latest": "ล่าสุด"
}, },
"classroom": { "classroom": {
"backToDashboard": "กลับไปคอร์สของฉัน", "backToDashboard": "กลับไปคอร์สของฉัน",
@ -258,5 +319,16 @@
"statusNotStarted": "ยังไม่ทำ", "statusNotStarted": "ยังไม่ทำ",
"alertIncomplete": "กรุณาเลือกคำตอบให้ครบทุกข้อ", "alertIncomplete": "กรุณาเลือกคำตอบให้ครบทุกข้อ",
"yourAnswer": "คำตอบของคุณ" "yourAnswer": "คำตอบของคุณ"
},
"footer": {
"location": "สถานที่ตั้ง",
"connectWithUs": "ติดต่อเรา",
"broncoHorse": "Bronco Hourse",
"address": "123 อาคารสยามทาวเวอร์ ชั้น 15 เขตปทุมวัน กรุงเทพฯ 10330",
"emailLabel": "อีเมล",
"emailValue": "info{'@'}chamomind.com",
"telLabel": "เบอร์โทรศัพท์",
"telValue": "02-123-4567",
"copyright": "© 2026 E-Learning Platform. All rights reserved."
} }
} }

View file

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

@ -1,58 +1,65 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file default.vue * @file default.vue
* @description Layout หลกสำหรบหนาเวบของผใช (Authenticated Users) * @description Master layout for the EduLearn platform.
* Uses Quasar QLayout for responsive structure.
*/ */
// Initialize global theme management
useThemeMode() useThemeMode()
const leftDrawerOpen = ref(false) const leftDrawerOpen = ref(true)
const toggleLeftDrawer = () => { const toggleLeftDrawer = () => {
leftDrawerOpen.value = !leftDrawerOpen.value leftDrawerOpen.value = !leftDrawerOpen.value
} }
const route = useRoute()
// Show sidebar for these routes
const isDashboardRoute = computed(() => {
const routes = ['/dashboard', '/browse', '/classroom', '/course']
return routes.some(r => route.path.startsWith(r))
})
</script> </script>
<template> <template>
<q-layout view="hHh LpR lFf" class="bg-slate-50 dark:!bg-[#020617] text-slate-900 dark:!text-slate-50"> <q-layout view="lHh Lpr lFf" class="bg-[#F8FAFC] dark:!bg-[#020617] text-slate-900 dark:!text-slate-50">
<!-- Header --> <!-- Header -->
<q-header <q-header
class="bg-white/80 dark:!bg-[#0f172a]/80 backdrop-blur-md text-slate-900 dark:!text-white" class="bg-transparent text-slate-900 dark:!text-white border-none shadow-none"
> >
<AppHeader @toggleSidebar="toggleLeftDrawer" /> <AppHeader @toggleSidebar="toggleLeftDrawer" />
</q-header> </q-header>
<!-- Sidebar (Drawer) --> <!-- Navigation Sidebar -->
<q-drawer <q-drawer
v-model="leftDrawerOpen" v-model="leftDrawerOpen"
show-if-above show-if-above
:width="280" :width="280"
class="bg-white dark:!bg-[#0f172a]" side="left"
bordered
class="bg-white dark:!bg-[#0f172a] border-none"
> >
<AppSidebar /> <AppSidebar />
</q-drawer> </q-drawer>
<!-- Main Content --> <!-- Main Content Area -->
<q-page-container> <q-page-container>
<q-page class="relative"> <q-page class="px-3 py-6 md:p-8">
<div class="max-w-[1600px] mx-auto">
<slot /> <slot />
</div>
</q-page> </q-page>
</q-page-container> </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> </q-layout>
</template> </template>
<style> <style>
/* Ensure fonts are applied */ /* Global Layout Adjustments */
.font-inter { .q-drawer--bordered {
font-family: 'Inter', sans-serif; border-right: 1px solid rgba(0,0,0,0.05) !important;
}
.dark .q-drawer--bordered {
border-right: 1px solid rgba(255,255,255,0.05) !important;
} }
</style> </style>

View file

@ -36,6 +36,9 @@ onMounted(() => {
</q-page> </q-page>
</q-page-container> </q-page-container>
<!-- Footer -->
<LandingFooter />
</q-layout> </q-layout>
</template> </template>

View file

@ -33,8 +33,11 @@ export default defineNuxtConfig({
// การตั้งค่า Quasar Framework // การตั้งค่า Quasar Framework
quasar: { quasar: {
iconSet: 'material-icons-outlined',
extras: { extras: {
fontIcons: ["material-icons"], fontIcons: [
"material-icons",
"material-icons-outlined"] // ใช้ไอคอน Material Icons, material-icons-outlined
}, },
plugins: ["Notify", "Dialog"], // เปิดใช้ Plugin Notify และ Dialog plugins: ["Notify", "Dialog"], // เปิดใช้ Plugin Notify และ Dialog
config: { config: {
@ -66,10 +69,11 @@ export default defineNuxtConfig({
{ name: "viewport", content: "width=device-width, initial-scale=1" }, { name: "viewport", content: "width=device-width, initial-scale=1" },
], ],
link: [ link: [
{ rel: 'icon', type: 'image/png', href: '/img/logo.png' },
{ {
rel: "stylesheet", rel: "stylesheet",
// โหลด Font: Inter, Prompt, Sarabun // โหลด Font: Inter, Prompt, Sarabun
href: "https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Prompt:wght@300;400;500;600;700;800;900&family=Sarabun:wght@300;400;500;600;700;800&display=swap", href: "https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Prompt:wght@300;400;500;600;700;800;900&family=Sarabun:wght@300;400;500;600;700;800&family=Poppins:wght@300;400;500;600;700;800;900&display=swap",
}, },
], ],
}, },

View file

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

View file

@ -1,9 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file discovery.vue * @file discovery.vue
* @description Course Discovery / Catalog Page. * @description Course Discovery / Catalog Page matching Figma Desktop Layout.
* Allows users to browse, filter, and view details of available courses.
* Includes a toggleable detailed view for course previews.
*/ */
definePageMeta({ definePageMeta({
@ -15,11 +13,19 @@ useHead({
title: "รายการคอร์ส - e-Learning", title: "รายการคอร์ส - e-Learning",
}); });
// ========================================== const { t, locale } = useI18n();
// 1. State Management const { currentUser } = useAuth();
const $q = useQuasar();
const { fetchCategories } = useCategory();
const { fetchCourses, fetchCourseById, enrollCourse, getLocalizedText } = useCourse();
const showDetail = ref(false); const showDetail = ref(false);
const searchQuery = ref(""); const searchQuery = ref("");
const selectedCategoryIds = ref<number[]>([]); const activeCategory = ref<number | 'all'>('all');
const viewMode = ref<'grid' | 'list'>('grid');
const sortBy = ref('ยอดนิยม');
const sortOptions = ['ยอดนิยม', 'ล่าสุด', 'ราคาต่ำ-สูงสุด', 'ราคาสูง-ต่ำสุด'];
const categories = ref<any[]>([]); const categories = ref<any[]>([]);
const courses = ref<any[]>([]); const courses = ref<any[]>([]);
const selectedCourse = ref<any>(null); const selectedCourse = ref<any>(null);
@ -28,46 +34,43 @@ const isLoading = ref(false);
const isLoadingDetail = ref(false); const isLoadingDetail = ref(false);
const isEnrolling = ref(false); const isEnrolling = ref(false);
// Pagination State
const currentPage = ref(1); const currentPage = ref(1);
const totalPages = ref(1); const totalPages = ref(1);
const itemsPerPage = 12; const itemsPerPage = 12;
const getCategoryIcon = (name: any) => {
const text = getLocalizedText(name) || ''
if (text.includes('เว็บ') || text.includes('Web') || text.includes('โปรแกรม') || text.includes('Program') || text.includes('โค้ด')) return 'code'
if (text.includes('ออกแบบ') || text.includes('Design') || text.includes('UI')) return 'palette'
if (text.includes('ธุรกิจ') || text.includes('Business') || text.includes('การตลาด') || text.includes('Market')) return 'trending_up'
if (text.includes('ข้อมูล') || text.includes('Data') || text.includes('วิเคราะ') || text.includes('Sci')) return 'storage'
return 'category'
}
const { t, locale } = useI18n(); const formatPrice = (course: any) => {
const { currentUser } = useAuth(); if (course.is_free) return 'ฟรี';
const $q = useQuasar(); if (!course.price) return 'ฟรี';
const { fetchCategories } = useCategory(); return `฿${parseFloat(course.price).toLocaleString()}`;
const { fetchCourses, fetchCourseById, enrollCourse, getLocalizedText } = useCourse(); };
// 2. Computed Properties const getInstructorName = (course: any) => {
const sortOption = ref(t('discovery.sortRecent')); let user = null;
const sortOptions = computed(() => [t('discovery.sortRecent')]); if (course.instructors && course.instructors.length > 0) {
const primary = course.instructors.find((i: any) => i.is_primary);
const filteredCourses = computed(() => { user = primary ? primary.user : course.instructors[0].user;
let result = courses.value; } else {
user = course.creator || course.instructor;
// 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));
} }
if (searchQuery.value) { if (user?.profile?.first_name) {
const query = searchQuery.value.toLowerCase(); return `${user.profile.first_name} ${user.profile.last_name || ''}`.trim();
result = result.filter(c => {
const title = getLocalizedText(c.title).toLowerCase();
const desc = getLocalizedText(c.description).toLowerCase();
return title.includes(query) || (desc && desc.includes(query));
});
} }
return result; if (user?.first_name) {
}); return `${user.first_name} ${user.last_name || ''}`.trim();
}
return user?.username || 'ผู้สอน';
};
// 3. Helper Functions
// 4. API Actions
const loadCategories = async () => { const loadCategories = async () => {
const res = await fetchCategories(); const res = await fetchCategories();
if (res.success) categories.value = res.data || []; if (res.success) categories.value = res.data || [];
@ -75,19 +78,28 @@ const loadCategories = async () => {
const loadCourses = async (page = 1) => { const loadCourses = async (page = 1) => {
isLoading.value = true; isLoading.value = true;
const categoryId = activeCategory.value === 'all' ? undefined : activeCategory.value as number;
// Use server-side filtering if exactly one category is selected
const categoryId = selectedCategoryIds.value.length === 1 ? selectedCategoryIds.value[0] : undefined;
const res = await fetchCourses({ const res = await fetchCourses({
category_id: categoryId, category_id: categoryId,
search: searchQuery.value,
page: page, page: page,
limit: itemsPerPage, limit: itemsPerPage,
forceRefresh: true forceRefresh: true,
}); });
if (res.success) { if (res.success) {
courses.value = res.data || []; courses.value = (res.data || []).map(c => {
const cat = categories.value.find(cat => cat.id === c.category_id);
return {
...c,
category_name: cat ? getLocalizedText(cat.name) : '',
instructor_name: getInstructorName(c),
formatted_price: formatPrice(c),
rating: c.rating || '4.9',
reviews_count: c.total_lessons ? c.total_lessons * 123 : Math.floor(Math.random() * 2000) + 100
}
});
totalPages.value = res.totalPages || 1; totalPages.value = res.totalPages || 1;
currentPage.value = res.page || 1; currentPage.value = res.page || 1;
} }
@ -108,194 +120,188 @@ const handleEnroll = async (id: number) => {
isEnrolling.value = true; isEnrolling.value = true;
const res = await enrollCourse(id); const res = await enrollCourse(id);
if (res.success) { if (res.success) {
return navigateTo('/dashboard/my-courses?enrolled=true'); return navigateTo("/dashboard/my-courses?enrolled=true");
} else { } else {
$q.notify({ $q.notify({
type: 'negative', type: "negative",
message: res.error || t('enrollment.error'), message: res.error || t("enrollment.error"),
position: 'top', position: "top",
timeout: 3000, timeout: 3000,
actions: [{ icon: 'close', color: 'white' }] actions: [{ icon: "close", color: "white" }],
}) });
} }
isEnrolling.value = false; isEnrolling.value = false;
}; };
// Watch for category selection changes to reload courses watch(
watch(selectedCategoryIds, () => { activeCategory,
() => {
currentPage.value = 1; currentPage.value = 1;
loadCourses(1); loadCourses(1);
}, { deep: true });
const toggleCategory = (id: number) => {
const index = selectedCategoryIds.value.indexOf(id)
if (index === -1) {
selectedCategoryIds.value.push(id)
} else {
selectedCategoryIds.value.splice(index, 1)
} }
} );
onMounted(() => { onMounted(async () => {
loadCategories(); await loadCategories();
loadCourses();
// Check if category_id or course_id is in query
const route = useRoute();
if (route.query.category_id) {
activeCategory.value = Number(route.query.category_id);
}
await loadCourses(1);
if (route.query.course_id) {
selectCourse(Number(route.query.course_id));
}
}); });
</script> </script>
<template> <template>
<div class="page-container"> <div class="bg-[#F8F9FA] dark:bg-[#020617] min-h-screen p-4 md:p-8 transition-colors duration-300">
<div class="max-w-[1240px] mx-auto">
<!-- วนของการคนหาคอร (Catalog View) -->
<div v-if="!showDetail" class="bg-white dark:bg-slate-900 rounded-[2rem] p-6 md:p-8 shadow-[0_2px_15px_rgb(0,0,0,0.02)] border border-slate-100 dark:border-slate-800 min-h-[500px] mb-12">
<!-- CATALOG VIEW: Browse courses --> <!-- วนหวและการคนหา -->
<div v-if="!showDetail"> <div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
<h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">คอรสเรยนทงหมด</h2>
<!-- Top Header Area --> <div class="flex flex-wrap sm:flex-nowrap items-center gap-3 w-full md:w-auto">
<div class="flex flex-col gap-6 mb-10"> <div class="relative w-full sm:w-[260px] flex-1">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6"> <q-icon name="search" size="18px" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-[#3B6BE8]" />
<!-- Title --> <input v-model="searchQuery" @keyup.enter="loadCourses(1)" class="w-full bg-slate-100 dark:bg-slate-800 border-none rounded-xl py-2.5 pl-11 pr-4 text-sm font-medium text-slate-700 dark:text-slate-200 placeholder:text-slate-400 focus:ring-2 focus:ring-blue-500/20 outline-none transition-all shadow-sm" placeholder="ค้นหาคอร์ส..." />
<h1 class="text-3xl font-black text-slate-900 dark:text-white flex items-center gap-3"> </div>
<span class="w-1.5 h-8 bg-blue-600 rounded-full shadow-sm shadow-blue-500/50"></span> <div class="flex items-center gap-2 shrink-0">
{{ $t('discovery.title') }} <button @click="viewMode = 'grid'" :class="viewMode === 'grid' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-[#3B6BE8]' : 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700 text-slate-400 hover:bg-slate-50'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="grid_view" size="20px" /></button>
</h1> <button @click="viewMode = 'list'" :class="viewMode === 'list' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-[#3B6BE8]' : 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700 text-slate-400 hover:bg-slate-50'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="view_list" size="20px" /></button>
</div>
<!-- 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>
</div> </div>
<!-- Unified Filter Section: Categories --> <!-- Filters Category -->
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-col xl:flex-row xl:items-center justify-between gap-4 mb-10 w-full relative">
<q-btn <!-- Figma Style: Separate pill buttons -->
flat <div class="flex flex-wrap items-center gap-3 w-full xl:w-auto">
rounded <button
dense @click="activeCategory = 'all'"
class="px-4 font-bold transition-all text-xs uppercase tracking-widest" :class="activeCategory === 'all' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'"
: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'" class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none">
@click="selectedCategoryIds = []" <q-icon name="check_circle_outline" size="18px" :class="activeCategory === 'all' ? 'text-[#3B6BE8]' : 'text-slate-400'"/> งหมด
:label="$t('discovery.showAll')" </button>
/>
<q-btn <button
v-for="cat in categories" v-for="cat in categories" :key="cat.id"
:key="cat.id" @click="activeCategory = cat.id"
flat :class="activeCategory === cat.id ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'"
rounded class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none bg-transparent">
dense <q-icon :name="getCategoryIcon(cat.name)" size="18px" :class="activeCategory === cat.id ? 'text-[#3B6BE8]' : 'text-slate-600 dark:text-slate-400'"/>
class="px-4 font-bold transition-all text-xs uppercase tracking-widest" {{ getLocalizedText(cat.name) }}
: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'" </button>
@click="toggleCategory(cat.id)" </div>
:label="getLocalizedText(cat.name)"
/>
</div>
<!-- Loader -->
<div v-if="isLoading" class="flex justify-center py-24">
<q-spinner size="3rem" color="primary" />
</div>
<div v-else-if="courses.length > 0">
<!-- GRID VIEW -->
<div v-if="viewMode === 'grid'" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div v-for="course in courses" :key="course.id" class="flex flex-col rounded-[1.5rem] bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 overflow-hidden shadow-[0_2px_10px_rgb(0,0,0,0.03)] hover:shadow-[0_8px_30px_rgb(0,0,0,0.08)] transition-all duration-300 group cursor-pointer" @click="selectCourse(course.id)">
<!-- Thumbnail -->
<div class="relative w-full aspect-[16/10] bg-slate-100 dark:bg-slate-800 overflow-hidden">
<img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
<div v-if="course.category_name" class="absolute top-3 left-3 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md text-[#3B6BE8] dark:text-blue-400 font-bold text-[10px] px-3.5 py-1 rounded-full shadow-sm" style="border-radius: 9999px; padding: 4px 12px;">
{{ course.category_name }}
</div> </div>
</div> </div>
<!-- Main Layout: Grid Only --> <!-- Body -->
<div class="w-full"> <div class="p-5 flex flex-col flex-1">
<div v-if="filteredCourses.length > 0" class="flex flex-col gap-12"> <h3 class="font-bold text-slate-900 dark:text-white text-[15px] leading-snug line-clamp-2 mb-2">{{ getLocalizedText(course.title) }}</h3>
<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" <div class="mt-auto flex items-center justify-between">
v-bind="{ ...course, image: course.thumbnail_url }" <div class="font-[900] text-[18px]" :class="course.is_free ? 'text-green-500' : 'text-[#2563EB] dark:text-blue-400'">
show-view-details {{ course.formatted_price }}
@view-details="selectCourse(course.id)" </div>
/> <!-- Eye icon circle button -->
<button class="w-[38px] h-[38px] rounded-full bg-slate-50 dark:bg-slate-800 text-slate-400 dark:text-slate-500 flex items-center justify-center hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-slate-700 border border-slate-100 dark:border-slate-700 transition-colors shadow-sm outline-none">
<q-icon name="visibility" size="18px" />
</button>
</div>
</div>
</div>
</div> </div>
<!-- Pagination Controls --> <!-- LIST VIEW -->
<div v-if="totalPages > 1" class="flex justify-center pb-10"> <div v-else class="flex flex-col gap-5">
<q-pagination <div v-for="course in courses" :key="course.id" class="flex flex-col sm:flex-row rounded-[1.5rem] bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 p-3 sm:p-4 gap-4 sm:gap-6 shadow-sm hover:shadow-[0_8px_30px_rgb(0,0,0,0.06)] transition-all duration-300 group cursor-pointer" @click="selectCourse(course.id)">
v-model="currentPage" <div class="relative w-full sm:w-[260px] aspect-[16/10] sm:aspect-auto sm:h-[160px] rounded-[1rem] bg-slate-100 dark:bg-slate-800 overflow-hidden shrink-0">
:max="totalPages" <img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
:max-pages="6" <div v-if="course.category_name" class="absolute top-3 left-3 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md text-[#3B6BE8] dark:text-blue-400 font-bold text-[10px] px-3.5 py-1.5 rounded-full shadow-sm" style="border-radius: 9999px; padding: 4px 12px;">
boundary-numbers {{ course.category_name }}
direction-links </div>
color="primary" </div>
flat <div class="flex flex-col flex-1 py-1">
active-design="unelevated" <div class="flex-1">
active-color="primary" <h3 class="font-bold text-slate-900 dark:text-white text-[16px] md:text-[18px] leading-snug line-clamp-2 md:line-clamp-1 mb-2">{{ getLocalizedText(course.title) }}</h3>
@update:model-value="loadCourses"
/> </div>
<div class="mt-4 sm:mt-auto flex items-center justify-between">
<div class="font-[900] text-[20px]" :class="course.is_free ? 'text-green-500' : 'text-[#2563EB] dark:text-blue-400'">
{{ course.formatted_price }}
</div>
<button class="px-6 py-2 rounded-full bg-slate-50 text-slate-600 dark:bg-slate-800 dark:text-slate-300 font-bold text-[13px] flex items-center gap-2 hover:bg-blue-50 border border-slate-100 dark:border-slate-700 hover:text-blue-600 transition-colors">
<q-icon name="visibility" size="16px" /> รายละเอยด
</button>
</div>
</div>
</div>
</div>
<!-- Pagination -->
<div v-if="totalPages > 1" class="flex justify-center pt-8 pb-4">
<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>
</div> </div>
<!-- Empty State --> <!-- Empty State -->
<div <div v-else 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-slate-800">
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" /> <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> <h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ $t("discovery.emptyTitle") }}</h3>
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md"> <p class="text-slate-500 dark:text-slate-400 text-center max-w-md">{{ $t("discovery.emptyDesc") }}</p>
{{ $t('discovery.emptyDesc') }} <button class="mt-6 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''; activeCategory = 'all';">
</p> {{ $t("discovery.showAll") }}
<button class="mt-6 font-bold text-blue-600 hover:text-blue-700 dark:hover:text-blue-400 transition-colors" @click="searchQuery = ''; selectedCategoryIds = []">
{{ $t('discovery.showAll') }}
</button> </button>
</div> </div>
</div> </div>
</div>
<!-- COURSE DETAIL VIEW: Detailed information about a specific course --> <!-- COURSE DETAIL VIEW: Detailed information about a specific course -->
<div v-else> <div v-else>
<button <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">
@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" /> <q-icon name="arrow_back" size="24px" class="transition-transform group-hover:-translate-x-1" />
{{ $t('discovery.backToCatalog') }} {{ $t("discovery.backToCatalog") }}
</button> </button>
<div v-if="isLoadingDetail" class="flex justify-center py-20"><q-spinner size="3rem" color="primary" /></div>
<div v-if="isLoadingDetail" class="flex justify-center py-20"> <CourseDetailView v-else-if="selectedCourse" :course="selectedCourse" :user="currentUser" @back="showDetail = false" @enroll="handleEnroll"/>
<q-spinner size="3rem" color="primary" />
</div> </div>
<CourseDetailView
v-else-if="selectedCourse"
:course="selectedCourse"
:user="currentUser"
@back="showDetail = false"
@enroll="handleEnroll"
/>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
/* Standard overrides for Quasar inputs to match Tailwind theme */ /* Disable default scrollbar for filter container */
.search-input :deep(.q-field__control) { .scrollbar-hide::-webkit-scrollbar {
border-radius: 9999px; /* Full rounded pill */ display: none;
background-color: white !important;
transition: all 0.3s ease;
} }
.scrollbar-hide {
.dark .search-input :deep(.q-field__control) { -ms-overflow-style: none;
background-color: #1e293b !important; /* slate-800: Inner card depth */ scrollbar-width: none;
border-color: rgba(255, 255, 255, 0.1) !important;
}
.search-input :deep(.q-field__native) {
color: #0f172a !important; /* slate-900: Dark text for light mode */
}
.dark .search-input :deep(.q-field__native) {
color: white !important;
}
.search-input :deep(.q-field__shadow) {
box-shadow: none !important;
} }
</style> </style>

View file

@ -1,26 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file courses.vue * @file index.vue
* @description Page displaying all available courses in a public catalog format. * @description Page displaying all available courses in a public catalog format.
* Matches the premium dark theme of the landing page. * Matches the requested modern layout.
*/ */
// Define page metadata using the landing layout (dark theme default)
definePageMeta({ definePageMeta({
layout: 'landing' layout: 'landing'
}) })
// Set the HTML head title for SEO
useHead({ useHead({
title: 'คอร์สทั้งหมด - E-Learning System' title: 'คอร์สทั้งหมด - E-Learning System'
}) })
// Reactive state for the search input
const searchQuery = ref('') const searchQuery = ref('')
const { fetchCourses } = useCourse() const { fetchCourses } = useCourse()
const { fetchCategories, categories } = useCategory() const { fetchCategories, categories } = useCategory()
// Helper to handle localized text
const getLocalizedText = (text: string | { th: string; en: string } | undefined) => { const getLocalizedText = (text: string | { th: string; en: string } | undefined) => {
if (!text) return '' if (!text) return ''
if (typeof text === 'string') return text if (typeof text === 'string') return text
@ -30,7 +26,6 @@ const getLocalizedText = (text: string | { th: string; en: string } | undefined)
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
// State for selected category
const selectedCategory = ref((route.query.category as string) || 'all') const selectedCategory = ref((route.query.category as string) || 'all')
const selectCategory = (slug: string) => { const selectCategory = (slug: string) => {
@ -41,345 +36,234 @@ const selectCategory = (slug: string) => {
} }
} }
// Watch route query to sync state
watch(() => route.query.category, (newCategory) => { watch(() => route.query.category, (newCategory) => {
selectedCategory.value = (newCategory as string) || 'all' selectedCategory.value = (newCategory as string) || 'all'
}) })
// Specific labels mapping as requested const getCategoryIcon = (name: any) => {
const categoryLabels: Record<string, string> = { const text = getLocalizedText(name) || ''
all: "ทั้งหมด", if (text.includes('เว็บ') || text.includes('Web') || text.includes('โปรแกรม') || text.includes('Program') || text.includes('โค้ด')) return 'code'
programming: "การเขียนโปรแกรม", if (text.includes('ออกแบบ') || text.includes('Design') || text.includes('UI')) return 'palette'
design: "การออกแบบ", if (text.includes('ธุรกิจ') || text.includes('Business') || text.includes('การตลาด') || text.includes('Market')) return 'trending_up'
business: "ธุรกิจ" if (text.includes('ข้อมูล') || text.includes('Data') || text.includes('วิเคราะ') || text.includes('Sci')) return 'storage'
return 'category'
} }
const getCategoryLabel = (category: any) => {
if (categoryLabels[category.slug]) {
return categoryLabels[category.slug]
}
return getLocalizedText(category.name)
}
// Fetch categories on mount
await useAsyncData('categories-list', () => fetchCategories()) await useAsyncData('categories-list', () => fetchCategories())
// Fetch courses from API (reactive to selectedCategory) const { data: coursesResponse, pending: isLoading, error, refresh } = await useAsyncData(
const { data: coursesResponse, error, refresh } = await useAsyncData(
'browse-courses-list', 'browse-courses-list',
() => { () => {
const params: any = {} const params: any = {}
console.log('Fetching courses. Selected Category:', selectedCategory.value)
if (selectedCategory.value !== 'all') { if (selectedCategory.value !== 'all') {
const category = categories.value.find(c => c.slug === selectedCategory.value) const category = categories.value.find(c => c.slug === selectedCategory.value)
console.log('Found Category:', category)
if (category) { if (category) {
params.category_id = category.id params.category_id = category.id
} }
} }
console.log('Params being sent to fetchCourses:', params)
return fetchCourses(params) return fetchCourses(params)
} }
) )
// Watch for category changes and refresh data watch(selectedCategory, () => { refresh() })
watch(selectedCategory, (newVal) => {
console.log('Selected Category Changed to:', newVal)
refresh()
})
// Ref for the scroll container const formatPrice = (course: any) => {
const categoryScroll = ref<HTMLElement | null>(null) if (course.is_free) return 'ฟรี';
if (!course.price) return 'ฟรี';
return `฿${parseFloat(course.price).toLocaleString()}`;
}
const getInstructorName = (course: any) => {
let user = null;
if (course.instructors && course.instructors.length > 0) {
const primary = course.instructors.find((i: any) => i.is_primary);
user = primary ? primary.user : course.instructors[0].user;
} else {
user = course.creator || course.instructor;
}
if (user?.profile?.first_name) {
return `${user.profile.first_name} ${user.profile.last_name || ''}`.trim();
}
if (user?.first_name) {
return `${user.first_name} ${user.last_name || ''}`.trim();
}
return user?.username || 'ผู้สอน';
}
// Computed property for courses list from API response
const courses = computed(() => { const courses = computed(() => {
if (coursesResponse.value?.success) { if (coursesResponse.value?.success && coursesResponse.value.data) {
return coursesResponse.value.data return coursesResponse.value.data.map((c: any) => {
const cat = categories.value.find((cat: any) => cat.id === c.category_id);
return {
...c,
category_name: cat ? getLocalizedText(cat.name) : '',
instructor_name: getInstructorName(c),
formatted_price: formatPrice(c),
rating: c.rating || '4.9',
reviews_count: c.total_lessons ? c.total_lessons * 123 : Math.floor(Math.random() * 2000) + 100
}
})
} }
return [] return []
}) })
/**
* @computed filteredCourses
* @description Filters the courses list based on the search query.
* Checks both the course title and description (case-insensitive).
*/
const filteredCourses = computed(() => { const filteredCourses = computed(() => {
const list = courses.value || [] const list = courses.value || []
if (!searchQuery.value) return list if (!searchQuery.value) return list
const query = searchQuery.value.toLowerCase() const query = searchQuery.value.toLowerCase()
return list.filter(c => { return list.filter((c: any) => {
const title = getLocalizedText(c.title).toLowerCase() const title = getLocalizedText(c.title).toLowerCase()
const desc = getLocalizedText(c.description).toLowerCase() const desc = getLocalizedText(c.description).toLowerCase()
return title.includes(query) || desc.includes(query) return title.includes(query) || desc.includes(query)
}) })
}) })
const viewMode = ref<'grid' | 'list'>('grid')
</script> </script>
<template> <template>
<!-- Main Container: Dark Theme Base --> <div class="bg-[#F8F9FA] dark:bg-[#020617] min-h-screen pt-32 pb-20 px-4 md:px-8 transition-colors duration-300">
<div class="relative min-h-screen text-slate-600 bg-slate-50 transition-colors"> <div class="max-w-[1240px] mx-auto">
<!-- Catalog View -->
<div class="bg-white dark:bg-slate-900 rounded-[2rem] p-6 md:p-8 shadow-[0_2px_15px_rgb(0,0,0,0.02)] border border-slate-100 dark:border-slate-800 min-h-[500px] mb-12">
<!-- ========================================== <!-- วนหวและการคนหา -->
BACKGROUND EFFECTS <div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
Ambient glows matching the index.vue theme <h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">คอรสเรยนทงหมด</h2>
========================================== --> <div class="flex flex-wrap sm:flex-nowrap items-center gap-3 w-full md:w-auto">
<div class="fixed inset-0 overflow-hidden pointer-events-none -z-10"> <div class="relative w-full sm:w-[260px] flex-1">
<div class="absolute top-[-20%] right-[-10%] w-[60%] h-[60%] rounded-full bg-blue-600/10 blur-[140px] animate-pulse-slow"/> <q-icon name="search" size="18px" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-[#3B6BE8]" />
<div class="absolute bottom-[-20%] left-[-10%] w-[60%] h-[60%] rounded-full bg-indigo-600/10 blur-[140px] animate-pulse-slow" style="animation-delay: 3s;"/> <input v-model="searchQuery" class="w-full bg-slate-100 dark:bg-slate-800 border-none rounded-xl py-2.5 pl-11 pr-4 text-sm font-medium text-slate-700 dark:text-slate-200 placeholder:text-slate-400 focus:ring-2 focus:ring-blue-500/20 outline-none transition-all shadow-sm" placeholder="ค้นหาคอร์ส..." />
</div>
<!-- ==========================================
HERO SECTION
Title and subtitle area
========================================== -->
<section class="relative pt-32 pb-20 px-6 overflow-hidden">
<div class="container mx-auto max-w-6xl text-center relative z-10">
<!-- Tagline Badge -->
<!-- Main Title -->
<h1 class="text-4xl md:text-6xl font-black text-slate-900 mb-6 tracking-tight py-2 slide-up leading-[1.2] overflow-visible" style="animation-delay: 0.1s;">
คอรสเรยนออนไลน<span class="text-gradient-cyan">งหมด</span>
</h1>
<!-- Subtitle -->
<p class="text-slate-400 text-xl max-w-2xl mx-auto leading-relaxed slide-up" style="animation-delay: 0.2s;">
เรมตนอปสกลของคณวนนวยหลกสตรคณภาพทออกแบบโดยผเชยวชาญในอตสาหกรรม
</p>
</div>
</section>
<!-- ==========================================
SEARCH & GRID SECTION
========================================== -->
<!-- ==========================================
SEARCH & GRID SECTION
========================================== -->
<section class="container mx-auto max-w-[1440px] px-6 pb-20">
<!-- Content Frame Container -->
<div class="glass-premium rounded-[3rem] p-8 md:p-12 shadow-xl shadow-blue-900/5">
<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>
</div> </div>
<div class="flex items-center gap-2 shrink-0">
<button @click="viewMode = 'grid'" :class="viewMode === 'grid' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-[#3B6BE8]' : 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700 text-slate-400 hover:bg-slate-50'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="grid_view" size="20px" /></button>
<button @click="viewMode = 'list'" :class="viewMode === 'list' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-[#3B6BE8]' : 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700 text-slate-400 hover:bg-slate-50'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="view_list" size="20px" /></button>
</div> </div>
</div> </div>
</div> </div>
<!-- Category Filter Tabs with Scroll Buttons --> <!-- Filters Category -->
<div class="relative mb-8"> <div class="flex flex-col xl:flex-row xl:items-center justify-between gap-4 mb-10 w-full relative">
<!-- Left Scroll Button --> <div class="flex flex-wrap items-center gap-3 w-full xl:w-auto">
<button <button
class="absolute left-0 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-white shadow-md border border-slate-100 flex items-center justify-center text-slate-500 hover:text-blue-600 transition-all md:hidden"
@click="categoryScroll?.scrollBy({ left: -200, behavior: 'smooth' })"
>
<q-icon name="chevron_left" size="24px" />
</button>
<!-- Scrollable Container -->
<div
ref="categoryScroll"
class="flex items-center gap-3 overflow-x-auto pb-2 no-scrollbar px-1 scroll-smooth"
>
<button
class="px-6 py-2.5 rounded-full font-bold text-sm transition-all whitespace-nowrap border-2 flex-shrink-0"
:class="selectedCategory === 'all' ? 'bg-blue-600 text-white border-blue-600 shadow-lg shadow-blue-600/30' : 'bg-white border-slate-100 text-slate-500 hover:border-slate-300'"
@click="selectCategory('all')" @click="selectCategory('all')"
> :class="selectedCategory === 'all' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'"
{{ categoryLabels.all }} class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none">
<q-icon name="check_circle_outline" size="18px" :class="selectedCategory === 'all' ? 'text-[#3B6BE8]' : 'text-slate-400'"/> งหมด
</button> </button>
<button <button
v-for="category in categories" v-for="cat in categories" :key="cat.id"
:key="category.id" @click="selectCategory(cat.slug)"
class="px-6 py-2.5 rounded-full font-bold text-sm transition-all whitespace-nowrap border-2 flex-shrink-0" :class="selectedCategory === cat.slug ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'"
:class="selectedCategory === category.slug ? 'bg-blue-600 text-white border-blue-600 shadow-lg shadow-blue-600/30' : 'bg-white border-slate-100 text-slate-500 hover:border-slate-300'" class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none bg-transparent">
@click="selectCategory(category.slug)" <q-icon :name="getCategoryIcon(cat.name)" size="18px" :class="selectedCategory === cat.slug ? 'text-[#3B6BE8]' : 'text-slate-600 dark:text-slate-400'"/>
> {{ getLocalizedText(cat.name) }}
{{ getCategoryLabel(category) }}
</button> </button>
</div> </div>
</div>
<!-- Right Scroll Button --> <!-- Loader -->
<button <div v-if="isLoading" class="flex justify-center py-24">
class="absolute right-0 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-white shadow-md border border-slate-100 flex items-center justify-center text-slate-500 hover:text-blue-600 transition-all md:hidden" <q-spinner size="3rem" color="primary" />
@click="categoryScroll?.scrollBy({ left: 200, behavior: 'smooth' })" </div>
>
<q-icon name="chevron_right" size="24px" /> <div v-else-if="filteredCourses.length > 0">
<!-- GRID VIEW -->
<div v-if="viewMode === 'grid'" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<NuxtLink v-for="course in filteredCourses" :key="course.id" :to="`/course/${course.id}`" class="flex flex-col rounded-[1.5rem] bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 overflow-hidden shadow-[0_2px_10px_rgb(0,0,0,0.03)] hover:shadow-[0_8px_30px_rgb(0,0,0,0.08)] transition-all duration-300 group cursor-pointer">
<!-- Thumbnail -->
<div class="relative w-full aspect-[16/10] bg-slate-100 dark:bg-slate-800 overflow-hidden">
<img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
<div v-if="course.category_name" class="absolute top-3 left-3 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md text-[#3B6BE8] dark:text-blue-400 font-bold text-[10px] px-3.5 py-1 rounded-full shadow-sm" style="border-radius: 9999px; padding: 4px 12px;">
{{ course.category_name }}
</div>
</div>
<!-- Body -->
<div class="p-5 flex flex-col flex-1">
<h3 class="font-bold text-slate-900 dark:text-white text-[15px] leading-snug line-clamp-2 mb-2 group-hover:text-blue-600 transition-colors">{{ getLocalizedText(course.title) }}</h3>
<div class="flex items-center gap-2 mb-4">
<span class="text-[12px] text-slate-500 font-medium">โดย {{ course.instructor_name }}</span>
</div>
<div class="flex items-center gap-1.5 mb-5">
<q-icon name="star" class="text-amber-400" size="16px" />
<span class="text-[13px] font-bold text-slate-800 dark:text-slate-200">{{ course.rating }}</span>
<span class="text-[12px] text-slate-400">({{ course.reviews_count.toLocaleString() }} กเรยน)</span>
</div>
<div class="mt-auto flex items-center justify-between">
<div class="font-[900] text-[18px]" :class="course.is_free ? 'text-green-500' : 'text-[#2563EB] dark:text-blue-400'">
{{ course.formatted_price }}
</div>
<!-- Eye icon circle button -->
<button class="w-[38px] h-[38px] rounded-full bg-slate-50 dark:bg-slate-800 text-slate-400 dark:text-slate-500 flex items-center justify-center hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-slate-700 border border-slate-100 dark:border-slate-700 transition-colors shadow-sm outline-none">
<q-icon name="visibility" size="18px" />
</button> </button>
</div> </div>
<!-- Course Grid (Updated to 4 cols) -->
<div v-if="filteredCourses.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div
v-for="(course, index) in filteredCourses"
:key="course.id"
class="glass-card group flex flex-col h-full hover:-translate-y-2 transition-transform duration-500 slide-up"
:style="{ animationDelay: `${0.1 * (index + 1)}s` }"
>
<!-- Card Image -->
<div class="h-48 bg-gradient-to-br from-slate-800 to-slate-900 relative overflow-hidden group-hover:opacity-90 transition-opacity">
<img
v-if="course.thumbnail_url"
:src="course.thumbnail_url"
:alt="getLocalizedText(course.title)"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
/>
<div
v-else
class="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-slate-800 to-slate-900"
>
</div> </div>
</NuxtLink>
</div> </div>
<!-- Card Content Body --> <!-- LIST VIEW -->
<div class="p-6 flex-1 flex flex-col border-t border-slate-100 "> <div v-else class="flex flex-col gap-5">
<h3 class="text-xl font-bold text-slate-900 mb-2 leading-snug group-hover:text-blue-600 transition-colors line-clamp-2"> <NuxtLink v-for="course in filteredCourses" :key="course.id" :to="`/course/${course.id}`" class="flex flex-col sm:flex-row rounded-[1.5rem] bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 p-3 sm:p-4 gap-4 sm:gap-6 shadow-sm hover:shadow-[0_8px_30px_rgb(0,0,0,0.06)] transition-all duration-300 group cursor-pointer">
{{ getLocalizedText(course.title) }} <div class="relative w-full sm:w-[260px] aspect-[16/10] sm:aspect-auto sm:h-[160px] rounded-[1rem] bg-slate-100 dark:bg-slate-800 overflow-hidden shrink-0">
</h3> <img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
<p class="text-slate-500 text-xs mb-6 line-clamp-2 leading-relaxed flex-1"> <div v-if="course.category_name" class="absolute top-3 left-3 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md text-[#3B6BE8] dark:text-blue-400 font-bold text-[10px] px-3.5 py-1.5 rounded-full shadow-sm" style="border-radius: 9999px; padding: 4px 12px;">
{{ getLocalizedText(course.description) }} {{ course.category_name }}
</p> </div>
</div>
<!-- Card Footer --> <div class="flex flex-col flex-1 py-1">
<div class="pt-4 border-t border-slate-100 flex items-center justify-between mt-auto"> <div class="flex-1">
<span class="text-lg font-black text-blue-600 tracking-tight"> <h3 class="font-bold text-slate-900 dark:text-white text-[16px] md:text-[18px] leading-snug line-clamp-2 md:line-clamp-1 mb-2 group-hover:text-blue-600 transition-colors">{{ getLocalizedText(course.title) }}</h3>
{{ course.is_free ? 'ฟรี' : course.price }} <div class="flex items-center gap-2 mb-3">
</span> <span class="text-[13px] text-slate-500 font-medium">โดย {{ course.instructor_name }}</span>
<NuxtLink </div>
:to="`/course/${course.id}`" <div class="flex items-center gap-1.5 mb-2">
class="px-4 py-2 bg-slate-50 hover:bg-blue-600 text-slate-600 hover:text-white rounded-lg text-xs font-bold transition-all border border-slate-200 hover:border-blue-500/50" <q-icon name="star" class="text-amber-400" size="16px" />
> <span class="text-[13px] font-bold text-slate-800 dark:text-slate-200">{{ course.rating }}</span>
รายละเอยด <span class="text-[12px] text-slate-400">({{ course.reviews_count.toLocaleString() }} กเรยน)</span>
</div>
</div>
<div class="mt-4 sm:mt-auto flex items-center justify-between">
<div class="font-[900] text-[20px]" :class="course.is_free ? 'text-green-500' : 'text-[#2563EB] dark:text-blue-400'">
{{ course.formatted_price }}
</div>
<button class="px-6 py-2 rounded-full bg-slate-50 text-slate-600 dark:bg-slate-800 dark:text-slate-300 font-bold text-[13px] flex items-center gap-2 hover:bg-blue-50 border border-slate-100 dark:border-slate-700 hover:text-blue-600 transition-colors">
<q-icon name="visibility" size="16px" /> รายละเอยด
</button>
</div>
</div>
</NuxtLink> </NuxtLink>
</div> </div>
</div> </div>
</div>
</div>
<!-- Empty State (No Results) --> <!-- Empty State -->
<div v-else class="text-center py-20"> <div v-else 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-slate-800">
<div class="text-6xl mb-6 opacity-50 animate-bounce">🔭</div> <q-icon name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" />
<h2 class="text-2xl font-black text-slate-900 mb-3">ไมพบคอรสทณคนหา</h2> <h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ searchQuery ? 'ไม่พบคอร์สที่คุณค้นหา' : 'ไม่มีคอร์สในหมวดหมู่นี้' }}</h3>
<p class="text-slate-400 mb-8 max-w-md mx-auto"> <p class="text-slate-500 dark:text-slate-400 text-center max-w-md">ลองใชคำคนหาอ หรอเลอกหมวดหมนเพอดคอรสทเรามใหบรการ</p>
ลองใชคำคนหาอ หรอดคอรสทงหมดทเรามใหบรการ <button class="mt-6 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''; selectedCategory = 'all';">
</p>
<button
class="px-6 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition-all shadow-lg shadow-blue-600/20"
@click="searchQuery = ''"
>
แสดงคอรสทงหมด แสดงคอรสทงหมด
</button> </button>
</div> </div>
</div> </div>
</section>
<!-- ==========================================
CTA SECTION
Call to action to register
========================================== -->
<section class="py-24 relative overflow-hidden">
<!-- Background Decoration -->
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-blue-50/80 pointer-events-none -z-10"/>
<div class="absolute bottom-0 left-1/2 -translate-x-1/2 w-[800px] h-[400px] bg-blue-400/10 blur-[120px] rounded-full -z-10 pointer-events-none"/>
<div class="container mx-auto max-w-4xl text-center relative z-10 px-6">
<h2 class="text-4xl md:text-5xl font-black text-slate-900 mb-6 tracking-tight">
พรอมจะเรมตนแลวหรอย?
</h2>
<p class="text-slate-500 text-lg md:text-xl mb-10 max-w-2xl mx-auto leading-relaxed">
ลงทะเบยนฟรนนเพอเขาถงบทเรยนพนฐาน และตดตามความคบหนาการเรยนของคณไดนท ไมาใชายแอบแฝง
</p>
<NuxtLink
to="/auth/register"
class="inline-flex items-center gap-3 px-10 py-5 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white rounded-2xl text-lg font-black shadow-2xl shadow-blue-900/40 hover:scale-105 transition-all duration-300"
>
<span></span>
<span>สมครสมาชกฟร</span>
</NuxtLink>
</div> </div>
</section>
</div> </div>
</template> </template>
<style scoped> <style scoped>
/* /* Disable default scrollbar for filter container */
MATCHING INDEX.VUE STYLES .no-scrollbar::-webkit-scrollbar {
These styles ensure consistency with the landing page theme. display: none;
*/
/* Gradient Text Effect (Cyan to Blue) */
.text-gradient-cyan {
background: linear-gradient(135deg, #22d3ee 0%, #3b82f6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
display: inline-block;
padding: 0.5em 0.2em;
margin: -0.5em -0.2em;
vertical-align: baseline;
} }
.no-scrollbar {
/* Premium Glass Effect for Containers */ -ms-overflow-style: none;
.glass-premium { scrollbar-width: none;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(40px);
border: 1px solid rgba(255, 255, 255, 0.5);
}
/* Glass Card Style for Course Items */
.glass-card {
background: white;
border: 1px solid rgba(0, 0, 0, 0.05);
border-radius: 2rem;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
}
/* Slow Pulse Animation for Background Glows */
@keyframes pulse-slow {
0%, 100% { opacity: 0.1; transform: scale(1); }
50% { opacity: 0.15; transform: scale(1.15); }
}
.animate-pulse-slow {
animation: pulse-slow 10s linear infinite;
}
/* Slide Up Entrance Animation */
@keyframes slide-up {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
.slide-up {
animation: slide-up 0.8s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
opacity: 0;
} }
</style> </style>

View file

@ -113,7 +113,7 @@ const filteredCourses = computed(() => {
<!-- Tagline Badge --> <!-- Tagline Badge -->
<!-- Main Title --> <!-- Main Title -->
<h1 class="text-4xl md:text-6xl font-black text-slate-900 mb-6 tracking-tight py-2 slide-up leading-[1.2] overflow-visible" style="animation-delay: 0.1s;"> <h1 class="text-4xl md:text-6xl font-black text-slate-900 mb-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> </h1>
<!-- Subtitle --> <!-- Subtitle -->
<p class="text-slate-400 text-xl max-w-2xl mx-auto leading-relaxed slide-up" style="animation-delay: 0.2s;"> <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 --> <!-- Content Frame Container -->
<div class="glass-premium rounded-[3rem] p-8 md:p-12 shadow-xl shadow-blue-900/5"> <div class="glass-premium rounded-[3rem] p-8 md:p-12 shadow-xl shadow-blue-900/5">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-8 mb-8"> <!-- New Enhanced Search Section (Image 2 Style) -->
<h2 class="text-2xl font-black text-slate-900 flex items-center gap-3"> <div class="bg-blue-50/50 rounded-[2.5rem] p-8 md:p-10 mb-6 border border-blue-100/50">
<span class="w-2 h-8 bg-blue-600 rounded-full"/> <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>
</h2>
<!-- Search Bar (Compact) --> <div class="flex flex-col md:flex-row gap-4">
<div class="relative max-w-md w-full"> <!-- Search Input -->
<div class="relative group"> <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 <input
v-model="searchQuery" v-model="searchQuery"
type="text" type="text"
class="w-full pl-12 pr-6 py-3 bg-slate-100 border border-slate-200 rounded-xl text-slate-900 placeholder-slate-400 focus:outline-none focus:bg-white focus: focus:ring-2 focus:ring-blue-500/50 transition-all font-medium" placeholder="ค้นหาชื่อคอร์สแนะนำ..."
placeholder="ค้นหาคอร์สแนะนำ..." class="w-full pl-14 pr-6 py-4 bg-white border-2 border-transparent rounded-2xl text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blue-500/20 focus:ring-4 focus:ring-blue-500/5 transition-all text-lg font-medium shadow-sm"
/>
</div>
<!-- Search Button -->
<q-btn
unelevated
color="primary"
class="px-4 h-16 rounded-2xl font-black shadow-lg shadow-blue-600/20 hover:scale-[1.02] transition-transform"
no-caps
> >
<div class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400"> <div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <q-icon name="search" size="20px" />
<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" /> <span class="text-base">นหา</span>
</svg>
</div>
</div> </div>
</q-btn>
</div> </div>
</div> </div>

View file

@ -90,6 +90,7 @@ const isPlaying = ref(false)
const videoProgress = ref(0) const videoProgress = ref(0)
const currentTime = ref(0) const currentTime = ref(0)
const duration = ref(0) const duration = ref(0)
const activeTab = ref('content')
@ -604,60 +605,67 @@ onBeforeUnmount(() => {
</script> </script>
<template> <template>
<q-layout view="hHh LpR lFf" class="bg-[var(--bg-body)] text-[var(--text-main)]"> <q-layout view="hHh lpR lFf" class="bg-[var(--bg-body)] text-[var(--text-main)]">
<!-- Header --> <!-- Header -->
<q-header bordered class="bg-[var(--bg-surface)] border-b border-gray-200 dark:border-white/5 text-[var(--text-main)] h-14"> <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> <q-toolbar class="h-full px-4">
<!-- Exit/Back Button --> <!-- 1. Left Side: Back & Title -->
<q-btn <div class="flex items-center gap-4 flex-grow overflow-hidden">
flat <!-- Back Button -->
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>
<!-- 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>
<div class="flex items-center gap-2 pr-2">
<!-- Announcements Button -->
<q-btn <q-btn
flat flat
round round
dense dense
icon="campaign" color="primary"
@click="handleOpenAnnouncements" class="bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
class="text-slate-600 dark:text-slate-300 hover:text-blue-600 transition-colors" @click="handleExit('/dashboard/my-courses')"
> >
<q-badge v-if="hasUnreadAnnouncements" color="red" floating rounded /> <q-icon name="arrow_back" size="20px" />
<q-tooltip>{{ $t('classroom.backToDashboard') }}</q-tooltip>
</q-btn>
<!-- Course Title -->
<div class="flex flex-col">
<h1 class="text-base md:text-lg font-bold text-slate-900 dark:text-white truncate max-w-[200px] md:max-w-md leading-tight">
{{ courseData ? getLocalizedText(courseData.course.title) : $t('classroom.loadingTitle') }}
</h1>
</div>
</div>
<!-- 2. Right Side: Actions -->
<div class="flex items-center gap-3">
<!-- Sidebar Toggle (Right Side) -->
<q-btn
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
class="bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-slate-700 transition-all relative overflow-visible"
@click="handleOpenAnnouncements"
>
<q-icon name="campaign" size="22px" />
<!-- Red Dot Notification -->
<span v-if="hasUnreadAnnouncements" class="absolute top-2 right-2 w-2.5 h-2.5 bg-rose-500 border-2 border-white dark:border-slate-900 rounded-full"></span>
<q-tooltip>{{ $t('classroom.announcements') }}</q-tooltip> <q-tooltip>{{ $t('classroom.announcements') }}</q-tooltip>
</q-btn> </q-btn>
</div> </div>
</q-toolbar> </q-toolbar>
</q-header> </q-header>
<!-- Sidebar (Curriculum) --> <!-- Sidebar (Curriculum) - Positioned Right via component prop -->
<!-- Sidebar (Curriculum) -->
<CurriculumSidebar <CurriculumSidebar
v-model="sidebarOpen" v-model="sidebarOpen"
:courseData="courseData" :courseData="courseData"
@ -672,7 +680,7 @@ onBeforeUnmount(() => {
<q-page-container class="bg-white dark:bg-slate-900"> <q-page-container class="bg-white dark:bg-slate-900">
<q-page class="flex flex-col h-full bg-slate-50 dark:bg-[#0B0F1A]"> <q-page class="flex flex-col h-full bg-slate-50 dark:bg-[#0B0F1A]">
<!-- Video Player & Content Area --> <!-- Video Player & Content Area -->
<div class="w-full 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) --> <!-- 1. LOADING STATE (Comprehensive Skeleton) -->
<div v-if="isLessonLoading" class="animate-fade-in"> <div v-if="isLessonLoading" class="animate-fade-in">
<!-- Video Skeleton --> <!-- Video Skeleton -->

View file

@ -1,243 +1,322 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file index.vue * @file index.vue
* @description Dashboard Home Page matching FutureSkill design * @description หนาหลกแดชบอร (Dashboard Home)
*/ */
// 1. MetaData
definePageMeta({ definePageMeta({
layout: 'default', layout: "default",
middleware: 'auth' middleware: "auth",
}) });
useHead({ useHead({
title: 'Dashboard - FutureSkill Clone' title: "Dashboard - e-Learning Platform",
}) });
const { currentUser } = useAuth() // 2. Composables
const { fetchCourses, fetchEnrolledCourses, getLocalizedText } = useCourse() const { currentUser } = useAuth();
const { fetchCategories } = useCategory() const { fetchCourses, fetchEnrolledCourses, getLocalizedText } = useCourse();
const { t } = useI18n() const { fetchCategories } = useCategory();
const { t } = useI18n();
// State // 3. (State)
const enrolledCourses = ref<any[]>([]) const enrolledCourses = ref<any[]>([]);
const recommendedCourses = ref<any[]>([]) const recommendedCourses = ref<any[]>([]);
const libraryCourses = ref<any[]>([]) const libraryCourses = ref<any[]>([]);
const categories = ref<any[]>([]) const categories = ref<any[]>([]);
const isLoading = ref(true);
const isLoading = ref(true) // 4. (Data Initialization)
// Initial Data Fetch
onMounted(async () => { onMounted(async () => {
isLoading.value = true isLoading.value = true;
try { try {
const [catRes, enrollRes, courseRes] = await Promise.all([ const [catRes, enrollRes, courseRes, allCoursesRes] = await Promise.all([
fetchCategories(), fetchCategories(),
fetchEnrolledCourses({ limit: 3, status: 'IN_PROGRESS' }), // Fetch recent enrolled fetchEnrolledCourses({ limit: 10 }), //
fetchCourses({ limit: 3, random: true, forceRefresh: true, is_recommended: true }) // Fetch 3 Recommended Courses fetchCourses({
]) limit: 3,
random: true,
forceRefresh: true,
is_recommended: true,
}), //
fetchCourses({ limit: 1000 }) //
]);
if (catRes.success) { if (catRes.success) {
categories.value = catRes.data || [] categories.value = catRes.data || [];
} }
const catMap = new Map() const catMap = new Map();
categories.value.forEach((c: any) => catMap.set(c.id, c.name)) categories.value.forEach((c: any) => catMap.set(c.id, c.name));
const catIdMap = new Map();
if (allCoursesRes && allCoursesRes.success && allCoursesRes.data) {
allCoursesRes.data.forEach((c: any) => catIdMap.set(c.id, c.category_id));
}
// Map Enrolled Courses // (Mapping Enrolled Courses)
if (enrollRes.success && enrollRes.data) { if (enrollRes.success && enrollRes.data) {
enrolledCourses.value = enrollRes.data.map((item: any) => ({ 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) => {
const mappedCategoryId = catIdMap.get(item.course.id) || item.course.category_id;
return {
id: item.course_id, id: item.course_id,
title: item.course.title, title: item.course.title,
thumbnail_url: item.course.thumbnail_url, thumbnail_url: item.course.thumbnail_url,
progress: item.progress_percentage || 0, progress: item.progress_percentage || 0,
total_lessons: item.course.total_lessons || 10, total_lessons: item.course.total_lessons || 0,
completed_lessons: Math.floor((item.progress_percentage / 100) * (item.course.total_lessons || 10)) completed_lessons: Math.floor(
})) (item.progress_percentage / 100) * (item.course.total_lessons || 0),
),
category: catMap.get(mappedCategoryId),
lessons: item.course.total_lessons || 0,
image: item.course.thumbnail_url,
enrolled: true,
instructor: item.course.creator || item.course.instructor,
last_accessed: item.last_accessed_at || item.enrolled_at
};
});
libraryCourses.value = enrolledCourses.value.slice(0, 2);
} }
// Map Recommended/Library Courses // (Mapping Recommended Courses)
if (courseRes.success && courseRes.data) { if (courseRes.success && courseRes.data) {
// Use fetched courses for recommended section
recommendedCourses.value = courseRes.data.map((c: any) => ({ recommendedCourses.value = courseRes.data.map((c: any) => ({
id: c.id, id: c.id,
title: c.title, title: c.title,
category: catMap.get(c.category_id), category: catMap.get(c.category_id),
description: c.description, description: c.description,
lessons: c.total_lessons || 0, lessons: c.total_lessons || 0,
image: c.thumbnail_url || '', image: c.thumbnail_url || "",
rating: c.rating, rating: c.rating,
price: c.price, price: c.price,
is_free: c.is_free 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)
} }
} catch (err) { } catch (err) {
console.error('Failed to load dashboard data', err) console.error("Failed to load dashboard data", err);
} finally { } finally {
isLoading.value = false isLoading.value = false;
} }
}) });
// Helper for "Continue Learning" Hero Card // 5. Computed
const heroCourse = computed(() => enrolledCourses.value[0] || null) const heroCourse = computed(() => enrolledCourses.value[0] || null);
const sideCourses = computed(() => enrolledCourses.value.slice(1, 3)) const sideCourses = computed(() => enrolledCourses.value.slice(1, 3));
const navigateToCategory = (catName: string) => {
const cat = categories.value.find(c => getLocalizedText(c.name) === catName);
if (cat) {
navigateTo(`/browse/discovery?category_id=${cat.id}`);
} else {
navigateTo(`/browse/discovery`);
}
}
</script> </script>
<template> <template>
<div class="bg-[#F8F9FA] min-h-screen font-inter pb-20"> <div class="bg-[#F8F9FA] dark:bg-[#020617] min-h-screen font-inter p-4 md:p-8 transition-colors duration-300">
<div class="max-w-[1400px] mx-auto grid grid-cols-1 xl:grid-cols-3 gap-8">
<!-- 1. Greeting Section --> <!-- Left Column (Main Content) -->
<section class="bg-white pt-8 pb-10 px-4 md:px-12"> <div class="xl:col-span-2 space-y-6">
<div class="max-w-7xl mx-auto">
<h1 class="text-3xl md:text-4xl font-bold text-[#2D2D2D] mb-4 flex items-center gap-3"> <!-- ายตอนร (Welcome Banner) -->
สวสด {{ currentUser?.firstName || 'User' }} <span class="text-3xl">😊</span> <div class="bg-[#3B6BE8] rounded-[2rem] p-6 md:p-10 relative overflow-hidden text-white shadow-[0_8px_30px_rgb(59,107,232,0.2)]">
</h1> <!-- ลวดลายพนหลงและดาวตกแต -->
<p class="text-gray-500 text-base md:text-lg font-light max-w-3xl"> <div class="absolute inset-0 bg-grid-pattern opacity-10 md:opacity-20 pointer-events-none"></div>
เขาถ +490 หลกสตรออนไลนสำหรบสมาชกรายป เพอบรรลเปาหมายดานอาชพและพฒนาการเรยนรสำหรบค <div class="absolute right-5 md:right-10 top-1/2 -translate-y-1/2 w-20 h-20 md:w-28 md:h-28 border border-white/20 rounded-[1.5rem] md:rounded-[2rem] flex items-center justify-center rotate-12 bg-white/5 backdrop-blur-sm opacity-30 md:opacity-100">
</p> <q-icon name="auto_awesome" size="32px" md-size="48px" class="text-white" />
</div> </div>
</section>
<div class="max-w-7xl mx-auto px-4 md:px-12 space-y-16 mt-10"> <div class="relative z-10 max-w-lg">
<h1 class="text-2xl md:text-4xl font-bold mb-2 md:mb-3 tracking-tight">{{ $t('dashboard.welcomeTitle') }} {{ currentUser?.firstName || 'User' }} !</h1>
<p class="text-blue-100/90 text-[13px] md:text-[15px] leading-relaxed mb-6 md:mb-8 font-medium">
{{ $t('dashboard.welcomeSubtitle') }}
</p>
<button @click="navigateTo('/browse/discovery')" class="bg-white text-[#3B6BE8] font-bold px-5 py-2.5 md:px-6 md:py-3 rounded-full text-xs md:text-sm flex items-center gap-2 hover:bg-slate-50 hover:scale-105 shadow-md transition-all">
{{ $t('dashboard.moreCourses') }} <q-icon name="chevron_right" size="16px" />
</button>
</div>
</div>
<!-- 2. Continue Learning Section --> <!-- การดหมวดหมวน (3 Stats Cards) -->
<section v-if="enrolledCourses.length > 0"> <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div class="flex justify-between items-end mb-6"> <!-- การออกแบบ -->
<h2 class="text-xl md:text-2xl font-bold text-[#2D2D2D]">เรยนตอกบคอรสของค</h2> <div @click="navigateToCategory('การออกแบบ')" class="bg-white dark:!bg-slate-900 rounded-[1.5rem] p-5 flex items-center gap-4 shadow-sm border border-slate-100 dark:border-slate-800 transition-all hover:scale-105 hover:shadow-md cursor-pointer">
<NuxtLink to="/dashboard/my-courses" class="text-purple-600 hover:text-purple-700 font-medium text-sm flex items-center gap-1"> <div class="w-12 h-12 rounded-2xl bg-[#E9EFFD] dark:bg-blue-900/30 text-[#3B6BE8] flex items-center justify-center shrink-0">
การเรยนของฉ <q-icon name="arrow_forward" size="16px" /> <q-icon name="palette" size="24px" />
</div>
<div>
<h3 class="font-bold text-slate-800 dark:text-slate-200 text-sm">{{ $t('discovery.design') }}</h3>
</div>
</div>
<!-- การเขยนโปรแกรม -->
<div @click="navigateToCategory('การเขียนโปรแกรม')" class="bg-white dark:!bg-slate-900 rounded-[1.5rem] p-5 flex items-center gap-4 shadow-sm border border-slate-100 dark:border-slate-800 transition-all hover:scale-105 hover:shadow-md cursor-pointer">
<div class="w-12 h-12 rounded-2xl bg-[#FFF3EB] dark:bg-orange-900/30 text-[#FF8A4C] flex items-center justify-center shrink-0">
<q-icon name="code" size="24px" />
</div>
<div>
<h3 class="font-bold text-slate-800 dark:text-slate-200 text-sm">{{ $t('discovery.programming') }}</h3>
</div>
</div>
<!-- รก -->
<div @click="navigateToCategory('ธุรกิจ')" class="bg-white dark:!bg-slate-900 rounded-[1.5rem] p-5 flex items-center gap-4 shadow-sm border border-slate-100 dark:border-slate-800 transition-all hover:scale-105 hover:shadow-md cursor-pointer">
<div class="w-12 h-12 rounded-2xl bg-[#EBFAF6] dark:bg-emerald-900/30 text-[#10B981] flex items-center justify-center shrink-0">
<q-icon name="work_outline" size="24px" />
</div>
<div>
<h3 class="font-bold text-slate-800 dark:text-slate-200 text-sm">{{ $t('discovery.business') }}</h3>
</div>
</div>
</div>
<!-- Continue Learning (เรยนตอจากครงกอน) -->
<div class="bg-white dark:!bg-slate-900 rounded-[2rem] p-6 md:p-8 shadow-sm border border-slate-100 dark:border-slate-800 transition-colors">
<div class="flex items-center justify-between mb-6">
<h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">{{ $t('dashboard.continueLearningTitle') }}</h2>
<NuxtLink to="/dashboard/my-courses" class="text-[#3B6BE8] font-bold text-sm flex items-center gap-1 hover:underline">
{{ $t('dashboard.viewAll') }}
</NuxtLink> </NuxtLink>
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div v-if="heroCourse" class="bg-[#F8F9FA] dark:bg-slate-800/50 rounded-3xl p-4 md:p-6 flex flex-col md:flex-row gap-6 md:gap-8 items-center border border-slate-100 dark:border-slate-800">
<!-- Hero Card (Left) --> <div class="w-full md:w-[35%] aspect-[4/3] rounded-[1.5rem] overflow-hidden flex-shrink-0 bg-slate-200">
<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" />
<img :src="heroCourse.thumbnail_url" class="w-full h-full object-cover brightness-75 group-hover:brightness-90 transition-all duration-500" /> </div>
<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="flex-1 w-full flex flex-col">
<div class="bg-purple-600 text-white text-xs font-bold px-3 py-1 rounded w-fit mb-3">COURSE</div> <div class="flex items-center justify-between mb-3">
<h3 class="text-white text-2xl font-bold mb-4 line-clamp-2 leading-snug">{{ getLocalizedText(heroCourse.title) }}</h3> <!-- Category Badge -->
<div v-if="heroCourse.category">
<span class="bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] dark:text-blue-400 px-3 py-1 rounded-full text-[11px] font-bold tracking-wide">{{ getLocalizedText(heroCourse.category) }}</span>
</div>
<div v-else></div>
<span class="text-slate-400 dark:text-slate-400 text-xs flex items-center gap-1.5 font-medium" v-if="heroCourse.last_accessed">
<q-icon name="schedule" size="14px" /> {{ $t('common.latest') }} {{ new Date(heroCourse.last_accessed).toLocaleDateString('th-TH', { day: 'numeric', month: 'short' }) }}
</span>
</div>
<!-- Progress --> <h3 class="text-2xl font-bold text-slate-900 dark:text-white mb-2 leading-snug line-clamp-2">
<div class="w-full"> {{ getLocalizedText(heroCourse.title) || 'Advanced UI/UX Design มาสเตอร์คลาส' }}
<div class="flex justify-between text-gray-300 text-xs mb-2"> </h3>
<span>{{ heroCourse.completed_lessons }}/{{ heroCourse.total_lessons }} บทเรยน</span> <!-- Removed Lesson Title/Number as per request -->
<span>{{ heroCourse.progress }}%</span>
<div class="mb-6 mt-4">
<div class="flex justify-between text-[13px] font-bold mb-2">
<span class="text-[#3B6BE8] dark:text-blue-400">{{ $t('course.progress') }}: {{ heroCourse.progress || 0 }}%</span>
</div> </div>
<div class="h-1.5 w-full bg-white/20 rounded-full overflow-hidden"> <div class="h-2.5 w-full bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
<div class="h-full bg-purple-500 rounded-full" :style="{ width: `${heroCourse.progress}%` }"></div> <div class="h-full bg-[#3B6BE8] rounded-full transition-all duration-500" :style="{ width: `${heroCourse.progress || 0}%` }"></div>
</div> </div>
<div class="mt-4 flex justify-end">
<span class="text-white font-bold text-sm hover:underline">เรยนต</span>
</div> </div>
<div class="flex items-center justify-between mt-auto">
<div class="flex items-center gap-3" v-if="heroCourse.instructor">
<div class="w-10 h-10 rounded-full bg-orange-100 overflow-hidden shrink-0 border border-slate-200 dark:border-slate-700">
<img :src="heroCourse.instructor.profile_image_url || heroCourse.instructor.photoURL || `https://api.dicebear.com/7.x/avataaars/svg?seed=${heroCourse.instructor.username || 'Inst'}`" class="w-full h-full object-cover" />
</div>
<div>
<p class="font-bold text-slate-900 dark:text-white text-[13px] leading-tight mb-0.5">
{{ heroCourse.instructor.firstName || heroCourse.instructor.first_name ? `${heroCourse.instructor.firstName || heroCourse.instructor.first_name} ${heroCourse.instructor.lastName || heroCourse.instructor.last_name || ''}` : heroCourse.instructor.username || 'ผู้สอน' }}
</p>
<p class="text-slate-500 dark:text-slate-400 text-[11px] font-medium line-clamp-1">{{ heroCourse.instructor.bio || heroCourse.instructor.role?.name?.th || 'Instructor' }}</p>
</div>
</div>
<div v-else class="flex items-center gap-3"></div>
<button @click="navigateTo(`/classroom/learning?course_id=${heroCourse.id}`)" class="bg-[#3B6BE8] hover:bg-blue-700 text-white px-5 py-2.5 rounded-full font-bold text-sm flex items-center gap-2 shadow-lg shadow-blue-500/20 transition-all hover:scale-105 shrink-0">
<q-icon name="play_circle" size="18px" /> {{ $t('course.continueLearning') }}
</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Side List (Right) --> <div v-else class="bg-[#F8F9FA] dark:bg-slate-800/50 rounded-3xl p-10 flex items-center justify-center text-slate-400 border border-dashed border-slate-200 dark:border-slate-700">
<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-purple-600 rounded-full" :style="{ width: `${course.progress}%` }"></div>
</div>
<div class="flex justify-between items-center text-xs">
<span class="text-gray-500">{{ course.completed_lessons }}/{{ course.total_lessons }} บทเรยน</span>
<span class="text-purple-600 font-bold cursor-pointer hover:underline" @click="navigateTo(`/classroom/learning?course_id=${course.id}`)">เรียนต่อ</span>
</div> </div>
</div> </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>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6"> <!-- Right Column (Sidebar/Profile Content) -->
<!-- Course Cards --> <div class="xl:col-span-1 space-y-6">
<CourseCard <!-- Profile Widget -->
v-for="course in libraryCourses" <div class="bg-white dark:!bg-slate-900 rounded-[2rem] p-8 shadow-sm border border-slate-100 dark:border-slate-800 text-center flex flex-col items-center relative overflow-hidden transition-colors">
:key="course.id" <!-- decorative bg -->
v-bind="course" <div class="absolute top-0 left-0 right-0 h-24 bg-gradient-to-b from-[#F8FAFC] to-white dark:from-slate-800 dark:to-slate-900"></div>
:image="course.thumbnail_url"
class="h-full md:col-span-1" <div class="relative z-10 w-24 h-24 rounded-full bg-white dark:bg-slate-800 mb-4 shadow-md flex items-center justify-center">
<UserAvatar
:photo-u-r-l="currentUser?.photoURL"
:first-name="currentUser?.firstName || 'ผู้ใช้งาน'"
:last-name="currentUser?.lastName"
size="88"
class="rounded-full object-cover"
/> />
<!-- 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-purple-600 hover:bg-purple-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>
</section>
<!-- 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> </div>
<!-- Recommended Grid (3 columns) --> <h2 class="text-xl font-bold text-slate-900 dark:text-white relative z-10 tracking-tight">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 animate-fade-in"> {{ currentUser?.firstName ? `${currentUser.firstName} ${currentUser.lastName || ''}` : 'ผู้ใช้งาน' }}
<CourseCard </h2>
v-for="course in recommendedCourses" <p class="text-slate-500 dark:text-slate-400 text-xs mt-1 relative z-10 font-medium tracking-wide">{{ $t('common.student') }}</p>
:key="course.id"
v-bind="course" <div class="flex w-full gap-3 mt-7 relative z-10 px-2">
/> <div class="flex-1 bg-[#F8FAFC] dark:bg-slate-800 rounded-2xl p-3.5 flex flex-col items-center justify-center transition-colors shadow-sm">
<span class="text-[1.35rem] font-black text-[#3B6BE8] dark:text-blue-400 mb-1 leading-none">{{ String(enrolledCourses.length || 0).padStart(2, '0') }}</span>
<span class="text-slate-400 text-[10px] font-bold tracking-wider">{{ $t('myCourses.filterProgress') }}</span>
</div>
<div class="flex-1 bg-[#F8FAFC] dark:bg-slate-800 rounded-2xl p-3.5 flex flex-col items-center justify-center transition-colors shadow-sm">
<span class="text-[1.35rem] font-black text-[#10B981] dark:text-emerald-400 mb-1 leading-none z-10">{{ String(enrolledCourses.filter(c => c.progress >= 100).length || 0).padStart(2, '0') }}</span>
<span class="text-slate-400 text-[10px] font-bold tracking-wider z-10">{{ $t('myCourses.filterCompleted') }}</span>
</div>
</div>
</div> </div>
<!-- Loading State --> <!-- Recommended Courses Widget -->
<div v-if="recommendedCourses.length === 0 && !isLoading" class="flex justify-center py-10 opacity-50"> <div v-if="recommendedCourses.length > 0" class="bg-white dark:!bg-slate-900 rounded-[2rem] p-6 shadow-sm border border-slate-100 dark:border-slate-800 transition-colors">
<div class="text-gray-400">ไมพบขอมลคอรสแนะนำ</div> <h2 class="text-[1.1rem] font-bold text-slate-900 dark:text-white mb-5 tracking-tight flex items-center justify-between">
{{ $t('dashboard.recommendedCourses') }}
</h2>
<div class="flex flex-col gap-5">
<div v-for="course in recommendedCourses.slice(0, 3)" :key="course.id" class="flex gap-4 group cursor-pointer transition-all" @click="navigateTo(`/browse/discovery?course_id=${course.id}`)">
<!-- Thumbnail -->
<div class="w-24 h-[68px] rounded-xl overflow-hidden bg-slate-100 shrink-0 relative shadow-sm">
<img :src="course.image" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
</div>
<!-- Info -->
<div class="flex-1 flex flex-col justify-center min-w-0">
<h3 class="font-bold text-[13px] text-slate-900 dark:text-white leading-snug line-clamp-2 mb-1.5 group-hover:text-[#3B6BE8] transition-colors pr-1">{{ getLocalizedText(course.title) }}</h3>
<div class="flex items-center justify-between mt-auto">
<span class="text-slate-500 dark:text-slate-400 text-[11px] font-medium bg-slate-100 dark:bg-slate-800 px-2 py-0.5 rounded text-ellipsis overflow-hidden whitespace-nowrap max-w-[80px]">{{ getLocalizedText(course.category) || 'อื่นๆ' }}</span>
<span v-if="course.is_free" class="text-[#10B981] font-bold text-[12px]">ฟรี</span>
<span v-else class="text-[#3B6BE8] font-bold text-[12px]">฿{{ Number(course.price).toLocaleString() }}</span>
</div>
</div>
</div>
</div> </div>
</section>
<button @click="navigateTo('/browse/discovery')" class="w-full mt-6 py-2.5 rounded-xl text-[13px] font-bold text-[#3B6BE8] dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30 hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors">
{{ $t('dashboard.viewAllCourses') }}
</button>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
/* Scoped specific styles */ .bg-grid-pattern {
.animate-fade-in { background-image:
animation: fadeIn 0.5s ease-out; linear-gradient(to right, rgba(255,255,255,0.08) 1px, transparent 1px),
} linear-gradient(to bottom, rgba(255,255,255,0.08) 1px, transparent 1px);
@keyframes fadeIn { background-size: 24px 24px;
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
:deep(.q-btn) {
text-transform: none; /* Prevent uppercase in Q-Btns */
} }
</style> </style>

View file

@ -1,293 +1,369 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file my-courses.vue * @file my-courses.vue
* @description My Courses Page. * @description หนาคอรสของฉ (My Enrolled Courses)
* Displays enrolled courses with filters for progress/completed.
* Handles enrollment success modals and certificate downloads.
*/ */
// 1. MetaData
definePageMeta({ definePageMeta({
layout: 'default', layout: 'default',
middleware: 'auth' middleware: 'auth'
}) })
useHead({ const { t, locale } = useI18n()
title: 'คอร์สของฉัน - e-Learning'
})
const route = useRoute() const route = useRoute()
const showEnrollModal = ref(false) const quasar = useQuasar()
const activeFilter = ref<'all' | 'progress' | 'completed'>('all') useHead({ title: `${t('sidebar.myCourses') || 'My Courses'} - e-Learning Platform` })
// 2. Composables
const { fetchEnrolledCourses, getCertificate, generateCertificate, fetchCourses } = useCourse()
const { fetchCategories } = useCategory()
// 3. (State)
const enrolledCourses = ref<any[]>([])
const allCategories = ref<any[]>([])
const isLoading = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
const activeCategory = ref<number | 'all'>('all')
const viewMode = ref<'grid' | 'list'>('grid')
const showEnrollModal = ref(false)
// 4. (Helper Functions)
// Check URL query parameters to show 'Enrollment Success' modal const getLocalizedText = (text: any) => {
onMounted(() => {
if (route.query.enrolled) {
showEnrollModal.value = true
}
})
const { locale } = useI18n()
// Helper to get localized text
const getLocalizedText = (text: string | { th: string; en: string } | undefined) => {
if (!text) return '' if (!text) return ''
if (typeof text === 'string') return text if (typeof text === 'string') return text
const currentLocale = locale.value as 'th' | 'en' const currentLocale = locale.value as 'th' | 'en'
return text[currentLocale] || text.th || text.en || '' return text[currentLocale] || text.th || text.en || ''
} }
// Data Handling const getCategoryIcon = (name: any) => {
const { fetchEnrolledCourses, getCertificate, generateCertificate } = useCourse() const text = getLocalizedText(name) || ''
const enrolledCourses = ref<any[]>([]) if (text.includes('เว็บ') || text.includes('Web') || text.includes('โปรแกรม') || text.includes('Program') || text.includes('โค้ด')) return 'code'
const isLoading = ref(false) if (text.includes('ออกแบบ') || text.includes('Design') || text.includes('UI')) return 'palette'
const isDownloadingCert = ref(false) if (text.includes('ธุรกิจ') || text.includes('Business') || text.includes('การตลาด') || text.includes('Market')) return 'trending_up'
if (text.includes('ข้อมูล') || text.includes('Data') || text.includes('วิเคราะ') || text.includes('Sci')) return 'storage'
return 'category'
}
const loadEnrolledCourses = async () => { // 5. (Data Loading)
const loadData = async () => {
isLoading.value = true isLoading.value = true
// FIX: For 'progress' tab, we want both ENROLLED and IN_PROGRESS. try {
// Since API takes single status, we fetch ALL and filter locally for 'progress'. const [catRes, courseRes, allCoursesRes] = await Promise.all([
const apiStatus = activeFilter.value === 'completed' fetchCategories(),
? 'COMPLETED' fetchEnrolledCourses({}),
: undefined // 'all' or 'progress' -> fetch all fetchCourses({ limit: 1000 })
])
const res = await fetchEnrolledCourses({ if (catRes.success) allCategories.value = catRes.data || []
status: apiStatus const catMap = new Map()
}) allCategories.value.forEach(c => catMap.set(c.id, c))
if (res.success) { const catIdMap = new Map()
let courses = (res.data || []) if (allCoursesRes && allCoursesRes.success && allCoursesRes.data) {
allCoursesRes.data.forEach((c: any) => catIdMap.set(c.id, c.category_id))
// Local filter to ensure UI consistency regardless of backend filtering
if (activeFilter.value === 'progress') {
courses = courses.filter(c => c.status !== 'COMPLETED')
} else if (activeFilter.value === 'completed') {
courses = courses.filter(c => c.status === 'COMPLETED')
} }
enrolledCourses.value = courses.map(item => ({ if (courseRes.success && courseRes.data) {
enrolledCourses.value = courseRes.data.map(item => {
const mappedCategoryId = catIdMap.get(item.course.id) || item.course.category_id
const cat = catMap.get(mappedCategoryId)
// (Instructor Name Logic)
let instName = t('course.instructor')
let user = null;
if (item.course.instructors && item.course.instructors.length > 0) {
const primary = item.course.instructors.find((i: any) => i.is_primary);
user = primary ? primary.user : item.course.instructors[0].user;
} else {
user = item.course.creator || (item.course as any).instructor;
}
if (user?.profile?.first_name) {
instName = `${user.profile.first_name} ${user.profile.last_name || ''}`.trim();
} else if (user?.first_name) {
instName = `${user.first_name} ${user.last_name || ''}`.trim();
} else if (user?.username) {
instName = user.username;
}
return {
id: item.course_id, id: item.course_id,
enrollment_id: item.id, enrollment_id: item.id,
title: item.course.title, title: item.course.title,
progress: item.progress_percentage || 0, progress: item.progress_percentage || 0,
lessons: item.course.total_lessons || 0, lessons: item.course.total_lessons || 10,
completed: item.status === 'COMPLETED', completed: item.status === 'COMPLETED',
thumbnail_url: item.course.thumbnail_url thumbnail_url: item.course.thumbnail_url,
})) category_id: mappedCategoryId,
category_name: cat ? getLocalizedText(cat.name) : '',
instructor_name: instName
} }
isLoading.value = false
}
// Watch filter changes to reload
watch(activeFilter, () => {
loadEnrolledCourses()
})
// Client-side Search Filtering
const filteredEnrolledCourses = computed(() => {
if (!searchQuery.value) return enrolledCourses.value
const query = searchQuery.value.toLowerCase()
return enrolledCourses.value.filter(c => {
const title = getLocalizedText(c.title).toLowerCase()
return title.includes(query)
}) })
})
onMounted(() => {
if (route.query.enrolled) {
showEnrollModal.value = true
} }
loadEnrolledCourses() } catch (err) {
}) console.error("Failed to load enrolled courses", err)
// Certificate Handling
const downloadingCourseId = ref<number | null>(null)
// Certificate Handling
const downloadCertificate = async (course: any) => {
if (!course) return
downloadingCourseId.value = course.id
try {
// 1. Try to GET existing certificate
let res = await getCertificate(course.id)
// 2. If not found (or error), try to GENERATE new one
if (!res.success) {
res = await generateCertificate(course.id)
}
// 3. Handle Result
if (res.success && res.data) {
const cert = res.data
if (cert.download_url) {
window.open(cert.download_url, '_blank')
} else {
// Fallback if no URL but success (maybe show message)
console.warn('Certificate ready but no URL')
}
} else {
// Silent fail or minimal log, or maybe use a toast if available, but avoid $q if undefined
console.error(res.error || 'Failed to get certificate')
}
} catch (e) {
console.error(e)
} finally { } finally {
downloadingCourseId.value = null isLoading.value = false
} }
} }
// 6. Computed (Computed Properties)
const uniqueCategories = computed(() => {
const ids = Array.from(new Set(enrolledCourses.value.map(c => c.category_id)))
return allCategories.value.filter(c => ids.includes(c.id))
})
const inProgressCourses = computed(() => {
return enrolledCourses.value.filter(c => !c.completed && c.progress >= 0 && c.progress < 100).reverse()
})
const filteredEnrolledCourses = computed(() => {
let result = enrolledCourses.value
if (activeCategory.value !== 'all') {
result = result.filter(c => c.category_id === activeCategory.value)
}
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(c => getLocalizedText(c.title).toLowerCase().includes(query))
}
return result
})
const validCourseId = computed(() => { const validCourseId = computed(() => {
const cid = route.query.course_id const cid = route.query.course_id
if (!cid || cid === 'undefined' || cid === 'null' || cid === 'NaN') return null if (!cid || cid === 'undefined' || cid === 'null' || cid === 'NaN') return null
return cid return cid
}) })
// 7. (Actions)
const handleDownloadCertificate = async (courseId: number) => {
try {
quasar.notify({ message: t('common.loading') + '...', color: 'info' })
const genRes = await generateCertificate(courseId)
if (genRes.success && genRes.data?.download_url) {
window.open(genRes.data.download_url, '_blank')
} else {
throw new Error(genRes.error || t('common.error'))
}
} catch (err: any) {
quasar.notify({ message: err.message || t('common.error'), color: 'negative' })
}
}
// 8. Lifecycle Hooks
onMounted(() => {
if (route.query.enrolled) showEnrollModal.value = true
loadData()
})
</script> </script>
<template> <template>
<div class="page-container"> <div class="bg-[#F8F9FA] dark:bg-[#020617] min-h-screen p-4 md:p-8 transition-colors duration-300">
<div class="max-w-[1240px] mx-auto">
<!-- Section 1: เรยนตอจากครงกอน (Continue Learning) -->
<div v-if="inProgressCourses.length > 0 && !searchQuery" class="bg-white dark:!bg-slate-900 rounded-[2rem] p-6 md:p-8 shadow-[0_2px_15px_rgb(0,0,0,0.02)] border border-slate-100 dark:border-slate-800 mb-8 transition-all">
<div class="flex items-center gap-2.5 mb-6">
<q-icon name="play_circle_outline" size="26px" class="text-[#3B6BE8]" />
<h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">{{ $t('dashboard.continueLearningTitle') }}</h2>
</div>
<!-- Page Header & Filters (Unified Layout) --> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="flex flex-col gap-6 mb-10"> <div v-for="course in inProgressCourses.slice(0, 2)" :key="course.id" class="border border-slate-100 dark:border-slate-800 rounded-3xl p-4 flex flex-col sm:flex-row gap-5 items-center bg-[#F8FAFC] dark:bg-slate-800/50 hover:border-blue-100 dark:hover:border-blue-900/50 transition-colors">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6"> <!-- Image -->
<h1 class="text-3xl font-black text-slate-900 dark:text-white flex items-center gap-3"> <div class="w-full sm:w-[160px] h-[120px] rounded-[1.25rem] overflow-hidden bg-slate-200 shrink-0 relative group">
<span class="w-1.5 h-8 bg-blue-600 rounded-full shadow-sm shadow-blue-500/50"></span> <img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"/>
{{ $t('sidebar.myCourses') }} <!-- Quick play overlay -->
</h1> <div @click="navigateTo(`/classroom/learning?course_id=${course.id}`)" class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer">
<div class="bg-white/30 backdrop-blur-md rounded-full w-10 h-10 flex flex-col items-center justify-center">
<q-icon name="play_arrow" color="white" size="20px" class="ml-0.5" />
</div>
</div>
</div>
<!-- Info -->
<div class="flex-1 flex flex-col justify-center min-w-0 py-1 w-full">
<div class="mb-2" v-if="course.category_name">
<span class="bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] dark:text-blue-400 px-3.5 py-1.5 rounded-full text-[10px] font-bold tracking-wide">{{ course.category_name }}</span>
</div>
<h3 class="font-bold text-slate-900 dark:text-white text-[14px] leading-snug line-clamp-2 mb-4 pr-2">{{ getLocalizedText(course.title) }}</h3>
<!-- Search Input --> <div class="flex items-center justify-between gap-4 mt-auto">
<div class="flex items-center gap-3 w-full md:w-auto"> <div class="flex-1">
<q-input <div class="flex items-center gap-2 text-[11px] font-bold text-slate-700 dark:text-slate-300 mb-1.5 tracking-wide">
v-model="searchQuery" {{ $t('course.progress') }}: {{ course.progress }}%
dense </div>
outlined <div class="h-[6px] w-full bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
rounded <div class="h-full bg-[#3B6BE8] rounded-full transition-all duration-500" :style="{ width: `${course.progress}%` }"></div>
:placeholder="$t('discovery.searchPlaceholder')" </div>
class="w-full md:w-72 search-input shadow-sm" </div>
bg-color="transparent" <button @click="navigateTo(`/classroom/learning?course_id=${course.id}`)" class="bg-[#3B6BE8] hover:bg-blue-700 text-white rounded-full px-5 py-2 text-[12px] font-bold shrink-0 shadow-md shadow-blue-500/20 transition-transform hover:scale-105 outline-none">{{ $t('dashboard.continue') }}</button>
> </div>
<template v-slot:prepend> </div>
<q-icon name="search" class="text-slate-400" /> </div>
</template>
</q-input>
</div> </div>
</div> </div>
<!-- Filter Tabs (Horizontal Bar) --> <!-- วนท 2: คอรสของฉ (My Courses) -->
<div class="flex flex-wrap items-center gap-2"> <div class="bg-white dark:!bg-slate-900 rounded-[2rem] p-6 md:p-8 shadow-[0_2px_15px_rgb(0,0,0,0.02)] border border-slate-100 dark:border-slate-800 min-h-[500px] mb-12">
<q-btn <!-- วนหวและการคนหา -->
v-for="filter in ['all', 'progress', 'completed']" <div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
:key="filter" <h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">{{ $t('myCourses.title') }}</h2>
@click="activeFilter = filter as any" <div class="flex flex-wrap sm:flex-nowrap items-center gap-3 w-full md:w-auto">
flat <div class="relative w-full sm:w-[260px] flex-1">
rounded <q-icon name="search" size="18px" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-[#3B6BE8]" />
dense <input v-model="searchQuery" class="w-full bg-slate-100 dark:bg-slate-800 border-none rounded-xl py-2.5 pl-11 pr-4 text-sm font-medium text-slate-700 dark:text-slate-200 placeholder:text-slate-400 focus:ring-2 focus:ring-blue-500/20 outline-none transition-all shadow-sm" :placeholder="$t('myCourses.searchPlaceholder')" />
class="px-4 font-bold transition-all text-xs uppercase tracking-widest" </div>
: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'" <div class="flex items-center gap-2 shrink-0">
:label="$t(`myCourses.filter${filter.charAt(0).toUpperCase() + filter.slice(1)}`)" <button @click="viewMode = 'grid'" :class="viewMode === 'grid' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-[#3B6BE8]' : 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700 text-slate-400 hover:bg-slate-50'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="grid_view" size="20px" /></button>
/> <button @click="viewMode = 'list'" :class="viewMode === 'list' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-[#3B6BE8]' : 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700 text-slate-400 hover:bg-slate-50'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="view_list" size="20px" /></button>
</div>
</div> </div>
</div> </div>
<!-- Courses Grid --> <!-- วกรองหมวดหม (แบบเลอนแนวนอนบนมอถ) -->
<div v-if="isLoading" class="flex justify-center py-20"> <div class="mb-8 w-full overflow-hidden">
<div class="flex flex-nowrap items-center gap-3 overflow-x-auto scrollbar-hide pb-2 -mx-1 px-1">
<button
@click="activeCategory = 'all'"
:class="activeCategory === 'all' ? 'bg-[#3B6BE8] text-white border-transparent' : 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-300 hover:border-slate-300'"
class="px-5 py-2.5 rounded-xl border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none whitespace-nowrap font-bold shadow-sm">
<q-icon name="apps" size="18px" /> {{ $t('myCourses.filterAll') }}
</button>
<button
v-for="cat in uniqueCategories" :key="cat.id"
@click="activeCategory = cat.id"
:class="activeCategory === cat.id ? 'bg-[#3B6BE8] text-white border-transparent' : 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-300 hover:border-slate-300'"
class="px-5 py-2.5 rounded-xl border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none whitespace-nowrap font-bold shadow-sm">
<q-icon :name="getCategoryIcon(cat.name)" size="18px" />
{{ getLocalizedText(cat.name) }}
</button>
</div>
</div>
<!-- Active View -->
<div v-if="isLoading" class="flex justify-center py-24">
<q-spinner size="3rem" color="primary" /> <q-spinner size="3rem" color="primary" />
</div> </div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<template v-for="course in filteredEnrolledCourses" :key="course.id"> <div v-else-if="filteredEnrolledCourses.length > 0">
<!-- In Progress Course Card -->
<CourseCard <!-- GRID VIEW -->
v-if="!course.completed" <div v-if="viewMode === 'grid'" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
:id="course.id" <div v-for="course in filteredEnrolledCourses" :key="course.id" class="flex flex-col rounded-[1.5rem] bg-white dark:!bg-slate-900 border border-slate-100 dark:border-slate-800 overflow-hidden shadow-sm hover:shadow-[0_8px_30px_rgb(0,0,0,0.06)] transition-all duration-300 group cursor-pointer" @click="navigateTo(`/classroom/learning?course_id=${course.id}`)">
:title="course.title" <!-- Thumbnail -->
:progress="course.progress" <div class="relative w-full aspect-[4/3] bg-slate-100 dark:bg-slate-800 overflow-hidden">
:image="course.thumbnail_url" <img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
show-continue <!-- Badge inside Image Map Top Left -->
:show-view-details="false" <div v-if="course.category_name" class="absolute top-3 left-3 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md text-[#3B6BE8] dark:text-blue-400 font-bold text-[10px] px-3.5 py-1 rounded-full shadow-sm">
/> {{ course.category_name }}
<!-- Completed Course Card --> </div>
<CourseCard
v-else
:id="course.id"
:title="course.title"
:progress="100"
:image="course.thumbnail_url"
:completed="true"
show-certificate
show-study-again
:show-view-details="false"
:loading="downloadingCourseId === course.id"
@view-certificate="downloadCertificate(course)"
/>
</template>
</div> </div>
<!-- Empty State --> <!-- Card Body -->
<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 class="p-5 flex flex-col flex-1">
<q-icon v-if="searchQuery" name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" /> <h3 class="font-bold text-slate-900 dark:text-white text-[14px] leading-snug line-clamp-2 mb-3">{{ getLocalizedText(course.title) }}</h3>
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">
{{ searchQuery ? $t('discovery.emptyTitle') : $t('myCourses.emptyTitle') }}
</h3>
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md"> <div class="mt-auto flex items-center justify-between gap-4">
{{ searchQuery ? $t('discovery.emptyDesc') : $t('myCourses.emptyDesc') }} <div class="flex-1">
</p> <div class="text-[10px] font-bold text-slate-700 dark:text-slate-300 mb-1.5 tracking-wide">{{ $t('course.progress') }}: {{ course.progress }}%</div>
<NuxtLink v-if="!searchQuery" to="/browse/discovery" class="mt-6 px-6 py-2 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors">{{ $t('myCourses.goToDiscovery') }}</NuxtLink> <div class="h-[6px] w-full bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
<button v-else class="mt-4 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''"> <div class="h-full rounded-full transition-all duration-500" :class="course.completed ? 'bg-green-500' : 'bg-[#3B6BE8] dark:bg-blue-400'" :style="{ width: `${course.progress}%` }"></div>
{{ $t('discovery.showAll') }} </div>
</div>
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 mt-3 sm:mt-0">
<!-- Certificate Button -->
<button v-if="course.completed" @click.stop="handleDownloadCertificate(course.id)" class="border border-green-100 bg-green-50 text-green-600 dark:border-green-900/50 dark:bg-green-900/30 dark:text-green-400 rounded-full px-3 py-1.5 text-[11px] font-bold hover:bg-green-100 dark:hover:bg-green-900/50 transition-colors shrink-0 flex items-center justify-center gap-1">
<q-icon name="workspace_premium" size="14px" /> {{ $t('course.certificate') }}
</button> </button>
<!-- Continue/Replay Button -->
<button class="bg-[#3B6BE8] text-white border-transparent hover:bg-blue-700 shadow-sm rounded-full px-5 py-1.5 text-[11px] font-bold transition-colors shrink-0 text-center cursor-pointer">
{{ course.completed ? $t('course.studyAgain') : $t('dashboard.continue') }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- LIST VIEW -->
<div v-else class="flex flex-col gap-4">
<div v-for="course in filteredEnrolledCourses" :key="course.id" class="flex flex-col sm:flex-row items-center rounded-[1.5rem] bg-white dark:!bg-slate-900 border border-slate-100 dark:border-slate-800 p-4 gap-6 shadow-sm hover:shadow-[0_8px_30px_rgb(0,0,0,0.06)] transition-all duration-300 cursor-pointer group" @click="navigateTo(`/classroom/learning?course_id=${course.id}`)">
<!-- Thumbnail Left -->
<div class="relative w-full sm:w-[240px] aspect-[16/10] sm:aspect-auto sm:h-[130px] rounded-2xl bg-slate-100 dark:bg-slate-800 overflow-hidden shrink-0">
<img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
<!-- Badge inside Image -->
<div v-if="course.category_name" class="absolute top-2.5 left-2.5 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md text-[#3B6BE8] dark:text-blue-400 font-bold text-[10px] px-3.5 py-1 rounded-full shadow-sm">
{{ course.category_name }}
</div>
</div>
<!-- Content Right -->
<div class="flex-1 w-full flex flex-col md:flex-row gap-6 md:items-center">
<div class="flex-1 min-w-0">
<h3 class="font-bold text-slate-900 dark:text-white text-[15px] leading-snug line-clamp-2 mb-3 pr-4">{{ getLocalizedText(course.title) }}</h3>
</div>
<!-- Progress and Button Zone -->
<div class="flex md:flex-col items-center md:items-end justify-between md:justify-center gap-4 shrink-0 md:w-[200px]">
<div class="w-full max-w-[140px] md:max-w-full">
<div class="flex justify-between items-center text-[11px] font-bold text-slate-700 dark:text-slate-300 mb-2 tracking-wide">
<span>{{ $t('course.progress') }}:</span>
<span>{{ course.progress }}%</span>
</div>
<div class="h-[6px] w-full bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500" :class="course.completed ? 'bg-green-500' : 'bg-[#3B6BE8] dark:bg-blue-400'" :style="{ width: `${course.progress}%` }"></div>
</div>
</div>
<div class="flex flex-col items-stretch md:items-end gap-2 mt-3 sm:mt-0 w-full sm:w-auto">
<!-- Certificate Button -->
<button v-if="course.completed" @click.stop="handleDownloadCertificate(course.id)" class="border border-green-100 bg-green-50 text-green-600 dark:border-green-900/50 dark:bg-green-900/30 dark:text-green-400 rounded-full px-4 py-2 text-[12px] font-bold hover:bg-green-100 dark:hover:bg-green-900/50 transition-colors shrink-0 flex items-center justify-center gap-1 w-full sm:w-auto">
<q-icon name="workspace_premium" size="16px" /> {{ $t('course.downloadCertificate') }}
</button>
<!-- Continue/Replay Button -->
<button class="bg-[#3B6BE8] text-white border-transparent hover:bg-blue-700 shadow-sm rounded-full px-6 py-2 text-[12px] font-bold transition-colors shrink-0 text-center w-full sm:w-auto cursor-pointer">
{{ course.completed ? $t('course.studyAgain') : $t('dashboard.continue') }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Empty filter state -->
<div v-else class="flex flex-col items-center justify-center py-20">
<q-icon name="search_off" size="48px" class="text-slate-300 dark:text-slate-600 mb-4" />
<h3 class="text-lg font-bold text-slate-900 dark:text-white mb-2">{{ $t('myCourses.searchNoResult') }}</h3>
<p class="text-slate-500 dark:text-slate-400 text-sm">{{ $t('myCourses.searchNoResultDesc') }}</p>
</div>
</div>
</div> </div>
<!-- MODAL: Enrollment Success --> <!-- MODAL: Enrollment Success -->
<q-dialog v-model="showEnrollModal" backdrop-filter="blur(4px)"> <q-dialog v-model="showEnrollModal" backdrop-filter="blur(4px)">
<q-card class="rounded-[1.5rem] shadow-2xl p-8 max-w-sm w-full text-center relative overflow-hidden bg-white dark:bg-slate-800"> <q-card class="rounded-[1.5rem] shadow-2xl p-8 max-w-sm w-full text-center relative overflow-hidden bg-white dark:bg-slate-800">
<div class="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-green-400 to-emerald-600"></div> <div class="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-green-400 to-emerald-600"></div>
<div class="w-16 h-16 bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 rounded-full flex items-center justify-center text-3xl mx-auto mb-6"> <div class="w-16 h-16 bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 rounded-full flex items-center justify-center text-3xl mx-auto mb-6"></div>
</div>
<h2 class="text-2xl font-bold mb-2 text-slate-900 dark:text-white">{{ $t('enrollment.successTitle') }}</h2> <h2 class="text-2xl font-bold mb-2 text-slate-900 dark:text-white">{{ $t('enrollment.successTitle') }}</h2>
<p class="text-slate-500 dark:text-slate-400 mb-8">{{ $t('enrollment.successDesc') }}</p> <p class="text-slate-500 dark:text-slate-400 mb-8">{{ $t('enrollment.successDesc') }}</p>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<q-btn <q-btn v-if="validCourseId" :to="`/classroom/learning?course_id=${validCourseId}`" unelevated rounded color="primary" class="w-full py-3 text-lg font-bold shadow-lg" :label="$t('enrollment.startNow')" />
v-if="validCourseId" <q-btn v-else unelevated rounded color="primary" class="w-full py-3 text-lg font-bold shadow-lg" :label="$t('common.close')" @click="showEnrollModal = false" />
:to="`/classroom/learning?course_id=${validCourseId}`" <q-btn v-if="validCourseId" flat rounded color="grey-7" class="w-full py-3 font-bold" :label="$t('enrollment.later')" @click="showEnrollModal = false" />
unelevated
rounded
color="primary"
class="w-full py-3 text-lg font-bold shadow-lg"
:label="$t('enrollment.startNow')"
/>
<q-btn
v-else
unelevated
rounded
color="primary"
class="w-full py-3 text-lg font-bold shadow-lg"
:label="$t('common.close')"
@click="showEnrollModal = false"
/>
<q-btn
v-if="validCourseId"
flat
rounded
color="grey-7"
class="w-full py-3 font-bold"
:label="$t('enrollment.later')"
@click="showEnrollModal = false"
/>
</div> </div>
</q-card> </q-card>
</q-dialog> </q-dialog>
</div> </div>
</template> </template>
<style scoped>
/* Custom Font for Signature/Name if desired */
.font-handwriting {
font-family: 'Dancing Script', cursive, serif; /* Fallback */
}
</style>

View file

@ -4,17 +4,20 @@ definePageMeta({
middleware: 'auth' middleware: 'auth'
}) })
useHead({ const { locale, t } = useI18n()
title: 'ตั้งค่าบัญชี - e-Learning'
})
const { currentUser, updateUserProfile, changePassword, uploadAvatar, sendVerifyEmail, fetchUserProfile } = useAuth() const { currentUser, updateUserProfile, changePassword, uploadAvatar, sendVerifyEmail, fetchUserProfile } = useAuth()
const { getLocalizedText } = useCourse() const { getLocalizedText } = useCourse()
const { locale, t } = useI18n() import { useQuasar } from 'quasar'
const $q = useQuasar()
useHead({
title: `${t('userMenu.settings')} - e-Learning`
})
const isEditing = ref(false) const isEditing = ref(false)
const activeTab = ref<'general' | 'security'>('general')
const isProfileSaving = ref(false) const isProfileSaving = ref(false)
const isPasswordSaving = ref(false) const isPasswordSaving = ref(false)
const isSendingVerify = ref(false) const isSendingVerify = ref(false)
@ -56,6 +59,13 @@ const passwordForm = reactive({
confirmPassword: '' confirmPassword: ''
}) })
const showPasswordModal = ref(false)
const showPassword = reactive({
current: false,
new: false,
confirm: false
})
// Rules have been moved to components // Rules have been moved to components
@ -94,9 +104,10 @@ const handleFileUpload = async (fileOrEvent: File | Event) => {
if (result.success && result.data?.avatar_url) { if (result.success && result.data?.avatar_url) {
userData.value.photoURL = result.data.avatar_url userData.value.photoURL = result.data.avatar_url
$q.notify({ type: 'positive', message: 'อัปเดตรูปโปรไฟล์สำเร็จ', position: 'top' })
} else { } else {
console.error('Upload failed:', result.error) console.error('Upload failed:', result.error)
alert(result.error || t('profile.updateError')) $q.notify({ type: 'negative', message: result.error || t('profile.updateError') || 'อัปเดตรูปโปรไฟล์ไม่สำเร็จ', position: 'top' })
} }
} }
} }
@ -130,9 +141,9 @@ const handleUpdateProfile = async () => {
const result = await updateUserProfile(payload) const result = await updateUserProfile(payload)
if (result?.success) { if (result?.success) {
// success logic $q.notify({ type: 'positive', message: t('profile.updateSuccess'), position: 'top' })
} else { } else {
alert(result?.error || t('profile.updateError')) $q.notify({ type: 'negative', message: result?.error || t('profile.updateError'), position: 'top' })
} }
isProfileSaving.value = false isProfileSaving.value = false
@ -144,19 +155,19 @@ const handleSendVerifyEmail = async () => {
isSendingVerify.value = false isSendingVerify.value = false
if (result.success) { if (result.success) {
alert(result.message || t('profile.verifyEmailSuccess') || 'ส่งอีเมลยืนยันสำเร็จ') $q.notify({ type: 'positive', message: result.message || t('profile.verifyEmailSuccess') || 'ส่งอีเมลยืนยันสำเร็จ', position: 'top' })
} else { } else {
if (result.code === 400) { if (result.code === 400) {
alert(t('profile.emailAlreadyVerified') || 'อีเมลของคุณได้รับการยืนยันแล้ว') $q.notify({ type: 'warning', message: t('profile.emailAlreadyVerified') || 'อีเมลของคุณได้รับการยืนยันแล้ว', position: 'top' })
} else { } else {
alert(result.error || t('profile.verifyEmailError') || 'ส่งอีเมลไม่สำเร็จ') $q.notify({ type: 'negative', message: result.error || t('profile.verifyEmailError') || 'ส่งอีเมลไม่สำเร็จ', position: 'top' })
} }
} }
} }
const handleUpdatePassword = async () => { const handleUpdatePassword = async () => {
if (passwordForm.newPassword !== passwordForm.confirmPassword) { if (passwordForm.newPassword !== passwordForm.confirmPassword) {
alert('รหัสผ่านใหม่ไม่ตรงกัน') $q.notify({ type: 'negative', message: 'รหัสผ่านใหม่ไม่ตรงกัน', position: 'top' })
return return
} }
@ -168,12 +179,13 @@ const handleUpdatePassword = async () => {
}) })
if (result.success) { if (result.success) {
alert(t('profile.passwordSuccess')) $q.notify({ type: 'positive', message: t('profile.passwordSuccess') || 'เปลี่ยนรหัสผ่านสำเร็จ', position: 'top' })
passwordForm.currentPassword = '' passwordForm.currentPassword = ''
passwordForm.newPassword = '' passwordForm.newPassword = ''
passwordForm.confirmPassword = '' passwordForm.confirmPassword = ''
showPasswordModal.value = false
} else { } else {
alert(result.error || t('profile.passwordError')) $q.notify({ type: 'negative', message: result.error || t('profile.passwordError') || 'เปลี่ยนรหัสผ่านไม่สำเร็จ', position: 'top' })
} }
isPasswordSaving.value = false isPasswordSaving.value = false
@ -202,102 +214,215 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div class="page-container"> <div class="page-container bg-[#F8F9FA] dark:bg-[#020617] transition-colors duration-300 min-h-screen">
<div class="flex items-center justify-between mb-8 md:mb-10">
<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"
@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>
<div class="min-h-9 flex items-center">
<q-btn
v-if="isHydrated && !isEditing"
unelevated
rounded
color="primary"
class="font-bold"
icon="edit"
:label="$t('profile.editProfile')"
@click="toggleEdit(true)"
/>
<div
v-else-if="!isHydrated"
class="h-9 w-24 rounded-md bg-slate-200 dark:bg-slate-700 animate-pulse"
/>
</div>
</div>
<div v-if="!isHydrated" class="flex justify-center py-20"> <div v-if="!isHydrated" class="flex justify-center py-20">
<q-spinner size="3rem" color="primary" /> <q-spinner size="3rem" color="primary" />
</div> </div>
<div v-else> <!-- MAIN PROFILE SETTINGS -->
<div v-if="!isEditing" class="card-premium overflow-hidden fade-in"> <div v-else class="max-w-5xl mx-auto pb-20 fade-in pt-4">
<div class="bg-gradient-to-r from-blue-600 to-indigo-600 h-32 w-full"/>
<div class="px-8 pb-10 -mt-16"> <!-- ตรขอมลโปรไฟล (Profile Card) -->
<div class="flex flex-col md:flex-row items-end gap-6 mb-10"> <div class="bg-white dark:!bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl shadow-sm mb-6 overflow-hidden">
<div class="relative flex-shrink-0"> <div class="p-8 border-b border-slate-200 dark:border-slate-800">
<h2 class="text-xl font-bold text-slate-900 dark:text-white">{{ $t('profile.myProfile') }}</h2>
<p class="text-slate-500 dark:text-slate-400 text-sm mt-1">{{ $t('profile.publicInfo') }}</p>
</div>
<div class="p-8">
<!-- วนอปโหลดรปโปรไฟล -->
<div class="flex flex-col sm:flex-row items-center sm:items-center gap-6 mb-10 text-center sm:text-left">
<div class="relative cursor-pointer" @click="triggerUpload">
<UserAvatar <UserAvatar
:photo-u-r-l="userData.photoURL" :photo-u-r-l="userData.photoURL"
:first-name="userData.firstName" :first-name="userData.firstName"
:last-name="userData.lastName" :last-name="userData.lastName"
size="128" size="100"
class="border-4 border-white dark:border-[#1e293b] shadow-2xl bg-slate-800" class="rounded-full bg-slate-100 dark:bg-slate-800 object-cover border-4 border-slate-50 dark:border-slate-800"
/> />
<div class="absolute bottom-0 right-0 bg-[#3B6BE8] text-white p-1.5 rounded-full border-2 border-white dark:border-slate-900 hover:bg-blue-700 transition flex items-center justify-center">
<q-icon name="edit" size="14px" />
</div> </div>
<div class="pb-2"> <input type="file" ref="fileInput" class="hidden" accept="image/jpeg, image/png, image/gif" @change="handleFileUpload" />
<h2 class="text-3xl font-black text-slate-900 dark:text-white mb-1">{{ userData.firstName }} {{ userData.lastName }}</h2> </div>
<p class="text-slate-500 dark:text-slate-400 font-medium">{{ userData.email }}</p> <div class="flex flex-col items-center sm:items-start">
<button @click="triggerUpload" class="bg-[#3B6BE8] hover:bg-blue-700 text-white px-5 py-2.5 rounded-lg text-sm font-bold transition mb-2 shadow-sm whitespace-nowrap">
<span v-if="isProfileSaving"><q-spinner size="18px" /> {{ $t('profile.uploading') }}</span>
<span v-else>{{ $t('profile.changeAvatar') }}</span>
</button>
<p class="text-slate-500 dark:text-slate-400 text-xs mt-1">{{ $t('profile.avatarHint') }}</p>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> <!-- Form Inputs (2 Column Grid) -->
<div class="info-group"> <div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6 mb-4">
<span class="label">{{ $t('profile.phone') }}</span> <div class="md:col-span-2 relative">
<p class="value">{{ userData.phone || '-' }}</p> <label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300 mb-2">{{ $t('profile.prefix') }}</label>
<select v-model="userData.prefix" class="w-full bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 px-4 py-3 rounded-lg focus:outline-none focus:border-[#3B6BE8] transition text-sm font-medium text-slate-900 dark:text-white appearance-none cursor-pointer">
<option value="" disabled>{{ $t('profile.selectPrefix') }}</option>
<option value="นาย">{{ $t('profile.mr') }}</option>
<option value="นาง">{{ $t('profile.mrs') }}</option>
<option value="นางสาว">{{ $t('profile.miss') }}</option>
</select>
<div class="pointer-events-none absolute bottom-0 right-0 flex items-center px-4 h-11 text-slate-500">
<q-icon name="arrow_drop_down" size="24px" />
</div> </div>
<div class="info-group">
<span class="label">{{ $t('profile.joinedAt') }}</span>
<p class="value">{{ formatDate(userData.createdAt) }}</p>
</div> </div>
<div>
<label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300 mb-2">{{ $t('profile.firstName') }}-{{ $t('profile.lastName') }}</label>
<div class="flex gap-3">
<input type="text" v-model="userData.firstName" class="w-full bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 px-4 py-3 rounded-lg focus:outline-none focus:border-[#3B6BE8] transition text-sm font-medium text-slate-900 dark:text-white" :placeholder="$t('profile.firstName')" />
<input type="text" v-model="userData.lastName" class="w-full bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 px-4 py-3 rounded-lg focus:outline-none focus:border-[#3B6BE8] transition text-sm font-medium text-slate-900 dark:text-white" :placeholder="$t('profile.lastName')" />
</div>
</div>
<div>
<div class="flex items-center justify-between mb-2">
<label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300">{{ $t('profile.email') }}</label>
<div v-if="userData.emailVerifiedAt" class="flex items-center gap-1 text-green-500 text-xs font-bold">
<q-icon name="verified_user" size="14px" /> {{ $t('profile.emailVerified') }}
</div>
<button v-else @click="handleSendVerifyEmail" :disabled="isSendingVerify" class="flex items-center gap-1 text-amber-500 hover:text-amber-600 text-xs font-bold transition">
<q-icon name="warning" size="14px" /> <span v-if="isSendingVerify"><q-spinner size="xs" /> {{ $t('profile.verifying') }}</span><span v-else class="underline">{{ $t('profile.verifyNow') }}</span>
</button>
</div>
<input type="email" v-model="userData.email" class="w-full bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 px-4 py-3 rounded-lg focus:outline-none transition text-sm font-medium text-slate-500 dark:text-slate-400" disabled />
</div>
<div>
<label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300 mb-2">{{ $t('profile.phone') }}</label>
<input type="tel" v-model="userData.phone" class="w-full bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 px-4 py-3 rounded-lg focus:outline-none focus:border-[#3B6BE8] transition text-sm font-medium text-slate-900 dark:text-white" placeholder="08x-xxx-xxxx" />
</div>
<div>
<label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300 mb-2">{{ $t('profile.joinedAt') }}</label>
<input type="text" :value="formatDate(userData.createdAt)" class="w-full bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 px-4 py-3 rounded-lg focus:outline-none transition text-sm font-medium text-slate-500 dark:text-slate-400" disabled />
</div>
</div>
</div>
<!-- Footer Buttons -->
<div class="px-6 sm:px-8 py-5 border-t border-slate-200 dark:border-slate-800 flex flex-col sm:flex-row justify-center sm:justify-end gap-3 items-center bg-white dark:!bg-slate-900">
<button class="w-full sm:w-auto text-[13px] font-bold text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white px-4 py-2 transition order-2 sm:order-1" @click="fetchUserProfile(true)">{{ $t('common.cancel') }}</button>
<button @click="handleUpdateProfile" :disabled="isProfileSaving" class="w-full sm:w-auto bg-[#3B6BE8] hover:bg-blue-700 text-white px-6 py-2.5 rounded-lg text-[13px] font-bold transition shadow-sm disabled:opacity-50 order-1 sm:order-2">
<span v-if="isProfileSaving"><q-spinner size="18px" color="white" class="mr-1" /> {{ $t('profile.saving') }}</span>
<span v-else>{{ $t('common.saveChanges') }}</span>
</button>
</div>
</div>
<!-- Security Card -->
<div class="bg-white dark:!bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl shadow-sm overflow-hidden">
<div class="p-8 border-b border-slate-200 dark:border-slate-800">
<h2 class="text-xl font-bold text-slate-900 dark:text-white">{{ $t('profile.security') }}</h2>
<p class="text-slate-500 dark:text-slate-400 text-sm mt-1">{{ $t('profile.securitySubtitle') }}</p>
</div>
<div class="px-6 sm:px-8 py-8 sm:py-10">
<div class="p-5 sm:p-6 rounded-2xl bg-slate-50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-800 flex flex-col sm:flex-row items-center sm:items-center justify-between gap-6 text-center sm:text-left">
<div class="flex flex-col sm:flex-row items-center gap-4">
<div class="w-12 h-12 rounded-xl bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 flex items-center justify-center shrink-0">
<q-icon name="key" size="24px" />
</div>
<div>
<h3 class="font-bold text-slate-900 dark:text-white text-[15px] sm:text-[16px]">{{ $t('profile.password') }}</h3>
<p class="text-slate-500 dark:text-slate-400 text-xs mt-0.5 sm:mt-1">{{ $t('profile.securitySubtitle') }}</p>
</div>
</div>
<button @click="showPasswordModal = true" class="w-full sm:w-auto bg-[#3B6BE8] hover:bg-blue-700 text-white px-6 py-2.5 rounded-lg text-sm font-bold transition shadow-sm shadow-blue-500/10">
{{ $t('profile.changePasswordBtn') }}
</button>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Password Modal -->
<q-dialog v-model="showPasswordModal">
<q-card class="w-full max-w-md rounded-2xl p-2 dark:bg-slate-900 shadow-xl">
<q-form @submit="handleUpdatePassword">
<q-card-section class="flex items-center justify-between pb-2">
<div class="text-xl font-bold text-slate-900 dark:text-white">{{ $t('profile.changePasswordBtn') }}</div>
<q-btn icon="close" flat round dense v-close-popup class="text-slate-500" />
</q-card-section>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-8 fade-in"> <q-card-section class="pt-2">
<div class="space-y-1">
<ProfileEditForm <div>
v-model="userData" <label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300 mb-1">{{ $t('profile.currentPassword') }}</label>
:loading="isProfileSaving" <q-input
:verifying="isSendingVerify" v-model="passwordForm.currentPassword"
@submit="handleUpdateProfile" :type="showPassword.current ? 'text' : 'password'"
@upload="handleFileUpload" outlined
@verify="handleSendVerifyEmail" dense
class="custom-pwd-input"
:rules="[val => !!val || $t('common.required')]"
>
<template v-slot:append>
<q-icon
:name="showPassword.current ? 'visibility_off' : 'visibility'"
class="cursor-pointer text-slate-400"
@click="showPassword.current = !showPassword.current"
/> />
</template>
</q-input>
</div>
<div>
<PasswordChangeForm <label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300 mb-1">{{ $t('profile.newPassword') }}</label>
v-model="passwordForm" <q-input
:loading="isPasswordSaving" v-model="passwordForm.newPassword"
@submit="handleUpdatePassword" :type="showPassword.new ? 'text' : 'password'"
outlined
dense
class="custom-pwd-input"
:rules="[
val => !!val || $t('common.required'),
val => val.length >= 6 || $t('profile.newPasswordHint')
]"
>
<template v-slot:append>
<q-icon
:name="showPassword.new ? 'visibility_off' : 'visibility'"
class="cursor-pointer text-slate-400"
@click="showPassword.new = !showPassword.new"
/> />
</template>
</q-input>
</div>
<div>
<label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300 mb-1">{{ $t('profile.confirmNewPassword') }}</label>
<q-input
v-model="passwordForm.confirmPassword"
:type="showPassword.confirm ? 'text' : 'password'"
outlined
dense
class="custom-pwd-input"
:rules="[
val => !!val || $t('common.required'),
val => val === passwordForm.newPassword || $t('common.passwordsDoNotMatch')
]"
>
<template v-slot:append>
<q-icon
:name="showPassword.confirm ? 'visibility_off' : 'visibility'"
class="cursor-pointer text-slate-400"
@click="showPassword.confirm = !showPassword.confirm"
/>
</template>
</q-input>
</div> </div>
</div> </div>
</q-card-section>
<q-card-actions align="right" class="pt-2 pb-2 px-4">
<q-btn flat :label="$t('common.cancel')" color="grey-7" v-close-popup class="font-bold text-[13px]" />
<q-btn type="submit" unelevated color="primary" :label="$t('common.save')" :loading="isPasswordSaving" class="font-bold rounded-lg px-4 text-[13px]" />
</q-card-actions>
</q-form>
</q-card>
</q-dialog>
</div> </div>
</template> </template>
@ -307,56 +432,12 @@ onMounted(async () => {
color: white; color: white;
} }
.card-premium { .custom-pwd-input :deep(.q-field__control) {
background-color: white; border-radius: 8px;
border-color: #e2e8f0; background-color: #f8fafc;
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 .custom-pwd-input :deep(.q-field__control) {
.dark .card-premium {
background-color: #1e293b; background-color: #1e293b;
border-color: rgba(255, 255, 255, 0.05);
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.3);
}
.info-group .label {
font-size: 0.75rem;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #64748b;
display: block;
margin-bottom: 0.5rem;
}
.dark .info-group .label {
color: #94a3b8;
}
.info-group .value {
font-size: 1.125rem;
font-weight: 700;
color: #0f172a;
}
.dark .info-group .value {
color: white;
}
.premium-q-input :deep(.q-field__control) {
border-radius: 12px;
}
.dark .premium-q-input :deep(.q-field__control) {
background: #0f172a;
}
.dark .premium-q-input :deep(.q-field__native) {
color: white;
}
.dark .premium-q-input :deep(.q-field__label) {
color: #94a3b8;
} }
.fade-in { .fade-in {

View file

@ -13,47 +13,29 @@ useHead({
title: 'E-Learning System - ระบบการเรียนการสอนออนไลน์' title: 'E-Learning System - ระบบการเรียนการสอนออนไลน์'
}) })
import { CATEGORY_CARDS, WHY_CHOOSE_US } from '@/constants/landing'
const { fetchCategories } = useCategory() const { fetchCategories } = useCategory()
const { fetchCourses, getLocalizedText } = useCourse() const { fetchCourses, getLocalizedText } = useCourse()
const { user } = useAuth() const { user } = useAuth()
const stepOneCards = [ const categoryCards = CATEGORY_CARDS
{ 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' }, const whyChooseUs = WHY_CHOOSE_US
{ 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 = [ //
{ const levelModel = ref('ระดับทั้งหมด')
title: 'คอร์สออนไลน์', icon: 'desktop_windows', type: 'ONLINE', const levelOptions = ['ระดับทั้งหมด','ระดับเริ่มต้น', 'ระดับกลาง', 'ระดับสูง']
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 categories = ref<any[]>([]) const categories = ref<any[]>([])
const topCourses = ref<any[]>([]) const topCourses = ref<any[]>([])
const selectedCategory = ref('all') const selectedCategory = ref('all')
const isLoading = ref(false) const isLoading = ref(false)
const currentSlide = ref(0) const currentSlide = ref(0)
const courseChunks = computed(() => { const courseChunks = computed(() => {
const chunkSize = 4 const chunkSize = 4
const chunks = [] const chunks = []
if (!topCourses.value) return [] if (!topCourses.value || topCourses.value.length === 0) return []
for (let i = 0; i < topCourses.value.length; i += chunkSize) { for (let i = 0; i < topCourses.value.length; i += chunkSize) {
chunks.push(topCourses.value.slice(i, i + chunkSize)) chunks.push(topCourses.value.slice(i, i + chunkSize))
} }
@ -65,7 +47,7 @@ const loadData = async () => {
try { try {
const [catRes, courseRes] = await Promise.all([ const [catRes, courseRes] = await Promise.all([
fetchCategories(), fetchCategories(),
fetchCourses({ limit: 8, forceRefresh: true }) fetchCourses({ limit: 12, forceRefresh: true })
]) ])
if (catRes.success) categories.value = catRes.data || [] if (catRes.success) categories.value = catRes.data || []
@ -84,7 +66,7 @@ const goBrowse = (slug: string) => {
watch(selectedCategory, async (newVal) => { watch(selectedCategory, async (newVal) => {
isLoading.value = true isLoading.value = true
try { try {
const params: any = { limit: 8 } const params: any = { limit: 12 }
if (newVal !== 'all') { if (newVal !== 'all') {
const category = categories.value.find(c => c.slug === newVal) const category = categories.value.find(c => c.slug === newVal)
if (category) { if (category) {
@ -110,41 +92,41 @@ onMounted(() => {
<template> <template>
<div class="landing-page bg-white min-h-screen"> <div class="landing-page bg-white min-h-screen">
<!-- Hero Section --> <!-- Section 1: Hero Section -->
<header class="relative pt-32 pb-16 md:pt-40 md:pb-20 overflow-hidden bg-white"> <section class="container mx-auto py-24 md:py-24 lg:py-28 px-6 lg:px-12 pb-16">
<!-- Decorative Background --> <div class="flex flex-col lg:flex-row items-center gap-10 lg:gap-10 justify-between animate-fade-in">
<div class="absolute top-0 right-0 w-[45%] h-[105%] bg-blue-50/50 rounded-bl-[12rem] -z-10 animate-fade-in"/> <!-- Left Content -->
<div class="flex flex-col items-start gap-6 flex-1 max-w-2xl ">
<div class="container mx-auto px-6 md:px-12 grid grid-cols-1 md:grid-cols-2 items-center gap-16"> <!-- Heading -->
<div class="hero-left slide-up"> <h1 class="text-4xl sm:text-5xl lg:text-[55px] font-bold leading-tight lg:leading-[66px] slide-up" style="animation-delay: 0.2s;">
<div class="flex items-center gap-3 mb-8 text-blue-600"> <span class="text-slate-900">ขยายขอบเขตความรของค</span><br>
<q-icon name="stars" size="28px" /> <span class="text-blue-600">วยการเรยนรออนไลน</span>
<span class="text-sm font-black tracking-widest uppercase">E-Learning Platform</span>
</div>
<h1 class="text-4xl md:text-5xl lg:text-7xl font-black text-slate-900 leading-[1.15] mb-8">
คอรสเรยนออนไลน<br><span class="text-blue-600">เพมทกษะ</span>คด
</h1> </h1>
<p class="text-slate-500 text-lg md:text-xl font-medium mb-12 leading-relaxed max-w-xl slide-up" style="animation-delay: 0.1s;">
แหลงรวมคอรสออนไลนณภาพสงทจะชวยอปสกลใหณทำงานเกงข ฒนาทกษะทตลาดตองการ พรอมใหณกาวไปขางหนาไดอยางมนใจ! <!-- Subtitle -->
<p class="text-slate-500 text-lg sm:text-xl leading-relaxed slide-up" style="animation-delay: 0.3s;">
ดประกายความรของค และเรมตนอปสกลกบผเชยวชาญ
ในอตสาหกรรมทความรรอบดานหลากหลายในหลายสาขา
เรยนไดกท กเวลา
</p> </p>
<!-- Search Bar Pill --> <!-- Buttons -->
<div class="flex flex-col sm:flex-row gap-4 mb-10 slide-up" style="animation-delay: 0.2s;"> <div class=" w-full flex flex-col sm:flex-row items-center gap-4 pt-5 slide-up" style="animation-delay: 0.4s;">
<q-btn <q-btn
unelevated unelevated
rounded rounded
color="primary" color="blue-600"
label="ดูคอร์สเรียนทั้งหมด" label="ดูคอร์สเรียน"
class="px-10 h-16 font-black text-white text-xl shadow-xl shadow-blue-600/20 hover:scale-105 transition-transform" class="px-10 py-4 w-full sm:w-auto rounded-3xl font-semibold text-white text-lg shadow-xl shadow-blue-600/20 transition-transform"
no-caps no-caps
to="/browse" to="/browse"
/> />
<q-btn <q-btn
outline outline
rounded rounded
color="primary"
label="สมัครสมาชิกฟรี" label="สมัครสมาชิกฟรี"
class="px-10 h-16 font-black text-xl border-2 hover:bg-blue-50" color="grey-8"
class="px-10 py-4 w-full sm:w-auto btn-user rounded-3xl font-semibold text-lg hover:bg-blue-50 "
no-caps no-caps
to="/auth/register" to="/auth/register"
v-if="!user" v-if="!user"
@ -152,188 +134,171 @@ onMounted(() => {
</div> </div>
</div> </div>
<!-- Hero Visual Showcase --> <!-- Right - Hero Image -->
<div class="hero-right flex justify-center md:justify-end items-center slide-up" style="animation-delay: 0.2s;"> <div class="flex-1 w-full max-w-lg md:max-w-md lg:max-w-xl pl-0 py-10">
<div class="relative w-full max-w-xl"> <div class="relative rounded-2xl overflow-hidden shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)] aspect-square">
<!-- Main Illustration -->
<div class="relative z-10 animate-float">
<img <img
src="/img/elearning.png" src="https://api.builder.io/api/v1/image/assets/TEMP/11ba9b46c799fac950967377f8158fa942c1a6b8?width=1184"
alt="E-Learning Illustration" alt="Students collaborating"
class="w-full h-auto drop-shadow-2xl" class="w-full h-full object-cover"
/> />
</div> <div class="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent" />
<!-- Decorative shapes behind the image --> <!-- Course Card Overlay -->
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[120%] h-[120%] bg-blue-50/50 rounded-full blur-3xl -z-10" /> <!-- <div class="absolute bottom-5 left-5 right-5">
<div class="absolute -top-10 -left-10 w-32 h-32 bg-amber-100 rounded-[3rem] -z-10 animate-pulse" /> <div class="bg-white/85 backdrop-blur-sm border border-white/20 rounded-3xl px-6 py-5">
<div class="absolute -bottom-10 -right-10 w-48 h-48 bg-blue-100 rounded-full -z-10 animate-pulse" style="animation-delay: -2s;" />
</div>
</div>
</div>
</header>
<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="text-center mb-16 slide-up">
<h2 class="text-3xl md:text-5xl font-black text-slate-900 mb-6 px-4">
เพราะ าวแรก ของการพฒนาตวเอง าทายเสมอ
</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>
<!-- Horizontal Scrollable Container -->
<div class="relative">
<div class="flex justify-center gap-6 overflow-x-auto pb-12 px-4 no-scrollbar scroll-smooth snap-x flex-wrap">
<div v-for="(card, i) in stepOneCards" :key="i"
class="flex-none w-[280px] snap-start group cursor-pointer p-8 rounded-[2.5rem] aspect-[3/4.5] flex flex-col justify-between transition-all hover:-translate-y-3 shadow-xl overflow-hidden relative"
:class="card.bgClass"
@click="goBrowse(card.categorySlug)"
>
<!-- 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-xs font-black uppercase tracking-[0.2em] opacity-80 mb-3 block text-white/80">าวแรกของ</span>
<h3 class="text-3xl font-black leading-none tracking-tight text-white mb-2" style="text-shadow: 0 2px 4px rgba(0,0,0,0.1)">{{ card.title }}</h3>
</div>
<div class="space-y-6 relative z-10">
<p class="text-lg font-bold leading-snug text-white/95" style="text-shadow: 0 1px 2px rgba(0,0,0,0.1)">{{ card.desc }}</p>
<div class="flex justify-end">
<div class="w-14 h-14 rounded-full border-2 border-white/30 flex items-center justify-center transition-all bg-white/10 group-hover:bg-white/20 group-hover:scale-110 backdrop-blur-md shadow-lg">
<q-icon name="arrow_forward" size="28px" class="text-white" />
</div>
</div>
</div>
</div>
</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" />
<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="flex items-center gap-4">
<div class="w-12 h-12 rounded-2xl bg-amber-50 flex items-center justify-center text-amber-600"> <div class="flex-shrink-0 w-9 h-9 flex items-center justify-center rounded-2xl bg-blue-600/20">
<q-icon name="query_builder" size="28px" /> <q-icon name="o_play_circle" size="25px" class="text-blue-600" />
</div> </div>
<div> <div class="flex flex-col">
<div class="text-xs font-bold text-slate-400 uppercase tracking-tighter">Access Status</div> <span class="text-slate-900 font-bold text-sm leading-5">
<div class="text-xl font-black text-slate-900">เขาถงไดตลอดช</div> เรยนรการออกแบบ UI/UX
</div> </span>
</div> <span class="text-slate-500 text-xs leading-4 mt-0.5">
</div> คอรสวโอ 12 บทเรยน
</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> </span>
<h2 class="text-4xl md:text-6xl font-black text-slate-900 leading-[1.15] md:leading-[1.12] 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">
เราคดสรรและคราฟตกคอรสเรยนเพอใหนใจวาคณจะไดบประสบการณการเรยนร ไมาจะอยไหนหรอเวลาใดกตาม
</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> </div>
</div> </div>
</div>
<q-btn </div> -->
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> </div>
</div> </div>
</section> </section>
<!-- Section 2: ทำไมตองเลอกแพลตฟอรมของเรา -->
<section class="pt-20 pb-14 bg-white relative flex-col">
<div class="container mx-auto px-6 lg:px-12">
<!-- Heading -->
<div class="text-center mb-16 slide-up">
<h2 class="text-4xl md:text-[2.4rem] font-bold text-slate-900 mb-6">
ทำไมตองเลอกแพลตฟอรมของเรา?
</h2>
<p class="text-slate-500 text-base font-normal md:text-xl max-w-3xl mx-auto leading-relaxed">
เรามเครองมอและความเชยวชาญทจะชวยใหณประสบความสำเรจในการเปลยนสายอาชพและการสรางทกษะระดบมออาช
</p>
</div>
<!-- Horizontal Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div v-for="(item, i) in whyChooseUs" :key="i"
class="slide-up p-10 rounded-2xl bg-[#F8FAFC] border border-[#f1f2f9] hover:border-[#2463eb61] hover:bg-white transition-all duration-500 group"
:style="`animation-delay: ${i * 0.1}s`"
>
<div class="w-14 h-14 rounded-full bg-[#E3EBFA] flex items-center justify-center mb-5 transition-transform group-hover:scale-110 duration-500">
<q-icon :name="item.icon" size="28px" class="text-blue-600" />
</div>
<h3 class="text-[1.3rem] font-bold text-slate-900 group-hover:text-blue-600 transition-colors">
{{ item.title }}
</h3>
<p class="text-slate-500 text-lg leading-relaxed ">
{{ item.desc }}
</p>
</div>
</div>
</div>
</section>
<!-- Section 3: เลอกเรยนตามเรองทณสนใจ -->
<section class="py-20 md:py-24 bg-white">
<div class="container mx-auto px-6 lg:px-12">
<!-- Heading -->
<div class="mb-12 slide-up">
<h2 class="text-[1.4rem] text-3xl md:text-4xl font-semibold text-slate-900 px-4">
เลอกเรยนตามเรองทณสนใจ
</h2>
</div>
<!-- Horizontal Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 justify-center gap-6 px-4">
<div v-for="(card, i) in categoryCards" :key="i"
class="cursor-pointer bg-white rounded-3xl p-6 border border-slate-200/80 shadow-[0px_1px_2px_0px_rgba(0,0,0,0.05)] hover:shadow-2xl hover:shadow-blue-600/5 hover:-translate-y-1 hover:border-[#2463eb61] transition-all duration-500 flex items-center gap-5"
@click="goBrowse(card.slug)"
>
<!-- Icon Box -->
<div class="flex-shrink-0 w-16 h-16 rounded-2xl flex items-center justify-center bg-slate-50 group-hover:scale-110 transition-transform duration-500">
<q-icon :name="card.icon" size="35px" class="text-blue-600" />
</div>
<!-- Content -->
<div class="flex-grow pr-2">
<h3 class="text-lg md:text-xl font-bold text-slate-900 mb-1 group-hover:text-blue-600 transition-colors leading-tight">
{{ card.title }}
</h3>
<p class="text-slate-600 text-xs md:text-sm leading-relaxed opacity-70">
{{ card.desc }}
</p>
</div>
<!-- Arrow -->
<div class="gt-xs flex-shrink-0 text-slate-300 group-hover:text-blue-600 transition-colors transform group-hover:translate-x-1 duration-300">
<q-icon name="chevron_right" size="24px" />
</div>
</div>
</div>
</div>
</section>
<!-- Section 4: "คอร์สออนไลน์" --> <!-- Section 4: "คอร์สออนไลน์" -->
<section class="pt-12 pb-24 md:pt-20 md:pb-40 bg-slate-50/50"> <section class="py-12 md:py-24 bg-slate-50">
<div class="container mx-auto px-6 lg:px-12"> <div class="container mx-auto px-6 lg:px-12">
<div class="flex flex-col md:flex-row items-start md:items-end justify-between mb-12 gap-8"> <!-- Heading -->
<div class="flex flex-col md:flex-row items-start md:items-end justify-between mb-5 gap-8">
<div class="slide-up"> <div class="slide-up">
<h2 class="text-3xl md:text-5xl font-black text-slate-900 mb-4">คอรสออนไลน</h2> <h2 class="text-4xl md:text-[2.4rem] font-bold text-slate-900 mb-4">คอรสออนไลน</h2>
<p class="text-slate-500 font-black text-lg">เรมตนเรยนรกษะใหมวยคอรสคณภาพจากผเชยวชาญ</p>
</div> </div>
<NuxtLink to="/browse" class="flex items-center gap-3 px-8 py-3 rounded-full border-2 border-blue-600 text-blue-700 font-bold hover:bg-blue-600 hover:text-white transition-all slide-up"> <NuxtLink to="/browse" class="flex items-center py-3 text-lg rounded-full font-bold ">
คอรสออนไลนงหมด <q-icon name="arrow_forward" size="20px" /> <span class="text-blue-600 hover:text-blue-500 ">คอรสทงหมด</span>
<q-icon name="arrow_forward" size="15px" class="text-blue-600 ml-2" />
</NuxtLink> </NuxtLink>
</div> </div>
<!-- Filter Tabs / Pills --> <!-- Filters Row -->
<div class="flex items-center gap-4 mb-8 overflow-x-auto pb-6 no-scrollbar slide-up"> <div class="flex items-center gap-2 mb-8 overflow-x-auto no-scrollbar slide-up justify-between">
<!-- Category Filters -->
<div class="flex items-center gap-2">
<button <button
class="px-8 py-3 rounded-full font-black text-base transition-all whitespace-nowrap border-2" class="py-2 px-5 rounded-full font-medium text-lg transition-all whitespace-nowrap border-2"
:class="selectedCategory === 'all' ? 'bg-blue-600 text-white border-blue-600 shadow-lg shadow-blue-600/30' : 'bg-white border-slate-100 text-slate-500 hover:border-slate-300'" :class="selectedCategory === 'all' ? 'bg-blue-600 text-white border-blue-600 font-semibold' : 'bg-white border-slate-100 text-slate-700 hover:border-slate-300'"
@click="selectedCategory = 'all'" @click="selectedCategory = 'all'"
> >
<q-icon name="o_check_circle" size="20px" class="mr-1" />
งหมด งหมด
</button> </button>
<button <button
v-for="category in categories" v-for="category in categories"
:key="category.id" :key="category.id"
class="px-8 py-3 rounded-full font-black text-base transition-all whitespace-nowrap border-2" class="py-2 px-5 rounded-full font-medium text-lg transition-all whitespace-nowrap border-[1.5px]"
:class="selectedCategory === category.slug ? 'bg-blue-600 text-white border-blue-600 shadow-lg shadow-blue-600/30' : 'bg-white border-slate-100 text-slate-500 hover:border-slate-300'" :class="selectedCategory === category.slug ? 'bg-blue-600 text-white border-blue-600 font-semibold' : 'bg-white border-slate-200 text-slate-700 hover:border-slate-300'"
@click="selectedCategory = category.slug" @click="selectedCategory = category.slug"
> >
<q-icon :name="category.icon || 'o_label'" size="20px" class="mr-1" />
{{ getLocalizedText(category.name) }} {{ getLocalizedText(category.name) }}
</button> </button>
</div> </div>
<!-- Level Dropdown -->
<!-- <div class="flex items-center gap-2 font-medium">
<q-select
borderless
v-model="levelModel"
:options="levelOptions"
dropdown-icon="o_keyboard_arrow_down"
class="text-lg"
popup-content-class="rounded-lg text-lg shadow-sm text-slate-700"
>
<template v-slot:before>
<div class="text-slate-700 text-lg">ระดบความยาก:</div>
</template>
</q-select>
</div> -->
</div>
<!-- Courses Carousel --> <!-- Courses Carousel -->
<div v-if="isLoading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-10"> <div v-if="isLoading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div v-for="i in 4" :key="i" class="bg-white rounded-[3rem] h-[480px] animate-pulse" /> <div v-for="i in 4" :key="i" class="bg-white rounded-2xl h-[450px] animate-pulse" />
</div> </div>
<div v-else class="relative group/carousel slide-up"> <div v-else class="relative group/carousel slide-up">
@ -352,14 +317,63 @@ onMounted(() => {
v-for="(chunk, pageIndex) in courseChunks" v-for="(chunk, pageIndex) in courseChunks"
:key="pageIndex" :key="pageIndex"
:name="pageIndex" :name="pageIndex"
class="p-4" class="p-0"
> >
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-10"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<CourseCard <div
v-for="course in chunk" v-for="course in chunk"
:key="course.id" :key="course.id"
v-bind="{ ...course, image: course.thumbnail_url }" class="flex flex-col flex-1 min-w-0 rounded-2xl border border-slate-100 bg-white shadow-sm overflow-hidden hover:shadow-lg hover:-translate-y-1 transition-all duration-300 cursor-pointer"
@click="navigateTo(`/course/${course.id}`)"
>
<!-- Image-->
<div class="relative flex-shrink-0">
<img
v-if="course.thumbnail_url"
:src="course.thumbnail_url"
:alt="getLocalizedText(course.title)"
class="w-full h-[150px] sm:h-[180px] lg:h-[200px] object-cover"
/> />
<div v-else class="w-full h-[150px] sm:h-[180px] lg:h-[200px] bg-slate-100 flex items-center justify-center">
<q-icon name="o_image" size="40px" class="text-slate-300" />
</div>
</div>
<!-- Content -->
<div class="flex flex-col flex-1 p-6">
<!-- Title -->
<h3 class="text-[#0F172A] font-semibold text-lg leading-snug mb-2 line-clamp-2">
{{ getLocalizedText(course.title) }}
</h3>
<!-- Description -->
<p class="text-slate-500 text-sm leading-relaxed mb-4 flex-1 line-clamp-2">
{{ getLocalizedText(course.description) }}
</p>
<!-- Price + Button -->
<div class="flex items-center justify-between pt-6 border-t border-slate-100 gap-2">
<div class="flex flex-col">
<span v-if="course.price > 0" class="text-[#0F172A] font-bold text-xl">
{{ course.price.toLocaleString() }}.-
</span>
<span v-else class="text-green-600 font-bold text-xl">
ฟร
</span>
</div>
<button class="flex items-center gap-2 px-4 py-2 rounded-full bg-[#2463EB]/10 hover:bg-[#2463EB]/20 transition-colors">
<q-icon name="o_remove_red_eye" size="18px" class="text-[#2463EB]" />
<span class="text-[#2463EB] font-medium text-sm">
รายละเอยด
</span>
</button>
</div>
</div>
</div>
</div> </div>
</q-carousel-slide> </q-carousel-slide>
</q-carousel> </q-carousel>
@ -386,11 +400,55 @@ onMounted(() => {
</div> </div>
</div> </div>
</section> </section>
<!-- Section 5: "พร้อมเริ่มต้นการเรียนรู้แล้วหรือยัง" -->
<section class="py-16 md:py-24 bg-white">
<div class="max-w-7xl mx-auto px-6 lg:px-12">
<div class="bg-blue-600 rounded-3xl px-8 py-20 md:px-18 text-center relative overflow-hidden">
<div class="gradient-background">
<div class="gradient-sphere sphere-1"></div>
<div class="gradient-sphere sphere-2"></div>
<div class="gradient-sphere sphere-3"></div>
</div>
<div class="grid-overlay"></div>
<div class="relative z-10">
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">
พรอมเรมตนการเรยนรแลวหรอย?
</h2>
<p class="text-blue-100 text-lg mb-8 max-w-xl mx-auto">
ปสกลและรบทกษะทณตองการเพอกาวหนาในระดบมออาช
เปดประสบการณการเรยนรปแบบใหม สมครเลยวนนเพอเรมตนเขาสบทเรยน
</p>
<div class="flex flex-wrap justify-center gap-4">
<q-btn
unelevated
rounded
class="px-8 py-4 bg-white font-bold rounded-3xl hover:bg-slate-50 transition-colors"
no-caps
size="18px"
to="/browse"
>
<div class="text-blue-600">สำรวจคอรสเรยน</div>
</q-btn>
<q-btn
outline
rounded
label="สมัครฟรีวันนี้"
color="white"
class="px-8 py-4 font-bold rounded-3xl hover:bg-white/10 transition-colors"
no-caps
size="18px"
to="/auth/register"
v-if="!user"
/>
</div>
</div>
</div>
</div>
</section>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.landing-page { .landing-page {
font-family: var(--font-main); font-family: var(--font-main);
@ -415,15 +473,6 @@ onMounted(() => {
opacity: 0; opacity: 0;
} }
@keyframes float {
0%, 100% { transform: translateY(0) rotate(0); }
50% { transform: translateY(-20px) rotate(5deg); }
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
@keyframes fade-in { @keyframes fade-in {
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }
@ -433,26 +482,80 @@ onMounted(() => {
animation: fade-in 1s ease-out forwards; animation: fade-in 1s ease-out forwards;
} }
/* Typography Overrides */ /* Hero Right Hover State */
h1, h2, h3 { .hero-right:hover .relative {
letter-spacing: -0.02em; transform: translateY(-10px);
transition: transform 0.5s ease;
} }
/* Hover effects */ .gradient-background {
.hero-right:hover .animate-float { position: absolute;
animation-play-state: paused; top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
overflow: hidden;
} }
/* Responsive Grid Adjustments */ .gradient-sphere {
@media (max-width: 1200px) { position: absolute;
.career-cards-grid { border-radius: 50%;
grid-template-columns: repeat(4, 1fr); filter: blur(60px);
}
} }
@media (max-width: 768px) { .sphere-1 {
.career-cards-grid { width: 30vw;
grid-template-columns: repeat(2, 1fr); height: 30vw;
} background: linear-gradient(40deg, rgba(255, 255, 255, 0.41), rgba(255, 255, 255, 0.164));
top: 10%;
left: -30%;
animation: float-1 15s ease-in-out infinite alternate;
}
.sphere-2 {
width: 45vw;
height: 45vw;
background: linear-gradient(240deg, rgba(16, 33, 121, 0.245), rgba(155, 169, 239, 0.263));
bottom: -20%;
right: -35%;
animation: float-2 18s ease-in-out infinite alternate;
}
.sphere-3 {
width: 30vw;
height: 30vw;
background: linear-gradient(120deg, rgba(133, 89, 255, 0.5), rgba(98, 216, 249, 0.3));
top: 60%;
left: 20%;
animation: float-3 20s ease-in-out infinite alternate;
}
@keyframes float-1 {
0% { transform: translate(0, 0) scale(1); }
100% { transform: translate(10%, 10%) scale(1.1); }
}
@keyframes float-2 {
0% { transform: translate(0, 0) scale(1); }
100% { transform: translate(-10%, -5%) scale(1.15); }
}
@keyframes float-3 {
0% { transform: translate(0, 0) scale(1); opacity: 0.3; }
100% { transform: translate(-5%, 10%) scale(1.05); opacity: 0.6; }
}
.grid-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: 40px 40px;
background-image:
linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
z-index: 2;
} }
</style> </style>

View file

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

Binary file not shown.

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

@ -1,35 +0,0 @@
วันที่บันทึกปฏิบัติงาน \*
18/02/2026
องค์ความรู้ที่ได้รับ \*
- การใช้งาน Nuxt 3 Routing (useRoute, useRouter) ในการจัดการ Query Parameters
- การเชื่อมโยง State กับ URL เพื่อสร้าง Deep Linking ให้แชร์ลิงก์หมวดหมู่ได้
- การใช้งาน Vue 3 Composition API (Script Setup) แทน Options API เพื่อความเป็นระเบียบและลดความซับซ้อน
- การตกแต่ง UI ด้วย Tailwind CSS ขั้นสูง (Gradients, Glow Effects, Backdrop Filters) เพื่อให้ได้ธีม Premium/Clean
- การจัดการ Event Listener ใน Vue Component (onMounted, onUnmounted) เพื่อป้องกัน Memory Leak
รายละเอียด \*
- พัฒนาระบบ Filter หมวดหมู่คอร์สเรียน เชื่อมโยงหน้าแรก (Home) กับหน้าค้นหา (Browse)
- ปรับแก้โครงสร้าง Code ในหน้า `index.vue` ให้ใช้ Script Setup ทั้งหมดเพื่อลดความซับซ้อนและแก้ปัญหาการเรียกใช้ตัวแปร
- ปรับดีไซน์ส่วน Call to Action (CTA) ในหน้า `browse/index.vue` และ `browse/recommended.vue` ให้ดูสว่างและทันสมัยขึ้นโดยใช้แสงและเงา (Blue Glow)
ปัญหาและอุปสรรค \*
- พบปัญหาการทำงานร่วมกันระหว่าง Options API และ Script Setup ในไฟล์ `index.vue` ทำให้ฟังก์ชันบางตัวเรียกใช้ไม่ได้
- ระบบ Filter เดิมไม่ทำงานเมื่อกดย้อนกลับ (Back Button) เนื่องจากไม่ได้ Watch การเปลี่ยนแปลงของ URL Query
- การดึงข้อมูลคอร์สเริ่มต้นได้ไม่ครบเนื่องจาก API มีการกำหนด Limit ไว้
รายละเอียด \*
- แก้ไขโดยการยุบรวม Code ทั้งหมดให้เป็นรูปแบบ Script Setup มาตรฐานเดียว
- เพิ่ม `watch(() => route.query.category)` ในหน้า Browse เพื่อให้อัปเดตข้อมูลทุกครั้งที่ URL เปลี่ยน
- เพิ่มพารามิเตอร์ `limit: 1000` ในการเรียก API เพื่อดึงข้อมูลคอร์สทั้งหมดมาแสดง
หลักฐานการปฏิบัติงาน (เฉพาะไฟล์ JPG, JPEG, PNG, PDF.)
- แก้ไขไฟล์ `pages/index.vue` (เพิ่ม goBrowse, ย้าย Logic)
- แก้ไขไฟล์ `pages/browse/index.vue` (เพิ่ม Query Watcher, ปรับ UI)
- แก้ไขไฟล์ `pages/browse/recommended.vue` (ปรับ UI ส่วน CTA)
- แก้ไขไฟล์ `components/layout/LandingHeader.vue` (แก้ Memory Leak)

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 ### 1.1 Tech Stack
- **Core:** [Nuxt 3](https://nuxt.com) (v`^3.11.2`), TypeScript `^5.4.5` - **Framework:** Nuxt 3 (Vue 3, Vite, SSR/SPA Hybrid)
- **UI Framework:** Quasar Framework `^2.15.2` (via `nuxt-quasar-ui ^3.0.0`) - **UI System:** Quasar Framework + Tailwind CSS (Utility-first)
- **Styling:** Tailwind CSS `^6.12.0` (Utility) + Vanilla CSS Variables (Theming/Dark Mode) - **Typography:** Google Fonts (**Prompt** เป็น Font หลักเพื่อความทันสมัยและอ่านง่าย)
- **State Management:** `ref`/`reactive` (Local) + `useState` (Global/Shared State) - **Multilingual:** `@nuxtjs/i18n` (รองรับ JSON-based locales ภาษาไทยและอังกฤษ)
- **Localization:** `@nuxtjs/i18n` (Supports JSON locales in `i18n/locales/`) - **Programming:** TypeScript (Strict Type Checking)
- **Media Control:** `useMediaPrefs` (Command Pattern for global volume/mute state)
### 1.2 Core Systems & Security ### 1.2 Security & Authentication
- **Authentication:** - **Token Management:** ใช้ JWT (Access & Refresh Tokens) จัดเก็บผ่าน `useCookie`
- ใช้ **JWT** (Access Token 1 วัน, Refresh Token 7 วัน) โดยตั้งค่าความปลอดภัยระดับ **HTTP-only** และ **SameSite**
- เก็บ Token ใน `useCookie` (Secure, SameSite) - **Middleware:** `auth.ts` ตรวจสอบสิทธิ์การเข้าถึงหน้า Dashboard และ Classroom แบบ Real-time
- Middleware (`middleware/auth.ts`) ป้องกัน Route ตามสถานะ - **Persistence:** ระบบ Remember Me (จดจำอีเมล) ใช้ `localStorage` แยกส่วนจาก Session
- **Remember Me:** ระบบจดจำอีเมลลงใน `localStorage` (จำแยกจาก session, ไม่ถูกลบเมื่อ Logout) เพื่อความปลอดภัยและสะดวกสำหรับผู้ใช้
- **API Handling:**
- ใช้ `runtimeConfig.public.apiBase` เชื่องโยง Backend
- Auto-attach Bearer Token ใน `useAuth` และ `useCourse`
- **Performance:**
- **Hybrid Progress Saving:** บันทึกเวลาเรียนลง LocalStorage (ถี่) และ Server (Throttle 15s) เพื่อความแม่นยำสูงสุด
- **Caching:** ใช้ `useState` จำข้อมูล Profile และ Categories ลด request
- **Code Quality:** ลบ Log, Dead logic และ Redundant comments ทั่วทั้งโปรเจกต์ (Clean Code Phase)
--- ---
## 📂 2. Frontend Structure (โครงสร้างหน้าเว็บและ UI) ## 2. Project Architecture
### 2.1 Application Routes (`pages/`) โครงสร้างโฟลเดอร์ที่จัดระเบียบตามหลัก Clean Architecture เพื่อความคล่องตัวในการขยายระบบ
| Module | ไฟล์ | Path | หน้าที่ | ### 2.1 Directory Structure
| :---------- | :------------------------- | :---------------------- | :-------------------------------------------- |
| **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.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/`):** ### 2.2 Shared Infrastructure
- `GlobalLoader.vue`: Loading indicator ทั่วทั้งแอป
- `LanguageSwitcher.vue`: ปุ่มเปลี่ยนภาษา (TH/EN) - **Types Architecture:** การสกัด Types จาก Composable ออกมาไว้ที่ `@/types`
- `AppHeader.vue`, `MobileNav.vue`: Navigation หลัก (ใช้ร่วมกับ AppSidebar) ช่วยลดความซ้ำซ้อนและป้องกัน Error จากการเปลี่ยนโครงสร้างข้อมูล API
- `FormInput.vue`: Input field มาตรฐาน - **Constants System:** การใช้ `@/constants` ช่วยให้การแก้ไขคำโฆษณาหรือข้อมูลหน้าแรกทำได้จากจุดเดียว
- **Layout (`components/layout/`):** โดยไม่ต้องแก้โค้ด HTML
- `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`: ฟอร์มเปลี่ยนรหัสผ่าน
--- ---
## 🧠 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` - `useThemeMode`
- **Features:** Refresh Token อัตโนมัติ, ตรวจสอบ Role, **Logout Logic ที่ไม่ลบข้อมูลจดจำผู้ใช้** ระบบจัดการธีมกลางที่เชื่อมต่อกับ `localStorage` และ CSS Variables อย่างเป็นระบบ
### 3.2 `useCourse.ts` (Course & Classroom) - `useQuizRunner`
จัดการสถานะการสอบ เปลี่ยนข้อสอบ และส่งคะแนนไปยัง Backend โดยตรง
หัวใจหลักของการเรียนการสอน - `useNavItems`
Single Source of Truth สำหรับเมนูทั้งหมด (Sidebar, Mobile Drawer, User Menu)
- **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 จริงได้ทันที
--- ---
## 🎨 4. Design System & Theming ## 4. Branding & UI Policy
มาตรฐานการออกแบบที่เน้นความ Premium และ Consistent
### 4.1 Theme Strategy ### 4.1 Theme Strategy
- **Framework:** Tailwind CSS + Quasar UI - **Public Pages (Landing, Auth, Detail):** บังคับ **Forced Light Mode**
- **Light/Dark Mode Policy:** เพื่อภาพลักษณ์แบรนด์ที่สะอาดและน่าเชื่อถือ
- **Public Pages:** บังคับ **Light Mode** (Landing, Course Detail, Auth) เพื่อภาพลักษณ์แบรนด์ที่สะอาดตา - **Internal Pages (Dashboard, Learning):** รองรับ **Dark Mode (Oceanic Theme)**
- **Dashboard/Learning:** รองรับ **Dark Mode** เต็มรูปแบบ (Oceanic Theme) ลดการเมื่อยล้าของสายตาขณะเรียนเป็นเวลานาน
- **Aesthetics:** ปรับปรุงความชัดเจนของ Badge, Icon และสถานะต่างๆ ในหน้าสอบ (Quiz) สำหรับโหมดมืดโดยเฉพาะ ให้มี Contrast สูงและดู Premium - **Transitions:** ใช้ GlobalLoader และ Smooth transitions ทั่วทั้งแอปเพื่อประสบการณ์ที่ลื่นไหล
- **Visual Fixes:** แก้ไขปัญหา "Dark Frame" ในหน้า Auth โดยการบังคับสไตล์ระดับ HTML/Body
### 4.2 UI Elements
- **Image 2 Style Categories:** การ์ดหมวดหมู่แบบแนวนอนที่เป็นระเบียบ (Minimalist)
- **Glassmorphism:** พื้นผิวโปร่งแสงใน Dashboard และ Classroom ช่วยให้แอปดูมีมิติ
- **Standardized Icons:** ใช้ Material Icons ผ่าน Quasar ระบบเดียวทั้งหมด
--- ---
## 📊 5. Dependency Map (ความสัมพันธ์ไฟล์) ## 5. Core Feature Highlights
| หน้าเว็บ (Page) | Components หลัก | Composables หลัก | ฟีเจอร์เด่นที่ถูกพัฒนาขึ้นเพื่อผู้เรียนโดยเฉพาะ
| :----------------------- | :--------------------------- | :----------------------------------------------------- |
| **Login / Register** | `FormInput` | `useAuth` (Remember Me), `useFormValidation` | - **SPA Learning Journey:** การสลับบทเรียนในห้องเรียนเป็นแบบ Single Page App (ไม่มีการ Re-load หน้า)
| **Discovery (Browse)** | `CourseCard` | `useCourse` (Search/Filter), `useCategory` | ทำให้การเรียนต่อเนื่อง
| **My Courses** | `CourseCard` (with Progress) | `useCourse` (Certificates) | - **Hybrid Progress Tracking:** บันทึกเวลาเรียนลง `localStorage` แบบ Real-time และ Sync ขึ้น Server เป็นระยะ
| **Classroom (Learning)** | Video Player, Sidebar | `useCourse` (Progress, Announcements), `useMediaPrefs` | เพื่อป้องกันข้อมูลหาย
| **Quiz** | `QuizHeader`, `QuizContent` | `useQuizRunner` (Real API Integration) | - **Announcement System:** ระบบแจ้งเตือนในคอร์สพร้อมตัวระบุ "ยังไม่ได้อ่าน" (Unread Badge)
| **Profile** | `UserAvatar`, `FormInput` | `useAuth` (Upload Avatar, Verify Email) | ที่จำสถานะตามผู้ใช้งาน
- **Interactive Quizzes:** ระบบสอบที่สลับคำถามอัตโนมัติ พร้อมโหมดเฉลย (Answer Review) ที่ชัดเจน
- **Certificate Automation:** ระบบตรวจสอบสิทธิ์ความสำเร็จและออกใบประกาศนียบัตรได้ทันที
--- ---
## ✅ 6. Project Status (สถานะล่าสุด) ## 6. Maintenance & Performance Guidelines
### ✨ Recent Updates (กุมภาพันธ์ 2026) แนวทางสำหรับการพัฒนาต่อยอด
1. **System-Wide Code Cleanup (Phase Final):** - **Clean Code:** หลีกเลี่ยงการใช้ `console.log` ในโค้ด Final และลบ Dead Logic ทิ้งทันที
- **Refactoring:** ปัดกวาดโค้ดในหน้า `learning`, `quiz`, `discovery`, `dashboard` และ `profile` - **Standard Fonts:** ใช้ชุด Font Prompt ผ่านตัวแปร `--font-main` เสมอ
- **Logging:** ลบ `console.log` มหาศาล และ logic ซ้ำซ้อนที่ตกค้างจากการพัฒนา - **API Integrity:** ตรวจสอบข้อมูลผ่าน Interface ใน `@/types` ก่อนการใช้งานทุกครั้ง
- **Structure:** จัดกลุ่มสไตล์และฟังก์ชันให้เป็นระเบียบ อ่านง่ายขึ้นตามมาตรฐาน Clean Code - **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"> <div v-else-if="lessonDetail" class="p-6 space-y-6">
<!-- Video Player --> <!-- Video Player -->
<div v-if="lesson.type === 'VIDEO' && lessonDetail.video_url" class="aspect-video bg-black rounded-lg overflow-hidden"> <div v-if="lesson.type === 'VIDEO' && lessonDetail.video_url" class="aspect-video bg-black rounded-lg overflow-hidden">
<iframe
v-if="isYoutubeUrl(lessonDetail.video_url)"
:src="getYoutubeEmbedUrl(lessonDetail.video_url)"
class="w-full h-full"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
<video <video
v-else
:src="lessonDetail.video_url" :src="lessonDetail.video_url"
controls controls
class="w-full h-full object-contain" class="w-full h-full object-contain"
@ -38,7 +47,7 @@
</h3> </h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<a <a
v-for="file in lessonDetail.attachments" v-for="file in lessonDetail.attachments.filter(f => !['video/mp4', 'video/youtube'].includes(f.mime_type))"
:key="file.id" :key="file.id"
:href="file.file_path" :href="file.file_path"
target="_blank" target="_blank"
@ -74,6 +83,28 @@
</div> </div>
</div> </div>
<!-- Quiz Settings -->
<div class="mt-4 p-4 bg-white rounded-lg border border-blue-100">
<div class="font-semibold text-gray-700 mb-3">การตงคาเพมเต</div>
<div class="flex flex-wrap gap-2">
<q-chip :color="lessonDetail.quiz.shuffle_questions ? 'positive' : 'grey-4'" :text-color="lessonDetail.quiz.shuffle_questions ? 'white' : 'grey-8'" size="m" :icon="lessonDetail.quiz.shuffle_questions ? 'check' : 'close'">
มคำถาม
</q-chip>
<q-chip :color="lessonDetail.quiz.shuffle_choices ? 'positive' : 'grey-4'" :text-color="lessonDetail.quiz.shuffle_choices ? 'white' : 'grey-8'" size="m" :icon="lessonDetail.quiz.shuffle_choices ? 'check' : 'close'">
มตวเลอก
</q-chip>
<q-chip :color="lessonDetail.quiz.show_answers_after_completion ? 'positive' : 'grey-4'" :text-color="lessonDetail.quiz.show_answers_after_completion ? 'white' : 'grey-8'" size="m" :icon="lessonDetail.quiz.show_answers_after_completion ? 'check' : 'close'">
เฉลยหลงทำเสร
</q-chip>
<q-chip :color="lessonDetail.quiz.is_skippable ? 'positive' : 'grey-4'" :text-color="lessonDetail.quiz.is_skippable ? 'white' : 'grey-8'" size="m" :icon="lessonDetail.quiz.is_skippable ? 'check' : 'close'">
ามขอได
</q-chip>
<q-chip :color="lessonDetail.quiz.allow_multiple_attempts ? 'positive' : 'grey-4'" :text-color="lessonDetail.quiz.allow_multiple_attempts ? 'white' : 'grey-8'" size="m" :icon="lessonDetail.quiz.allow_multiple_attempts ? 'check' : 'close'">
ทำซำได
</q-chip>
</div>
</div>
<!-- Questions List --> <!-- Questions List -->
<div v-if="lessonDetail.quiz.questions && lessonDetail.quiz.questions.length > 0" class="mt-6 space-y-6"> <div v-if="lessonDetail.quiz.questions && lessonDetail.quiz.questions.length > 0" class="mt-6 space-y-6">
<!-- ... (questions rendering code unchanged) ... --> <!-- ... (questions rendering code unchanged) ... -->
@ -175,6 +206,23 @@ const formatFileSize = (bytes: number) => {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}; };
const isYoutubeUrl = (url: string) => {
return url.includes('youtube.com') || url.includes('youtu.be');
};
const getYoutubeEmbedUrl = (url: string) => {
let videoId = '';
if (url.includes('youtu.be')) {
videoId = url.split('/').pop()?.split('?')[0] || '';
} else if (url.includes('youtube.com')) {
const params = new URLSearchParams(url.split('?')[1]);
videoId = params.get('v') || '';
}
return `https://www.youtube.com/embed/${videoId}`;
};
const fetchLessonDetail = async () => { const fetchLessonDetail = async () => {
// Always verify lesson and courseId exist // Always verify lesson and courseId exist
if (!props.lesson || !props.courseId) return; if (!props.lesson || !props.courseId) return;

View file

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

View file

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

View file

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

View file

@ -218,7 +218,7 @@
<p>เลอกระยะเวลาทองการเกบไว (ลบขอมลทเกากวากำหนด):</p> <p>เลอกระยะเวลาทองการเกบไว (ลบขอมลทเกากวากำหนด):</p>
<q-select <q-select
v-model="cleanupDays" v-model="cleanupDays"
:options="[30, 60, 90, 180, 365]" :options="[7, 15, 30, 60, 90, 180, 365]"
label="จำนวนวัน" label="จำนวนวัน"
suffix="วัน" suffix="วัน"
outlined outlined

View file

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

View file

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

View file

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

View file

@ -104,7 +104,7 @@
<!-- View Details Dialog --> <!-- View Details Dialog -->
<q-dialog v-model="showDialog" maximized transition-show="slide-up" transition-hide="slide-down"> <q-dialog v-model="showDialog" maximized transition-show="slide-up" transition-hide="slide-down">
<q-card> <q-card>
<q-bar class="bg-primary text-white"> <q-bar class="bg-primary-500 text-white">
<q-space /> <q-space />
<q-btn dense flat icon="close" v-close-popup> <q-btn dense flat icon="close" v-close-popup>
<q-tooltip class="bg-white text-primary">Close</q-tooltip> <q-tooltip class="bg-white text-primary">Close</q-tooltip>
@ -153,7 +153,7 @@
<!-- Category --> <!-- Category -->
<div class="bg-gray-50 p-4 rounded-lg gap-2"> <div class="bg-gray-50 p-4 rounded-lg gap-2">
<div class="font-bold mb-2">หมวดหม (Category):</div> <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> </div>
<!-- Instructors --> <!-- Instructors -->
@ -164,7 +164,7 @@
<q-icon name="person" /> <q-icon name="person" />
</q-avatar> </q-avatar>
<div> <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 class="text-xs text-gray-500" v-if="inst.is_primary">(Primary)</div>
</div> </div>
</div> </div>
@ -183,6 +183,34 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Course Structure -->
<div v-if="selectedCourse.chapters && selectedCourse.chapters.length > 0" class="mt-6">
<div class="font-bold text-lg mb-3">โครงสรางหลกสตร (Course Structure)</div>
<div class="space-y-3">
<q-expansion-item
v-for="(chapter, index) in selectedCourse.chapters"
:key="chapter.id"
:label="`บทที่ ${index + 1}: ${chapter.title.th}`"
:caption="`${chapter.lessons.length} บทเรียน`"
header-class="bg-gray-50 rounded-lg"
expand-icon-class="text-primary"
>
<div class="pl-4 pt-2">
<div
v-for="(lesson, lessonIndex) in chapter.lessons"
:key="lesson.id"
class="flex items-center gap-3 py-2 border-b last:border-b-0"
>
<q-icon name="article" color="primary" size="20px" />
<span class="text-gray-700">{{ lessonIndex + 1 }}. {{ lesson.title.th }}</span>
<span v-if="lesson.title.en" class="text-gray-400 text-xs ml-auto">{{ lesson.title.en }}</span>
</div>
</div>
</q-expansion-item>
</div>
</div>
</q-card-section> </q-card-section>
<!-- Inner Loading --> <!-- 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: '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: '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: '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 () => { const fetchCourses = async () => {
@ -248,7 +276,8 @@ const fetchCourses = async () => {
console.error('Error fetching courses:', error); console.error('Error fetching courses:', error);
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
message: 'ไม่สามารถโหลดข้อมูลคอร์สได้' message: 'ไม่สามารถโหลดข้อมูลคอร์สได้',
position: 'top'
}); });
} finally { } finally {
loading.value = false; loading.value = false;
@ -265,7 +294,8 @@ const viewCourse = async (id: number) => {
console.error('Error fetching course details:', error); console.error('Error fetching course details:', error);
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
message: 'ไม่สามารถโหลดข้อมูลรายละเอียดคอร์สได้' message: 'ไม่สามารถโหลดข้อมูลรายละเอียดคอร์สได้',
position: 'top'
}); });
showDialog.value = false; showDialog.value = false;
} finally { } finally {
@ -278,7 +308,8 @@ const handleToggleRecommendation = async (course: RecommendedCourse, isRecommend
await adminService.toggleCourseRecommendation(course.id, isRecommended); await adminService.toggleCourseRecommendation(course.id, isRecommended);
$q.notify({ $q.notify({
type: 'positive', type: 'positive',
message: isRecommended ? 'เพิ่มคอร์สแนะนำสำร็จ' : 'ยกเลิกคอร์สแนะนำสำเร็จ' message: isRecommended ? 'เพิ่มคอร์สแนะนำสำร็จ' : 'ยกเลิกคอร์สแนะนำสำเร็จ',
position: 'top'
}); });
} catch (error) { } catch (error) {
console.error('Error toggling recommendation:', error); console.error('Error toggling recommendation:', error);
@ -286,7 +317,8 @@ const handleToggleRecommendation = async (course: RecommendedCourse, isRecommend
course.is_recommended = !isRecommended; course.is_recommended = !isRecommended;
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
message: 'เกิดข้อผิดพลาดในการบันทึกข้อมูล' message: 'เกิดข้อผิดพลาดในการบันทึกข้อมูล',
position: 'top'
}); });
} }
}; };

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

View file

@ -296,6 +296,15 @@ export interface RecommendedCourse {
}; };
chapters_count: number; chapters_count: number;
lessons_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 { export interface RecommendedCoursesListResponse {
@ -311,7 +320,25 @@ const getAuthToken = (): string => {
return tokenCookie.value || ''; return tokenCookie.value || '';
}; };
// Role interface
export interface RoleResponse {
id: number;
code: string;
}
export const adminService = { export const adminService = {
async getRoles(): Promise<RoleResponse[]> {
const config = useRuntimeConfig();
const token = getAuthToken();
const response = await $fetch<{ roles: RoleResponse[] }>('/api/user/roles', {
baseURL: config.public.apiBaseUrl as string,
headers: {
Authorization: `Bearer ${token}`
}
});
return response.roles;
},
async getUsers(): Promise<AdminUserResponse[]> { async getUsers(): Promise<AdminUserResponse[]> {
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const token = getAuthToken(); const token = getAuthToken();

View file

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