Compare commits

..

41 commits

Author SHA1 Message Date
supalerk-ar66
b6c1aebe30 feat: Implement Playwright E2E tests for authentication, quiz, student account, and discovery, and add a new quiz page.
Some checks failed
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Failing after 31s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Has been skipped
Build and Deploy Frontend Learner / Notify Deployment Status (push) Failing after 2s
2026-03-04 15:07:02 +07:00
supalerk-ar66
1c63e79db1 feat: add homepage 2026-03-04 11:48:10 +07:00
JakkrapartXD
9bb941b45e feat: Add unit tests for backend validators and configure Jest. 2026-03-04 10:58:37 +07:00
supalerk-ar66
ebcae0b3e7 feat: implement dashboard page displaying course progress, recommendations, and quick category navigation. 2026-03-04 10:41:53 +07:00
supalerk-ar66
a3b2e55443 feat: Add Playwright end-to-end testing setup and initial test suites for various application flows. 2026-03-02 16:26:22 +07:00
Missez
9bc24fbe8a feat: Establish Playwright testing infrastructure with initial tests for authentication, admin, and instructor modules, and fix instructor video and quiz lesson management pages.
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 1m17s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 8s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 2s
2026-03-02 15:48:47 +07:00
JakkrapartXD
734d922393 feat: Allow null for prerequisite_lesson_ids in the ChaptersLesson validator schema.
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-27 14:39:33 +07:00
Missez
03f16cf2fd feat: Implement initial frontend for instructor and admin course management functionalities.
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 4m21s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 7s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 1s
2026-02-27 14:00:04 +07:00
supalerk-ar66
c8ef372d4e feat: Implement 'My Courses' page to display enrolled courses with progress tracking, filtering, and certificate download functionality. 2026-02-27 11:36:57 +07:00
supalerk-ar66
dccf808c2b Please provide the file changes to generate a commit message. 2026-02-27 10:11:58 +07:00
supalerk-ar66
ad11c6b7c5 feat: Implement initial e-learning platform frontend structure including dashboard, course management, authentication, and common UI components. 2026-02-27 10:05:33 +07:00
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
supalerk-ar66
1b9119e606 feat: Implement core application UI with new headers, navigation, and initial pages.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 42s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 3s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
2026-02-19 10:39:44 +07:00
supalerk-ar66
3fa236cff5 feat: Implement initial application layouts, global navigation, and course browsing pages with i18n support.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 41s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
2026-02-18 16:28:29 +07:00
JakkrapartXD
b56f604890 feat: introduce Joi validation schemas and integrate them across various controllers for categories, lessons, courses, chapters, announcements, and admin course approvals.
All checks were successful
Build and Deploy Backend / Build Backend Docker Image (push) Successful in 26s
Build and Deploy Backend / Deploy E-learning Backend to Dev Server (push) Successful in 3s
Build and Deploy Backend / Notify Deployment Status (push) Successful in 2s
2026-02-18 15:59:40 +07:00
JakkrapartXD
c5aa195b13 feat: implement course cloning functionality including chapters, lessons, quizzes, and attachments for instructors.
All checks were successful
Build and Deploy Backend / Build Backend Docker Image (push) Successful in 24s
Build and Deploy Backend / Deploy E-learning Backend to Dev Server (push) Successful in 3s
Build and Deploy Backend / Notify Deployment Status (push) Successful in 1s
2026-02-13 17:41:01 +07:00
Missez
5442f1beb6 feat: Introduce core admin and instructor dashboards with dedicated services, pages, and layouts. 2026-02-13 15:26:51 +07:00
JakkrapartXD
af14610442 feat: Add token-based authorization to category deletion and enhance user registration with error handling and audit logging. 2026-02-13 14:54:45 +07:00
JakkrapartXD
45941fbe6c feat: Add error audit logging to instructor course operations and implement status filtering for listing courses. 2026-02-13 14:45:59 +07:00
supalerk-ar66
21273fcaeb feat: Implement the core online learning classroom interface with video player, quiz management, and announcements. 2026-02-13 11:42:10 +07:00
175 changed files with 13487 additions and 3165 deletions

22
Backend/jest.config.js Normal file
View file

@ -0,0 +1,22 @@
/** @type {import('jest').Config} */
const config = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/tests'],
testMatch: ['**/*.test.ts'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
},
clearMocks: true,
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/generated/**',
'!src/server.ts'
],
transform: {
'^.+\\.tsx?$': ['ts-jest', { tsconfig: './tsconfig.test.json' }]
}
};
module.exports = config;

File diff suppressed because it is too large Load diff

View file

@ -44,6 +44,7 @@
"@types/bcrypt": "^5.0.2",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^1.4.12",
"@types/node": "^22.10.5",

171
Backend/pnpm-lock.yaml generated
View file

@ -78,6 +78,9 @@ importers:
'@types/express':
specifier: ^5.0.0
version: 5.0.6
'@types/jest':
specifier: ^30.0.0
version: 30.0.0
'@types/jsonwebtoken':
specifier: ^9.0.7
version: 9.0.10
@ -119,7 +122,7 @@ importers:
version: 7.2.2
ts-jest:
specifier: ^29.2.5
version: 29.4.6(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.19.5))(typescript@5.9.3)
version: 29.4.6(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@30.2.0)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.5))(typescript@5.9.3)
tsconfig-paths:
specifier: ^4.2.0
version: 4.2.0
@ -615,6 +618,10 @@ packages:
node-notifier:
optional: true
'@jest/diff-sequences@30.0.1':
resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
'@jest/environment@29.7.0':
resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -623,6 +630,10 @@ packages:
resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
'@jest/expect-utils@30.2.0':
resolution: {integrity: sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
'@jest/expect@29.7.0':
resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -631,10 +642,18 @@ packages:
resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
'@jest/get-type@30.1.0':
resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
'@jest/globals@29.7.0':
resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
'@jest/pattern@30.0.1':
resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
'@jest/reporters@29.7.0':
resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -648,6 +667,10 @@ packages:
resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
'@jest/schemas@30.0.5':
resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
'@jest/source-map@29.6.3':
resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -668,6 +691,10 @@ packages:
resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
'@jest/types@30.2.0':
resolution: {integrity: sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@ -862,6 +889,9 @@ packages:
'@sinclair/typebox@0.27.8':
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
'@sinclair/typebox@0.34.48':
resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==}
'@sinonjs/commons@3.0.1':
resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==}
@ -1115,6 +1145,9 @@ packages:
'@types/istanbul-reports@3.0.4':
resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==}
'@types/jest@30.0.0':
resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@ -1433,6 +1466,10 @@ packages:
resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
engines: {node: '>=8'}
ci-info@4.4.0:
resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==}
engines: {node: '>=8'}
cjs-module-lexer@1.4.3:
resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==}
@ -1729,6 +1766,10 @@ packages:
resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
expect@30.2.0:
resolution: {integrity: sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
express-rate-limit@7.5.1:
resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==}
engines: {node: '>= 16'}
@ -2118,6 +2159,10 @@ packages:
resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
jest-diff@30.2.0:
resolution: {integrity: sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
jest-docblock@29.7.0:
resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -2146,14 +2191,26 @@ packages:
resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
jest-matcher-utils@30.2.0:
resolution: {integrity: sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
jest-message-util@29.7.0:
resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
jest-message-util@30.2.0:
resolution: {integrity: sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
jest-mock@29.7.0:
resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
jest-mock@30.2.0:
resolution: {integrity: sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
jest-pnp-resolver@1.2.3:
resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==}
engines: {node: '>=6'}
@ -2167,6 +2224,10 @@ packages:
resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
jest-regex-util@30.0.1:
resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
jest-resolve-dependencies@29.7.0:
resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -2191,6 +2252,10 @@ packages:
resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
jest-util@30.2.0:
resolution: {integrity: sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
jest-validate@29.7.0:
resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -2584,6 +2649,10 @@ packages:
resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
pretty-format@30.2.0:
resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
prisma@5.22.0:
resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==}
engines: {node: '>=16.13'}
@ -4027,6 +4096,8 @@ snapshots:
- supports-color
- ts-node
'@jest/diff-sequences@30.0.1': {}
'@jest/environment@29.7.0':
dependencies:
'@jest/fake-timers': 29.7.0
@ -4038,6 +4109,10 @@ snapshots:
dependencies:
jest-get-type: 29.6.3
'@jest/expect-utils@30.2.0':
dependencies:
'@jest/get-type': 30.1.0
'@jest/expect@29.7.0':
dependencies:
expect: 29.7.0
@ -4054,6 +4129,8 @@ snapshots:
jest-mock: 29.7.0
jest-util: 29.7.0
'@jest/get-type@30.1.0': {}
'@jest/globals@29.7.0':
dependencies:
'@jest/environment': 29.7.0
@ -4063,6 +4140,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@jest/pattern@30.0.1':
dependencies:
'@types/node': 22.19.5
jest-regex-util: 30.0.1
'@jest/reporters@29.7.0':
dependencies:
'@bcoe/v8-coverage': 0.2.3
@ -4096,6 +4178,10 @@ snapshots:
dependencies:
'@sinclair/typebox': 0.27.8
'@jest/schemas@30.0.5':
dependencies:
'@sinclair/typebox': 0.34.48
'@jest/source-map@29.6.3':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
@ -4145,6 +4231,16 @@ snapshots:
'@types/yargs': 17.0.35
chalk: 4.1.2
'@jest/types@30.2.0':
dependencies:
'@jest/pattern': 30.0.1
'@jest/schemas': 30.0.5
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
'@types/node': 22.19.5
'@types/yargs': 17.0.35
chalk: 4.1.2
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@ -4316,6 +4412,8 @@ snapshots:
'@sinclair/typebox@0.27.8': {}
'@sinclair/typebox@0.34.48': {}
'@sinonjs/commons@3.0.1':
dependencies:
type-detect: 4.0.8
@ -4721,6 +4819,11 @@ snapshots:
dependencies:
'@types/istanbul-lib-report': 3.0.3
'@types/jest@30.0.0':
dependencies:
expect: 30.2.0
pretty-format: 30.2.0
'@types/json-schema@7.0.15': {}
'@types/jsonwebtoken@9.0.10':
@ -5116,6 +5219,8 @@ snapshots:
ci-info@3.9.0: {}
ci-info@4.4.0: {}
cjs-module-lexer@1.4.3: {}
cliui@8.0.1:
@ -5398,6 +5503,15 @@ snapshots:
jest-message-util: 29.7.0
jest-util: 29.7.0
expect@30.2.0:
dependencies:
'@jest/expect-utils': 30.2.0
'@jest/get-type': 30.1.0
jest-matcher-utils: 30.2.0
jest-message-util: 30.2.0
jest-mock: 30.2.0
jest-util: 30.2.0
express-rate-limit@7.5.1(express@4.22.1):
dependencies:
express: 4.22.1
@ -5872,6 +5986,13 @@ snapshots:
jest-get-type: 29.6.3
pretty-format: 29.7.0
jest-diff@30.2.0:
dependencies:
'@jest/diff-sequences': 30.0.1
'@jest/get-type': 30.1.0
chalk: 4.1.2
pretty-format: 30.2.0
jest-docblock@29.7.0:
dependencies:
detect-newline: 3.1.0
@ -5923,6 +6044,13 @@ snapshots:
jest-get-type: 29.6.3
pretty-format: 29.7.0
jest-matcher-utils@30.2.0:
dependencies:
'@jest/get-type': 30.1.0
chalk: 4.1.2
jest-diff: 30.2.0
pretty-format: 30.2.0
jest-message-util@29.7.0:
dependencies:
'@babel/code-frame': 7.27.1
@ -5935,18 +6063,38 @@ snapshots:
slash: 3.0.0
stack-utils: 2.0.6
jest-message-util@30.2.0:
dependencies:
'@babel/code-frame': 7.27.1
'@jest/types': 30.2.0
'@types/stack-utils': 2.0.3
chalk: 4.1.2
graceful-fs: 4.2.11
micromatch: 4.0.8
pretty-format: 30.2.0
slash: 3.0.0
stack-utils: 2.0.6
jest-mock@29.7.0:
dependencies:
'@jest/types': 29.6.3
'@types/node': 22.19.5
jest-util: 29.7.0
jest-mock@30.2.0:
dependencies:
'@jest/types': 30.2.0
'@types/node': 22.19.5
jest-util: 30.2.0
jest-pnp-resolver@1.2.3(jest-resolve@29.7.0):
optionalDependencies:
jest-resolve: 29.7.0
jest-regex-util@29.6.3: {}
jest-regex-util@30.0.1: {}
jest-resolve-dependencies@29.7.0:
dependencies:
jest-regex-util: 29.6.3
@ -6053,6 +6201,15 @@ snapshots:
graceful-fs: 4.2.11
picomatch: 2.3.1
jest-util@30.2.0:
dependencies:
'@jest/types': 30.2.0
'@types/node': 22.19.5
chalk: 4.1.2
ci-info: 4.4.0
graceful-fs: 4.2.11
picomatch: 4.0.3
jest-validate@29.7.0:
dependencies:
'@jest/types': 29.6.3
@ -6444,6 +6601,12 @@ snapshots:
ansi-styles: 5.2.0
react-is: 18.3.1
pretty-format@30.2.0:
dependencies:
'@jest/schemas': 30.0.5
ansi-styles: 5.2.0
react-is: 18.3.1
prisma@5.22.0:
dependencies:
'@prisma/engines': 5.22.0
@ -6797,7 +6960,7 @@ snapshots:
ts-deepmerge@7.0.3: {}
ts-jest@29.4.6(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.19.5))(typescript@5.9.3):
ts-jest@29.4.6(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@30.2.0)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.5))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
@ -6813,9 +6976,9 @@ snapshots:
optionalDependencies:
'@babel/core': 7.28.5
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@jest/types': 30.2.0
babel-jest: 29.7.0(@babel/core@7.28.5)
jest-util: 29.7.0
jest-util: 30.2.0
tsconfig-paths@4.2.0:
dependencies:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -133,7 +133,11 @@ export class AdminCourseApprovalService {
},
chapters: {
orderBy: { sort_order: 'asc' },
include: {
select: {
id: true,
title: true,
sort_order: true,
is_published: true,
lessons: {
orderBy: { sort_order: 'asc' },
select: {

View file

@ -10,6 +10,7 @@ import {
UpdateCourseInput,
createCourseResponse,
GetMyCourseResponse,
ListMyCoursesInput,
ListMyCourseResponse,
addinstructorCourse,
addinstructorCourseResponse,
@ -33,6 +34,8 @@ import {
GetEnrolledStudentDetailInput,
GetEnrolledStudentDetailResponse,
GetCourseApprovalHistoryResponse,
CloneCourseInput,
CloneCourseResponse,
setCourseDraft,
setCourseDraftResponse,
} from "../types/CoursesInstructor.types";
@ -116,12 +119,13 @@ export class CoursesInstructorService {
}
}
static async listMyCourses(token: string): Promise<ListMyCourseResponse> {
static async listMyCourses(input: ListMyCoursesInput): Promise<ListMyCourseResponse> {
try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
const decoded = jwt.verify(input.token, config.jwt.secret) as { id: number; type: string };
const courseInstructors = await prisma.courseInstructor.findMany({
where: {
user_id: decoded.id
user_id: decoded.id,
course: input.status ? { status: input.status } : undefined
},
include: {
course: true
@ -153,6 +157,17 @@ export class CoursesInstructorService {
};
} catch (error) {
logger.error('Failed to retrieve courses', { error });
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: 0,
metadata: {
operation: 'list_my_courses',
error: error instanceof Error ? error.message : String(error)
}
});
throw error;
}
}
@ -212,12 +227,12 @@ export class CoursesInstructorService {
logger.error('Failed to retrieve course', { error });
const decoded = jwt.decode(getmyCourse.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: getmyCourse.course_id,
metadata: {
operation: 'get_course',
operation: 'get_my_course',
error: error instanceof Error ? error.message : String(error)
}
});
@ -245,7 +260,7 @@ export class CoursesInstructorService {
logger.error('Failed to update course', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: courseId,
@ -309,7 +324,7 @@ export class CoursesInstructorService {
logger.error('Failed to upload thumbnail', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: courseId,
@ -352,7 +367,7 @@ export class CoursesInstructorService {
logger.error('Failed to delete course', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: courseId,
@ -399,7 +414,7 @@ export class CoursesInstructorService {
logger.error('Failed to send course for review', { error });
const decoded = jwt.decode(sendCourseForReview.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: sendCourseForReview.course_id,
@ -432,7 +447,7 @@ export class CoursesInstructorService {
logger.error('Failed to set course to draft', { error });
const decoded = jwt.decode(setCourseDraft.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: setCourseDraft.course_id,
@ -478,7 +493,7 @@ export class CoursesInstructorService {
logger.error('Failed to retrieve course approvals', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: courseId,
@ -550,7 +565,7 @@ export class CoursesInstructorService {
logger.error('Failed to search instructors', { error });
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: input.course_id,
@ -624,7 +639,7 @@ export class CoursesInstructorService {
logger.error('Failed to add instructor to course', { error });
const decoded = jwt.decode(addinstructorCourse.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: addinstructorCourse.course_id,
@ -669,7 +684,7 @@ export class CoursesInstructorService {
logger.error('Failed to remove instructor from course', { error });
const decoded = jwt.decode(removeinstructorCourse.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: removeinstructorCourse.course_id,
@ -730,7 +745,7 @@ export class CoursesInstructorService {
logger.error('Failed to retrieve instructors of course', { error });
const decoded = jwt.decode(listinstructorCourse.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: listinstructorCourse.course_id,
@ -739,7 +754,6 @@ export class CoursesInstructorService {
error: error instanceof Error ? error.message : String(error)
}
});
throw error;
}
}
@ -779,7 +793,7 @@ export class CoursesInstructorService {
logger.error('Failed to set primary instructor', { error });
const decoded = jwt.decode(setprimaryCourseInstructor.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: setprimaryCourseInstructor.course_id,
@ -905,7 +919,7 @@ export class CoursesInstructorService {
logger.error(`Error getting enrolled students: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: input.course_id,
@ -1083,7 +1097,7 @@ export class CoursesInstructorService {
logger.error(`Error getting quiz scores: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: input.course_id,
@ -1207,7 +1221,7 @@ export class CoursesInstructorService {
logger.error(`Error getting quiz attempt detail: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: input.course_id,
@ -1355,7 +1369,7 @@ export class CoursesInstructorService {
logger.error(`Error getting enrolled student detail: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: input.course_id,
@ -1422,7 +1436,7 @@ export class CoursesInstructorService {
logger.error(`Error getting course approval history: ${error}`);
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: courseId,
@ -1434,4 +1448,228 @@ export class CoursesInstructorService {
throw error;
}
}
/**
* Clone a course (including chapters, lessons, quizzes, attachments)
*/
static async cloneCourse(input: CloneCourseInput): Promise<CloneCourseResponse> {
try {
const { token, course_id, title } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor
const courseInstructor = await this.validateCourseInstructor(token, course_id);
if (!courseInstructor) {
throw new ForbiddenError('You are not an instructor of this course');
}
// Fetch original course with all relations
const originalCourse = await prisma.course.findUnique({
where: { id: course_id },
include: {
chapters: {
orderBy: { sort_order: 'asc' },
include: {
lessons: {
orderBy: { sort_order: 'asc' },
include: {
attachments: true,
quiz: {
include: {
questions: {
include: {
choices: true
}
}
}
}
}
}
}
}
}
});
if (!originalCourse) {
throw new NotFoundError('Course not found');
}
// Use transaction for atomic creation
const newCourse = await prisma.$transaction(async (tx) => {
// 1. Create new Course
const createdCourse = await tx.course.create({
data: {
title: title as Prisma.InputJsonValue,
slug: `${originalCourse.slug}-clone-${Date.now()}`, // Temporary slug
description: originalCourse.description ?? Prisma.JsonNull,
thumbnail_url: originalCourse.thumbnail_url,
category_id: originalCourse.category_id,
price: originalCourse.price,
is_free: originalCourse.is_free,
have_certificate: originalCourse.have_certificate,
status: 'DRAFT', // Reset status
created_by: decoded.id
}
});
// 2. Add Instructor (Requester as primary)
await tx.courseInstructor.create({
data: {
course_id: createdCourse.id,
user_id: decoded.id,
is_primary: true
}
});
// Mapping for oldLessonId -> newLessonId for prerequisites
const lessonIdMap = new Map<number, number>();
const lessonsToUpdatePrerequisites: { newLessonId: number; oldPrerequisites: number[] }[] = [];
// 3. Clone Chapters and Lessons
for (const chapter of originalCourse.chapters) {
const newChapter = await tx.chapter.create({
data: {
course_id: createdCourse.id,
title: chapter.title as Prisma.InputJsonValue,
description: chapter.description ?? Prisma.JsonNull,
sort_order: chapter.sort_order,
is_published: chapter.is_published
}
});
for (const lesson of chapter.lessons) {
const newLesson = await tx.lesson.create({
data: {
chapter_id: newChapter.id,
title: lesson.title as Prisma.InputJsonValue,
content: lesson.content ?? Prisma.JsonNull,
type: lesson.type,
sort_order: lesson.sort_order,
is_published: lesson.is_published,
duration_minutes: lesson.duration_minutes,
prerequisite_lesson_ids: Prisma.JsonNull // Will update later
}
});
lessonIdMap.set(lesson.id, newLesson.id);
// Store prerequisites for later update
if (Array.isArray(lesson.prerequisite_lesson_ids) && lesson.prerequisite_lesson_ids.length > 0) {
lessonsToUpdatePrerequisites.push({
newLessonId: newLesson.id,
oldPrerequisites: lesson.prerequisite_lesson_ids as number[]
});
}
// Clone Attachments
if (lesson.attachments && lesson.attachments.length > 0) {
await tx.lessonAttachment.createMany({
data: lesson.attachments.map(att => ({
lesson_id: newLesson.id,
file_name: att.file_name,
file_path: att.file_path, // Reuse file path
file_size: att.file_size,
mime_type: att.mime_type,
sort_order: att.sort_order,
description: att.description ?? Prisma.JsonNull
}))
});
}
// Clone Quiz
if (lesson.quiz) {
const newQuiz = await tx.quiz.create({
data: {
lesson_id: newLesson.id,
title: lesson.quiz.title as Prisma.InputJsonValue,
description: lesson.quiz.description ?? Prisma.JsonNull,
passing_score: lesson.quiz.passing_score,
allow_multiple_attempts: lesson.quiz.allow_multiple_attempts,
time_limit: lesson.quiz.time_limit,
shuffle_questions: lesson.quiz.shuffle_questions,
shuffle_choices: lesson.quiz.shuffle_choices,
show_answers_after_completion: lesson.quiz.show_answers_after_completion,
created_by: decoded.id
}
});
for (const question of lesson.quiz.questions) {
await tx.question.create({
data: {
quiz_id: newQuiz.id,
question: question.question as Prisma.InputJsonValue,
explanation: question.explanation ?? Prisma.JsonNull,
question_type: question.question_type,
score: question.score,
sort_order: question.sort_order,
choices: {
create: question.choices.map(choice => ({
text: choice.text as Prisma.InputJsonValue,
is_correct: choice.is_correct,
sort_order: choice.sort_order
}))
}
}
});
}
}
}
}
// 4. Update Prerequisites
for (const item of lessonsToUpdatePrerequisites) {
const newPrerequisites = item.oldPrerequisites
.map(oldId => lessonIdMap.get(oldId))
.filter((id): id is number => id !== undefined);
if (newPrerequisites.length > 0) {
await tx.lesson.update({
where: { id: item.newLessonId },
data: {
prerequisite_lesson_ids: newPrerequisites
}
});
}
}
return createdCourse;
});
await auditService.logSync({
userId: decoded.id,
action: AuditAction.CREATE,
entityType: 'Course',
entityId: newCourse.id,
metadata: {
operation: 'clone_course',
original_course_id: course_id,
new_course_id: newCourse.id
}
});
return {
code: 201,
message: 'Course cloned successfully',
data: {
id: newCourse.id,
title: newCourse.title as { th: string; en: string }
}
};
} catch (error) {
logger.error(`Error cloning course: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: input.course_id,
metadata: {
operation: 'clone_course',
error: error instanceof Error ? error.message : String(error)
}
});
throw error;
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -14,7 +14,8 @@ import {
updateAvatarRequest,
updateAvatarResponse,
SendVerifyEmailResponse,
VerifyEmailResponse
VerifyEmailResponse,
rolesResponse
} from '../types/user.types';
import nodemailer from 'nodemailer';
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
@ -212,6 +213,30 @@ export class UserService {
}
}
async getRoles(token: string): Promise<rolesResponse> {
try {
jwt.verify(token, config.jwt.secret);
const roles = await prisma.role.findMany({
select: {
id: true,
code: true
}
});
return { roles };
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
logger.error('JWT token expired:', error);
throw new UnauthorizedError('Token expired');
}
if (error instanceof jwt.JsonWebTokenError) {
logger.error('Invalid JWT token:', error);
throw new UnauthorizedError('Invalid token');
}
logger.error('Failed to get roles', { error });
throw error;
}
}
/**
* Upload avatar picture to MinIO
*/

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,14 @@
{
"extends": "../tsconfig.test.json",
"compilerOptions": {
"rootDir": "..",
"types": [
"node",
"jest"
]
},
"include": [
"../src/**/*",
"./**/*"
]
}

View file

@ -0,0 +1,67 @@
import {
ApproveCourseValidator,
RejectCourseValidator,
} from '@/validators/AdminCourseApproval.validator';
describe('ApproveCourseValidator', () => {
it('should pass with no body (comment optional)', () => {
const { error } = ApproveCourseValidator.validate({});
expect(error).toBeUndefined();
});
it('should pass with optional comment', () => {
const { error } = ApproveCourseValidator.validate({
comment: 'Looks great!',
});
expect(error).toBeUndefined();
});
it('should fail when comment exceeds 1000 characters', () => {
const { error } = ApproveCourseValidator.validate({
comment: 'a'.repeat(1001),
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/must not exceed 1000/i);
});
it('should pass with comment exactly 1000 characters', () => {
const { error } = ApproveCourseValidator.validate({
comment: 'a'.repeat(1000),
});
expect(error).toBeUndefined();
});
});
describe('RejectCourseValidator', () => {
it('should pass with valid rejection comment', () => {
const { error } = RejectCourseValidator.validate({
comment: 'The content is incomplete and needs more details.',
});
expect(error).toBeUndefined();
});
it('should fail without comment', () => {
const { error } = RejectCourseValidator.validate({});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Comment is required when rejecting/i);
});
it('should fail when comment is too short (< 10 chars)', () => {
const { error } = RejectCourseValidator.validate({ comment: 'Too short' });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/at least 10 characters/i);
});
it('should pass with exactly 10 characters', () => {
const { error } = RejectCourseValidator.validate({ comment: '1234567890' });
expect(error).toBeUndefined();
});
it('should fail when comment exceeds 1000 characters', () => {
const { error } = RejectCourseValidator.validate({
comment: 'a'.repeat(1001),
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/must not exceed 1000/i);
});
});

View file

@ -0,0 +1,263 @@
import {
CreateChapterValidator,
UpdateChapterValidator,
ReorderChapterValidator,
CreateLessonValidator,
UpdateLessonValidator,
ReorderLessonsValidator,
AddQuestionValidator,
UpdateQuizValidator,
} from '@/validators/ChaptersLesson.validator';
// ============================================================
// Chapter Validators
// ============================================================
describe('CreateChapterValidator', () => {
it('should pass with valid data', () => {
const { error } = CreateChapterValidator.validate({
title: { th: 'บทที่ 1', en: 'Chapter 1' },
});
expect(error).toBeUndefined();
});
it('should pass with optional fields', () => {
const { error } = CreateChapterValidator.validate({
title: { th: 'บทที่ 1', en: 'Chapter 1' },
description: { th: 'คำอธิบาย', en: 'Description' },
sort_order: 0,
});
expect(error).toBeUndefined();
});
it('should fail if title is missing', () => {
const { error } = CreateChapterValidator.validate({});
expect(error).toBeDefined();
});
it('should fail if title.th is missing', () => {
const { error } = CreateChapterValidator.validate({
title: { en: 'Chapter 1' },
});
expect(error).toBeDefined();
});
it('should fail if title.en is missing', () => {
const { error } = CreateChapterValidator.validate({
title: { th: 'บทที่ 1' },
});
expect(error).toBeDefined();
});
});
describe('UpdateChapterValidator', () => {
it('should pass with empty object (all fields optional)', () => {
const { error } = UpdateChapterValidator.validate({});
expect(error).toBeUndefined();
});
it('should pass with partial fields', () => {
const { error } = UpdateChapterValidator.validate({
is_published: true,
});
expect(error).toBeUndefined();
});
});
describe('ReorderChapterValidator', () => {
it('should pass with valid sort_order', () => {
const { error } = ReorderChapterValidator.validate({ sort_order: 0 });
expect(error).toBeUndefined();
});
it('should fail without sort_order', () => {
const { error } = ReorderChapterValidator.validate({});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Sort order is required/i);
});
it('should fail with negative sort_order', () => {
const { error } = ReorderChapterValidator.validate({ sort_order: -1 });
expect(error).toBeDefined();
});
});
// ============================================================
// Lesson Validators
// ============================================================
describe('CreateLessonValidator', () => {
it('should pass with VIDEO type', () => {
const { error } = CreateLessonValidator.validate({
title: { th: 'บทเรียน 1', en: 'Lesson 1' },
type: 'VIDEO',
});
expect(error).toBeUndefined();
});
it('should pass with QUIZ type', () => {
const { error } = CreateLessonValidator.validate({
title: { th: 'แบบทดสอบ', en: 'Quiz' },
type: 'QUIZ',
});
expect(error).toBeUndefined();
});
it('should fail with invalid type', () => {
const { error } = CreateLessonValidator.validate({
title: { th: 'บทเรียน', en: 'Lesson' },
type: 'DOCUMENT',
});
expect(error).toBeDefined();
});
it('should fail without title', () => {
const { error } = CreateLessonValidator.validate({ type: 'VIDEO' });
expect(error).toBeDefined();
});
it('should fail without type', () => {
const { error } = CreateLessonValidator.validate({
title: { th: 'บทเรียน', en: 'Lesson' },
});
expect(error).toBeDefined();
});
});
describe('UpdateLessonValidator — prerequisite_lesson_ids', () => {
it('should pass with valid array of ids', () => {
const { error } = UpdateLessonValidator.validate({
prerequisite_lesson_ids: [1, 2, 3],
});
expect(error).toBeUndefined();
});
it('should pass with null (clear prerequisites)', () => {
const { error } = UpdateLessonValidator.validate({
prerequisite_lesson_ids: null,
});
expect(error).toBeUndefined();
});
it('should pass with empty array (clear prerequisites)', () => {
const { error } = UpdateLessonValidator.validate({
prerequisite_lesson_ids: [],
});
expect(error).toBeUndefined();
});
it('should pass without the field (no change)', () => {
const { error } = UpdateLessonValidator.validate({});
expect(error).toBeUndefined();
});
it('should fail with non-integer ids', () => {
const { error } = UpdateLessonValidator.validate({
prerequisite_lesson_ids: [1.5],
});
expect(error).toBeDefined();
});
it('should fail with negative ids', () => {
const { error } = UpdateLessonValidator.validate({
prerequisite_lesson_ids: [-1],
});
expect(error).toBeDefined();
});
it('should fail with string ids', () => {
const { error } = UpdateLessonValidator.validate({
prerequisite_lesson_ids: ['abc'],
});
expect(error).toBeDefined();
});
});
describe('ReorderLessonsValidator', () => {
it('should pass with valid data', () => {
const { error } = ReorderLessonsValidator.validate({
lesson_id: 1,
sort_order: 0,
});
expect(error).toBeUndefined();
});
it('should fail without lesson_id', () => {
const { error } = ReorderLessonsValidator.validate({ sort_order: 0 });
expect(error).toBeDefined();
});
it('should fail without sort_order', () => {
const { error } = ReorderLessonsValidator.validate({ lesson_id: 1 });
expect(error).toBeDefined();
});
});
// ============================================================
// Quiz Validators
// ============================================================
describe('AddQuestionValidator', () => {
it('should pass with MULTIPLE_CHOICE type + choices', () => {
const { error } = AddQuestionValidator.validate({
question: { th: 'ข้อที่ 1 คืออะไร?', en: 'What is question 1?' },
question_type: 'MULTIPLE_CHOICE',
choices: [
{ text: { th: 'ก', en: 'A' }, is_correct: true },
{ text: { th: 'ข', en: 'B' }, is_correct: false },
],
});
expect(error).toBeUndefined();
});
it('should pass with TRUE_FALSE type without choices', () => {
const { error } = AddQuestionValidator.validate({
question: { th: 'ถูกหรือผิด?', en: 'True or False?' },
question_type: 'TRUE_FALSE',
});
expect(error).toBeUndefined();
});
it('should fail with invalid question_type', () => {
const { error } = AddQuestionValidator.validate({
question: { th: 'คำถาม', en: 'Question' },
question_type: 'ESSAY',
});
expect(error).toBeDefined();
});
it('should fail without question', () => {
const { error } = AddQuestionValidator.validate({
question_type: 'TRUE_FALSE',
});
expect(error).toBeDefined();
});
});
describe('UpdateQuizValidator', () => {
it('should pass with empty object (all optional)', () => {
const { error } = UpdateQuizValidator.validate({});
expect(error).toBeUndefined();
});
it('should pass with valid passing_score', () => {
const { error } = UpdateQuizValidator.validate({ passing_score: 70 });
expect(error).toBeUndefined();
});
it('should fail with passing_score > 100', () => {
const { error } = UpdateQuizValidator.validate({ passing_score: 101 });
expect(error).toBeDefined();
});
it('should fail with passing_score < 0', () => {
const { error } = UpdateQuizValidator.validate({ passing_score: -1 });
expect(error).toBeDefined();
});
it('should pass with time_limit 0 (no limit)', () => {
const { error } = UpdateQuizValidator.validate({ time_limit: 0 });
expect(error).toBeUndefined();
});
});

View file

@ -0,0 +1,150 @@
import {
CreateCourseValidator,
UpdateCourseValidator,
CloneCourseValidator,
addInstructorCourseValidator,
} from '@/validators/CoursesInstructor.validator';
// ============================================================
// addInstructorCourseValidator
// ============================================================
describe('addInstructorCourseValidator', () => {
it('should pass with valid user_id and course_id', () => {
const { error } = addInstructorCourseValidator.validate({
user_id: 1,
course_id: 2,
});
expect(error).toBeUndefined();
});
it('should fail without user_id', () => {
const { error } = addInstructorCourseValidator.validate({ course_id: 2 });
expect(error).toBeDefined();
});
it('should fail without course_id', () => {
const { error } = addInstructorCourseValidator.validate({ user_id: 1 });
expect(error).toBeDefined();
});
});
// ============================================================
// CreateCourseValidator
// ============================================================
describe('CreateCourseValidator', () => {
const validPayload = {
category_id: 1,
title: { th: 'คอร์สทดสอบ', en: 'Test Course' },
slug: 'test-course',
description: { th: 'คำอธิบาย', en: 'Description' },
price: 500,
is_free: false,
have_certificate: true,
};
it('should pass with all required fields', () => {
const { error } = CreateCourseValidator.validate(validPayload);
expect(error).toBeUndefined();
});
it('should fail if title.th is missing', () => {
const { error } = CreateCourseValidator.validate({
...validPayload,
title: { en: 'Test Course' },
});
expect(error).toBeDefined();
});
it('should fail if slug is missing', () => {
const { error } = CreateCourseValidator.validate({
...validPayload,
slug: undefined,
});
expect(error).toBeDefined();
});
it('should fail if price is missing', () => {
const { error } = CreateCourseValidator.validate({
...validPayload,
price: undefined,
});
expect(error).toBeDefined();
});
it('should fail if is_free is missing', () => {
const { error } = CreateCourseValidator.validate({
...validPayload,
is_free: undefined,
});
expect(error).toBeDefined();
});
it('should allow price = 0 (free course)', () => {
const { error } = CreateCourseValidator.validate({
...validPayload,
price: 0,
is_free: true,
});
expect(error).toBeUndefined();
});
});
// ============================================================
// UpdateCourseValidator
// ============================================================
describe('UpdateCourseValidator', () => {
it('should pass with empty object (all optional)', () => {
const { error } = UpdateCourseValidator.validate({});
expect(error).toBeUndefined();
});
it('should pass with partial update', () => {
const { error } = UpdateCourseValidator.validate({ price: 999 });
expect(error).toBeUndefined();
});
it('should pass with title partial update (th only)', () => {
const { error } = UpdateCourseValidator.validate({
title: { th: 'ชื่อใหม่' },
});
expect(error).toBeUndefined();
});
});
// ============================================================
// CloneCourseValidator
// ============================================================
describe('CloneCourseValidator', () => {
it('should pass with valid title', () => {
const { error } = CloneCourseValidator.validate({
title: { th: 'คอร์ส Copy', en: 'Course Copy' },
});
expect(error).toBeUndefined();
});
it('should fail without title.th', () => {
const { error } = CloneCourseValidator.validate({
title: { en: 'Course Copy' },
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Thai title is required/i);
});
it('should fail without title.en', () => {
const { error } = CloneCourseValidator.validate({
title: { th: 'คอร์ส Copy' },
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/English title is required/i);
});
it('should fail without title entirely', () => {
const { error } = CloneCourseValidator.validate({});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Title is required/i);
});
});

View file

@ -0,0 +1,96 @@
import {
SaveVideoProgressValidator,
SubmitQuizValidator,
} from '@/validators/CoursesStudent.validator';
describe('SaveVideoProgressValidator', () => {
it('should pass with required field only', () => {
const { error } = SaveVideoProgressValidator.validate({
video_progress_seconds: 60,
});
expect(error).toBeUndefined();
});
it('should pass with all fields', () => {
const { error } = SaveVideoProgressValidator.validate({
video_progress_seconds: 120,
video_duration_seconds: 600,
});
expect(error).toBeUndefined();
});
it('should pass with progress = 0 (start of video)', () => {
const { error } = SaveVideoProgressValidator.validate({
video_progress_seconds: 0,
});
expect(error).toBeUndefined();
});
it('should fail without video_progress_seconds', () => {
const { error } = SaveVideoProgressValidator.validate({});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Video progress seconds is required/i);
});
it('should fail with negative progress', () => {
const { error } = SaveVideoProgressValidator.validate({
video_progress_seconds: -1,
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/at least 0/i);
});
it('should fail with negative video duration', () => {
const { error } = SaveVideoProgressValidator.validate({
video_progress_seconds: 10,
video_duration_seconds: -1,
});
expect(error).toBeDefined();
});
});
describe('SubmitQuizValidator', () => {
const validAnswer = { question_id: 1, choice_id: 2 };
it('should pass with valid answers', () => {
const { error } = SubmitQuizValidator.validate({
answers: [validAnswer, { question_id: 2, choice_id: 5 }],
});
expect(error).toBeUndefined();
});
it('should fail without answers', () => {
const { error } = SubmitQuizValidator.validate({});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Answers are required/i);
});
it('should fail with empty answers array', () => {
const { error } = SubmitQuizValidator.validate({ answers: [] });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/At least one answer/i);
});
it('should fail if question_id is missing in an answer', () => {
const { error } = SubmitQuizValidator.validate({
answers: [{ choice_id: 2 }],
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Question ID is required/i);
});
it('should fail if choice_id is missing in an answer', () => {
const { error } = SubmitQuizValidator.validate({
answers: [{ question_id: 1 }],
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Choice ID is required/i);
});
it('should fail if question_id is not a positive integer', () => {
const { error } = SubmitQuizValidator.validate({
answers: [{ question_id: -1, choice_id: 1 }],
});
expect(error).toBeDefined();
});
});

View file

@ -0,0 +1,45 @@
import { SetYouTubeVideoValidator } from '@/validators/Lessons.validator';
describe('SetYouTubeVideoValidator', () => {
it('should pass with valid youtube_video_id and video_title', () => {
const { error } = SetYouTubeVideoValidator.validate({
youtube_video_id: 'dQw4w9WgXcQ',
video_title: 'Introduction to TypeScript',
});
expect(error).toBeUndefined();
});
it('should fail without youtube_video_id', () => {
const { error } = SetYouTubeVideoValidator.validate({
video_title: 'Intro to TS',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/YouTube video ID is required/i);
});
it('should fail with empty youtube_video_id string', () => {
const { error } = SetYouTubeVideoValidator.validate({
youtube_video_id: '',
video_title: 'Intro to TS',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/cannot be empty/i);
});
it('should fail without video_title', () => {
const { error } = SetYouTubeVideoValidator.validate({
youtube_video_id: 'dQw4w9WgXcQ',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Video title is required/i);
});
it('should fail with empty video_title string', () => {
const { error } = SetYouTubeVideoValidator.validate({
youtube_video_id: 'dQw4w9WgXcQ',
video_title: '',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/cannot be empty/i);
});
});

View file

@ -0,0 +1,115 @@
import {
CreateAnnouncementValidator,
UpdateAnnouncementValidator,
} from '@/validators/announcements.validator';
describe('CreateAnnouncementValidator', () => {
const validPayload = {
title: { th: 'ประกาศใหม่', en: 'New Announcement' },
content: { th: 'เนื้อหา', en: 'Content' },
status: 'DRAFT',
is_pinned: false,
};
it('should pass with all required fields', () => {
const { error } = CreateAnnouncementValidator.validate(validPayload);
expect(error).toBeUndefined();
});
it('should pass with optional published_at as ISO date', () => {
const { error } = CreateAnnouncementValidator.validate({
...validPayload,
published_at: '2026-03-01T00:00:00.000Z',
});
expect(error).toBeUndefined();
});
it('should fail with invalid published_at format', () => {
const { error } = CreateAnnouncementValidator.validate({
...validPayload,
published_at: '01-03-2026',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/ISO date/i);
});
it('should fail with invalid status', () => {
const { error } = CreateAnnouncementValidator.validate({
...validPayload,
status: 'HIDDEN',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/DRAFT, PUBLISHED, ARCHIVED/i);
});
it('should pass with PUBLISHED status', () => {
const { error } = CreateAnnouncementValidator.validate({
...validPayload,
status: 'PUBLISHED',
});
expect(error).toBeUndefined();
});
it('should pass with ARCHIVED status', () => {
const { error } = CreateAnnouncementValidator.validate({
...validPayload,
status: 'ARCHIVED',
});
expect(error).toBeUndefined();
});
it('should fail without title', () => {
const { error } = CreateAnnouncementValidator.validate({
...validPayload,
title: undefined,
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Title is required/i);
});
it('should fail without content', () => {
const { error } = CreateAnnouncementValidator.validate({
...validPayload,
content: undefined,
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Content is required/i);
});
it('should fail without is_pinned', () => {
const { error } = CreateAnnouncementValidator.validate({
...validPayload,
is_pinned: undefined,
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/is_pinned is required/i);
});
});
describe('UpdateAnnouncementValidator', () => {
it('should pass with empty object (all optional)', () => {
const { error } = UpdateAnnouncementValidator.validate({});
expect(error).toBeUndefined();
});
it('should pass with partial update', () => {
const { error } = UpdateAnnouncementValidator.validate({
status: 'PUBLISHED',
is_pinned: true,
});
expect(error).toBeUndefined();
});
it('should fail with invalid status', () => {
const { error } = UpdateAnnouncementValidator.validate({ status: 'DELETED' });
expect(error).toBeDefined();
});
it('should fail with invalid published_at format', () => {
const { error } = UpdateAnnouncementValidator.validate({
published_at: 'not-a-date',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/ISO date/i);
});
});

View file

@ -0,0 +1,246 @@
import {
loginSchema,
registerSchema,
refreshTokenSchema,
resetPasswordSchema,
changePasswordSchema,
resetRequestSchema,
} from '@/validators/auth.validator';
// ============================================================
// loginSchema
// ============================================================
describe('loginSchema', () => {
it('should pass with valid email and password', () => {
const { error } = loginSchema.validate({
email: 'user@example.com',
password: 'password123',
});
expect(error).toBeUndefined();
});
it('should fail with invalid email format', () => {
const { error } = loginSchema.validate({
email: 'not-an-email',
password: 'password123',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/valid email/i);
});
it('should fail without email', () => {
const { error } = loginSchema.validate({ password: 'password123' });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Email is required/i);
});
it('should fail without password', () => {
const { error } = loginSchema.validate({ email: 'user@example.com' });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Password is required/i);
});
it('should fail with password shorter than 6 characters', () => {
const { error } = loginSchema.validate({
email: 'user@example.com',
password: '12345',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/at least 6 characters/i);
});
});
// ============================================================
// registerSchema
// ============================================================
describe('registerSchema', () => {
const validPayload = {
username: 'john_doe',
email: 'john@example.com',
password: 'securepass',
first_name: 'John',
last_name: 'Doe',
phone: '0812345678',
};
it('should pass with all required fields', () => {
const { error } = registerSchema.validate(validPayload);
expect(error).toBeUndefined();
});
it('should pass with optional prefix', () => {
const { error } = registerSchema.validate({
...validPayload,
prefix: { th: 'นาย', en: 'Mr.' },
});
expect(error).toBeUndefined();
});
it('should fail if username has invalid characters', () => {
const { error } = registerSchema.validate({
...validPayload,
username: 'john doe!',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/letters, numbers, and underscores/i);
});
it('should fail if username is too short (< 3 chars)', () => {
const { error } = registerSchema.validate({
...validPayload,
username: 'ab',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/at least 3 characters/i);
});
it('should fail if username is too long (> 50 chars)', () => {
const { error } = registerSchema.validate({
...validPayload,
username: 'a'.repeat(51),
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/not exceed 50 characters/i);
});
it('should fail with invalid email', () => {
const { error } = registerSchema.validate({
...validPayload,
email: 'bad-email',
});
expect(error).toBeDefined();
});
it('should fail if phone is too short (< 10 chars)', () => {
const { error } = registerSchema.validate({
...validPayload,
phone: '081234',
});
expect(error).toBeDefined();
});
it('should fail without first_name', () => {
const { error } = registerSchema.validate({
...validPayload,
first_name: undefined,
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/First name is required/i);
});
it('should fail without last_name', () => {
const { error } = registerSchema.validate({
...validPayload,
last_name: undefined,
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Last name is required/i);
});
});
// ============================================================
// refreshTokenSchema
// ============================================================
describe('refreshTokenSchema', () => {
it('should pass with a valid refreshToken', () => {
const { error } = refreshTokenSchema.validate({
refreshToken: 'some-refresh-token-string',
});
expect(error).toBeUndefined();
});
it('should fail without refreshToken', () => {
const { error } = refreshTokenSchema.validate({});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Refresh token is required/i);
});
});
// ============================================================
// resetPasswordSchema
// ============================================================
describe('resetPasswordSchema', () => {
it('should pass with valid token and password', () => {
const { error } = resetPasswordSchema.validate({
token: 'reset-token-abc',
password: 'newpassword',
});
expect(error).toBeUndefined();
});
it('should fail without token', () => {
const { error } = resetPasswordSchema.validate({ password: 'newpassword' });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Reset token is required/i);
});
it('should fail with password too short', () => {
const { error } = resetPasswordSchema.validate({
token: 'abc',
password: '12345',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/at least 6 characters/i);
});
it('should fail with password too long (> 100 chars)', () => {
const { error } = resetPasswordSchema.validate({
token: 'abc',
password: 'a'.repeat(101),
});
expect(error).toBeDefined();
});
});
// ============================================================
// changePasswordSchema (auth)
// ============================================================
describe('changePasswordSchema (auth.validator)', () => {
it('should pass with valid old and new passwords', () => {
const { error } = changePasswordSchema.validate({
oldPassword: 'oldpass123',
newPassword: 'newpass456',
});
expect(error).toBeUndefined();
});
it('should fail without oldPassword', () => {
const { error } = changePasswordSchema.validate({ newPassword: 'newpass456' });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Password is required/i);
});
it('should fail without newPassword', () => {
const { error } = changePasswordSchema.validate({ oldPassword: 'oldpass123' });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Password is required/i);
});
});
// ============================================================
// resetRequestSchema
// ============================================================
describe('resetRequestSchema', () => {
it('should pass with valid email', () => {
const { error } = resetRequestSchema.validate({ email: 'user@example.com' });
expect(error).toBeUndefined();
});
it('should fail without email', () => {
const { error } = resetRequestSchema.validate({});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Email is required/i);
});
it('should fail with invalid email', () => {
const { error } = resetRequestSchema.validate({ email: 'not-email' });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/valid email/i);
});
});

View file

@ -0,0 +1,121 @@
import {
CreateCategoryValidator,
UpdateCategoryValidator,
} from '@/validators/categories.validator';
describe('CreateCategoryValidator', () => {
const validPayload = {
name: { th: 'การพัฒนาเว็บ', en: 'Web Development' },
slug: 'web-development',
description: { th: 'หมวดหมู่การพัฒนาเว็บ', en: 'Web development category' },
};
it('should pass with all required fields', () => {
const { error } = CreateCategoryValidator.validate(validPayload);
expect(error).toBeUndefined();
});
it('should pass with optional created_by', () => {
const { error } = CreateCategoryValidator.validate({
...validPayload,
created_by: 1,
});
expect(error).toBeUndefined();
});
it('should fail without name', () => {
const { error } = CreateCategoryValidator.validate({
...validPayload,
name: undefined,
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Name is required/i);
});
it('should fail without name.th', () => {
const { error } = CreateCategoryValidator.validate({
...validPayload,
name: { en: 'Web Development' },
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Thai name is required/i);
});
it('should fail without slug', () => {
const { error } = CreateCategoryValidator.validate({
...validPayload,
slug: undefined,
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Slug is required/i);
});
it('should fail with invalid slug format (uppercase)', () => {
const { error } = CreateCategoryValidator.validate({
...validPayload,
slug: 'Web-Development',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/lowercase with hyphens/i);
});
it('should fail with invalid slug format (spaces)', () => {
const { error } = CreateCategoryValidator.validate({
...validPayload,
slug: 'web development',
});
expect(error).toBeDefined();
});
it('should pass with valid slug formats', () => {
const slugs = ['web', 'web-dev', 'web-development-101'];
for (const slug of slugs) {
const { error } = CreateCategoryValidator.validate({
...validPayload,
slug,
});
expect(error).toBeUndefined();
}
});
it('should fail without description', () => {
const { error } = CreateCategoryValidator.validate({
...validPayload,
description: undefined,
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Description is required/i);
});
});
describe('UpdateCategoryValidator', () => {
it('should pass with only required id', () => {
const { error } = UpdateCategoryValidator.validate({ id: 1 });
expect(error).toBeUndefined();
});
it('should pass with all optional fields', () => {
const { error } = UpdateCategoryValidator.validate({
id: 1,
name: { th: 'ใหม่', en: 'New' },
slug: 'new-category',
description: { th: 'คำอธิบาย', en: 'Description' },
});
expect(error).toBeUndefined();
});
it('should fail without id', () => {
const { error } = UpdateCategoryValidator.validate({ name: { th: 'ใหม่', en: 'New' } });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Category ID is required/i);
});
it('should fail with invalid slug format', () => {
const { error } = UpdateCategoryValidator.validate({
id: 1,
slug: 'Invalid Slug!',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/lowercase with hyphens/i);
});
});

View file

@ -0,0 +1,100 @@
import {
profileUpdateSchema,
changePasswordSchema,
} from '@/validators/user.validator';
describe('profileUpdateSchema', () => {
it('should pass with empty object (all optional)', () => {
const { error } = profileUpdateSchema.validate({});
expect(error).toBeUndefined();
});
it('should pass with all fields', () => {
const { error } = profileUpdateSchema.validate({
prefix: { th: 'นาย', en: 'Mr.' },
first_name: 'John',
last_name: 'Doe',
phone: '0812345678',
avatar_url: 'https://example.com/avatar.jpg',
birth_date: new Date('1990-01-01'),
});
expect(error).toBeUndefined();
});
it('should pass with partial update (first_name only)', () => {
const { error } = profileUpdateSchema.validate({ first_name: 'Jane' });
expect(error).toBeUndefined();
});
it('should fail if first_name is empty string', () => {
const { error } = profileUpdateSchema.validate({ first_name: '' });
expect(error).toBeDefined();
});
it('should fail if first_name exceeds 100 characters', () => {
const { error } = profileUpdateSchema.validate({ first_name: 'a'.repeat(101) });
expect(error).toBeDefined();
});
it('should fail if phone is too short (< 10 chars)', () => {
const { error } = profileUpdateSchema.validate({ phone: '081234' });
expect(error).toBeDefined();
});
it('should fail if phone exceeds 15 characters', () => {
const { error } = profileUpdateSchema.validate({ phone: '1'.repeat(16) });
expect(error).toBeDefined();
});
it('should pass with valid birth_date', () => {
const { error } = profileUpdateSchema.validate({ birth_date: new Date('2000-06-15') });
expect(error).toBeUndefined();
});
});
describe('changePasswordSchema (user.validator)', () => {
it('should pass with valid old and new passwords', () => {
const { error } = changePasswordSchema.validate({
oldPassword: 'oldpass123',
newPassword: 'newpass456',
});
expect(error).toBeUndefined();
});
it('should fail without oldPassword', () => {
const { error } = changePasswordSchema.validate({ newPassword: 'newpass456' });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Old password is required/i);
});
it('should fail without newPassword', () => {
const { error } = changePasswordSchema.validate({ oldPassword: 'oldpass123' });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/New password is required/i);
});
it('should fail if oldPassword is shorter than 6 chars', () => {
const { error } = changePasswordSchema.validate({
oldPassword: '12345',
newPassword: 'newpass456',
});
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/at least 6 characters/i);
});
it('should fail if newPassword is shorter than 6 chars', () => {
const { error } = changePasswordSchema.validate({
oldPassword: 'oldpass123',
newPassword: '123',
});
expect(error).toBeDefined();
});
it('should fail if oldPassword exceeds 100 characters', () => {
const { error } = changePasswordSchema.validate({
oldPassword: 'a'.repeat(101),
newPassword: 'newpass456',
});
expect(error).toBeDefined();
});
});

View file

@ -0,0 +1,59 @@
import {
getUserByIdValidator,
updateUserRoleValidator,
} from '@/validators/usermanagement.validator';
describe('getUserByIdValidator', () => {
it('should pass with valid id', () => {
const { error } = getUserByIdValidator.validate({ id: 1 });
expect(error).toBeUndefined();
});
it('should fail without id', () => {
const { error } = getUserByIdValidator.validate({});
expect(error).toBeDefined();
});
it('should fail with non-numeric id', () => {
const { error } = getUserByIdValidator.validate({ id: 'abc' });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/ID must be a number/i);
});
it('should pass with id = 0', () => {
// Joi number() allows 0 by default unless positive() is specified
const { error } = getUserByIdValidator.validate({ id: 0 });
expect(error).toBeUndefined();
});
});
describe('updateUserRoleValidator', () => {
it('should pass with valid id and role_id', () => {
const { error } = updateUserRoleValidator.validate({ id: 1, role_id: 2 });
expect(error).toBeUndefined();
});
it('should fail without id', () => {
const { error } = updateUserRoleValidator.validate({ role_id: 2 });
expect(error).toBeDefined();
});
it('should fail without role_id', () => {
const { error } = updateUserRoleValidator.validate({ id: 1 });
expect(error).toBeDefined();
// Joi uses field name in message when custom messages don't match
expect(error?.details[0].message).toContain('role_id');
});
it('should fail with non-numeric role_id', () => {
const { error } = updateUserRoleValidator.validate({ id: 1, role_id: 'admin' });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/Role ID must be a number/i);
});
it('should fail with non-numeric id', () => {
const { error } = updateUserRoleValidator.validate({ id: 'abc', role_id: 1 });
expect(error).toBeDefined();
expect(error?.details[0].message).toMatch(/ID must be a number/i);
});
});

View file

@ -0,0 +1,20 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
},
"types": [
"node",
"jest"
]
},
"include": [
"src/**/*",
"tests/**/*"
]
}

1
Frontend-Learner/.nuxtrc Normal file
View file

@ -0,0 +1 @@
setups.@nuxt/test-utils="4.0.0"

View file

@ -1,27 +1,34 @@
<script setup>
// Authentication
const { fetchUserProfile, isAuthenticated } = useAuth()
<script setup lang="ts">
/**
* @file app.vue
* @description Root application component.
* Handles initialization of authentication and theme settings.
*/
// App (Mounted)
// Initialize composables
const { fetchUserProfile, isAuthenticated } = useAuth()
const { isDark, set: setTheme } = useThemeMode()
// App initialization logic
onMounted(() => {
// 1. Login ( Token) Profile
// 1. Fetch user profile if tokens exist
if (isAuthenticated.value) {
fetchUserProfile()
}
// 2. Theme (Dark/Light) LocalStorage
// 2. Initialize theme from persistent storage or system preference
const savedTheme = localStorage.getItem('theme')
if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
if (savedTheme) {
setTheme(savedTheme === 'dark')
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
setTheme(true)
}
})
</script>
<template>
<!-- แสดง Loader ระหวางเปลยนหน หรอโหลดขอม -->
<GlobalLoader />
<!-- แสดงแถบโหลดดานบนจอ (Progress Bar) แทนการโหลดเตมหนาจอ -->
<NuxtLoadingIndicator color="#2563EB" :height="4" />
<!-- NuxtLayout: แสดง Layout กำหนดในแตละเพจ (default: layouts/default.vue) -->
<NuxtLayout>

View file

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

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
/**
* @file AnnouncementModal.vue
* @description Modal component to display course announcements
* @description คอมโพเนนต Modal สำหรบแสดงประกาศของคอรสเรยน (Modal component to display course announcements)
*/
const props = defineProps<{
@ -15,7 +15,7 @@ const emit = defineEmits<{
const { locale, t } = useI18n()
// Helper for localization
// (Helper for localization)
const getLocalizedText = (text: any) => {
if (!text) return ''
if (typeof text === 'string') return text
@ -49,7 +49,7 @@ const getLocalizedText = (text: any) => {
class="p-5 rounded-2xl bg-white dark:bg-slate-800 shadow-sm border border-gray-200 dark:border-white/5 transition-all hover:shadow-md relative overflow-hidden group"
:class="{'ring-2 ring-orange-200 dark:ring-orange-900/40 !bg-orange-50/50 dark:!bg-orange-900/20': ann.is_pinned}"
>
<!-- Pinned Banner -->
<!-- ายกำกบสำหรบขอความทกหมดไว (Pinned Banner) -->
<div v-if="ann.is_pinned" class="absolute top-0 right-0 p-3">
<q-icon name="push_pin" color="orange" size="18px" class="transform rotate-45" />
</div>

View file

@ -1,12 +1,12 @@
<script setup lang="ts">
/**
* @file CurriculumSidebar.vue
* @description Sidebar Component for displaying course curriculum (Chapters & Lessons)
* Handles lesson navigation, locked status display, and unread announcement badge.
* @description คอมโพเนนตแถบเมนานขางสำหรบแสดงหลกสตรของคอรสเรยน (บทเรยน & ตอนตางๆ)
* ดการการนำทางไปยงบทเรยน, แสดงสถานะการลอค, และแจงเตอนประกาศทงไมไดาน
*/
const props = defineProps<{
modelValue: boolean; // Sidebar open state (v-model)
modelValue: boolean; // / Sidebar (Sidebar open state - v-model)
courseData: any;
currentLessonId?: number;
isLoading: boolean;
@ -21,16 +21,41 @@ const emit = defineEmits<{
const { locale } = useI18n()
// Helper for localization
// (State for expansion items)
const chapterOpenState = ref<Record<string, boolean>>({})
// (Helper for localization)
const getLocalizedText = (text: any) => {
if (!text) return ''
if (typeof text === 'string') return text
const currentLocale = locale.value as 'th' | 'en'
// Safe locale access
const currentLocale = (locale?.value || 'th') as 'th' | 'en'
return text[currentLocale] || text.th || text.en || ''
}
// Local Progress Calculation
// (Helper: Check if lesson is completed)
const isLessonCompleted = (lesson: any) => {
return lesson.is_completed === true || lesson.progress?.is_completed === true
}
// (Reactive Chapter Completion Status)
// Map chapterId -> boolean (true )
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 (Local Progress Calculation)
const progressPercentage = computed(() => {
if (!props.courseData || !props.courseData.chapters) return 0
let total = 0
@ -38,11 +63,34 @@ const progressPercentage = computed(() => {
props.courseData.chapters.forEach((c: any) => {
c.lessons.forEach((l: any) => {
total++
if (l.is_completed || l.progress?.is_completed) completed++
if (isLessonCompleted(l)) completed++
})
})
return total > 0 ? Math.round((completed / total) * 100) : 0
})
// (Auto-expand chapter containing current lesson)
watch(() => props.currentLessonId, (newId) => {
if (newId && props.courseData?.chapters) {
props.courseData.chapters.forEach((chapter: any) => {
const hasLesson = chapter.lessons.some((l: any) => l.id === newId)
if (hasLesson) {
chapterOpenState.value[chapter.id] = true
}
})
}
}, { immediate: true })
// (Initialize all chapters as open by default on load)
watch(() => props.courseData, (newData) => {
if (newData?.chapters) {
newData.chapters.forEach((chapter: any) => {
if (chapterOpenState.value[chapter.id] === undefined) {
chapterOpenState.value[chapter.id] = true
}
})
}
}, { immediate: true })
</script>
<template>
@ -51,70 +99,110 @@ const progressPercentage = computed(() => {
@update:model-value="(val) => emit('update:modelValue', val)"
show-if-above
bordered
side="left"
:width="280"
side="right"
:width="300"
:breakpoint="1024"
class="bg-slate-50 dark:bg-slate-900 shadow-xl"
content-class="flex flex-col h-full"
class="bg-slate-50 dark:!bg-slate-900 shadow-xl"
>
<div v-if="courseData" class="flex flex-col h-full overflow-hidden">
<!-- Course Progress Header -->
<div class="p-5 border-b border-gray-200 dark:border-white/10 bg-slate-50/50 dark:bg-slate-900/50">
<div class="flex justify-between items-center mb-2">
<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>
<!-- คอนเทนเนอรหลกบงคบใชความกวางเตมท (Main Container: Enforce Column Layout and Full Width) -->
<div v-if="courseData" class="flex flex-col w-full h-full overflow-hidden text-slate-900 dark:!text-white relative bg-slate-50 dark:!bg-slate-900">
<!-- 1. วนห านบนคงท (Header Section - Fixed at Top) -->
<div class="flex-none p-5 border-b border-slate-200 dark:border-white/10 bg-white dark:!bg-slate-900 z-10 w-full">
<h2 class="text-sm font-bold mb-4 line-clamp-2 leading-snug block w-full 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 class="h-2 w-full bg-slate-200 dark:bg-slate-800 rounded-full overflow-hidden shadow-inner">
<div class="h-2 w-full bg-slate-100 dark:!bg-slate-800 rounded-full overflow-hidden">
<div
class="h-full bg-blue-600 dark:bg-blue-500 rounded-full transition-all duration-700 ease-out shadow-[0_0_12px_rgba(37,99,235,0.3)]"
class="h-full bg-blue-600 dark:bg-blue-500 rounded-full transition-all duration-700 ease-out"
:style="{ width: `${progressPercentage}%` }"
></div>
</div>
</div>
<div class="flex-grow scroll">
<q-list padding class="py-2">
<template v-for="chapter in courseData.chapters" :key="chapter.id">
<q-item-label header class="bg-slate-100 dark:bg-slate-800 text-[var(--text-main)] font-bold sticky top-0 z-10 border-b dark:border-white/5 text-sm py-4">
{{ getLocalizedText(chapter.title) }}
</q-item-label>
<q-item
v-for="lesson in chapter.lessons"
:key="lesson.id"
clickable
v-ripple
:active="currentLessonId === lesson.id"
active-class="bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 active-lesson-indicator"
class="px-5 py-3 transition-all duration-200 group relative border-b border-gray-100/50 dark:border-white/5"
@click="!lesson.is_locked && emit('select-lesson', lesson.id)"
:disable="lesson.is_locked"
<!-- 2. รายการหลกสตร นทเลอนได (Curriculum List - Scrollable Area) -->
<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">
<!-- กลองขอมลของบท (Chapter Accordion) -->
<q-expansion-item
v-model="chapterOpenState[chapter.id]"
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"
expand-icon-class="text-slate-400 dark:!text-slate-300"
>
<q-item-section avatar v-if="lesson.is_locked">
<q-icon name="lock" size="xs" color="grey" />
</q-item-section>
<template v-slot:header>
<div class="flex items-center w-full py-3 text-slate-900 dark:!text-white">
<div class="mr-3 flex-shrink-0">
<!-- วบงชบทเรยน เครองหมายถกหรอตวเลข (Chapter Indicator - Check or Number) -->
<div class="w-7 h-7 rounded-full border-2 flex items-center justify-center transition-colors font-bold"
:class="chapterCompletionStatus[chapter.id]
? 'border-green-500 text-green-500 bg-green-50 dark:!bg-green-500/10'
: 'border-slate-300 dark:!border-slate-600 text-slate-500 dark:!text-slate-400 bg-slate-100 dark:!bg-slate-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>
<q-item-label
class="text-sm font-bold line-clamp-2 transition-colors"
:class="currentLessonId === lesson.id ? 'text-blue-700 dark:text-blue-300' : 'text-slate-700 dark:text-slate-300'"
<!-- รายการบทเรยนยอย (Lessons List) -->
<div class="bg-slate-50 dark:!bg-slate-800/50 border-t border-slate-100 dark:border-slate-700 w-full">
<div
v-for="(lesson, lIdx) in chapter.lessons"
:key="lesson.id"
class="flex items-center px-4 py-3 cursor-pointer transition-all border-l-4 hover:bg-slate-100 dark:hover:!bg-slate-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) }}
</q-item-label>
</q-item-section>
<q-item-section side>
<div class="flex items-center">
<q-icon v-if="lesson.is_completed || lesson.progress?.is_completed" name="check_circle" color="positive" size="18px" />
<q-icon v-else-if="currentLessonId === lesson.id" name="play_arrow" color="primary" size="18px" class="animate-pulse" />
<q-icon v-else-if="lesson.is_locked" name="lock" color="grey-4" size="18px" />
<q-icon v-else name="radio_button_unchecked" color="grey-3" size="18px" />
</div>
</q-item-section>
</q-item>
</template>
</div>
</div>
</div>
</q-expansion-item>
</div>
</q-list>
</div>
</div>
@ -126,31 +214,18 @@ const progressPercentage = computed(() => {
</template>
<style scoped>
.active-lesson-indicator {
position: relative;
}
.active-lesson-indicator::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: #2563eb; /* blue-600 */
border-radius: 0 4px 4px 0;
}
.scroll::-webkit-scrollbar {
/* สครอลบาร์ปรับแต่งเพื่อความสวยงาม (Custom scrollbar for better aesthetics) */
::-webkit-scrollbar {
width: 4px;
}
.scroll::-webkit-scrollbar-track {
::-webkit-scrollbar-track {
background: transparent;
}
.scroll::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.05);
border-radius: 10px;
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
.dark .scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.05);
.dark ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
}
</style>

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
/**
* @file VideoPlayer.vue
* @description Video Player Component with custom controls provided by design
* @description คอมโพเนนตเครองเลนวโอพรอมดวยตวควบคมแบบกำหนดเองตามการออกแบบ (Video Player Component with custom controls provided by design)
*/
const props = defineProps<{
@ -22,7 +22,7 @@ const videoProgress = ref(0);
const currentTime = ref(0);
const duration = ref(0);
// Media Prefs
// (Media Prefs)
const { volume, muted: isMuted, setVolume, setMuted, applyTo } = useMediaPrefs();
const volumeIcon = computed(() => {
@ -40,7 +40,7 @@ const formatTime = (time: number) => {
const currentTimeDisplay = computed(() => formatTime(currentTime.value));
const durationDisplay = computed(() => formatTime(duration.value || 0));
// YouTube Helper Logic
// YouTube (YouTube Helper Logic)
const isYoutube = computed(() => {
const s = props.src.toLowerCase();
return s.includes('youtube.com') || s.includes('youtu.be');
@ -50,7 +50,7 @@ const youtubeEmbedUrl = computed(() => {
if (!isYoutube.value) return '';
let videoId = '';
// Extract Video ID
// (Extract Video ID)
if (props.src.includes('youtu.be')) {
videoId = props.src.split('youtu.be/')[1]?.split('?')[0];
} else {
@ -58,18 +58,18 @@ const youtubeEmbedUrl = computed(() => {
videoId = urlParams.get('v') || '';
}
// Return Embed URL with enablejsapi=1
// URL jsapi (Return Embed URL with enablejsapi=1)
return `https://www.youtube.com/embed/${videoId}?enablejsapi=1&rel=0`;
});
// YouTube API Tracking
// YouTube API (YouTube API Tracking)
let ytPlayer: any = null;
let ytInterval: any = null;
const initYoutubeAPI = () => {
if (!isYoutube.value || typeof window === 'undefined') return;
// Load API Script if not exists
// API (Load API Script if not exists)
if (!(window as any).YT) {
const tag = document.createElement('script');
tag.src = "https://www.youtube.com/iframe_api";
@ -83,7 +83,7 @@ const initYoutubeAPI = () => {
'onReady': (event: any) => {
duration.value = event.target.getDuration();
// Resume Logic for YouTube
// YouTube (Resume Logic for YouTube)
if (props.initialSeekTime && props.initialSeekTime > 0) {
event.target.seekTo(props.initialSeekTime, true);
}
@ -118,7 +118,7 @@ const startYTTracking = () => {
currentTime.value = ytPlayer.getCurrentTime();
emit('timeupdate', currentTime.value, duration.value);
}
}, 1000); // Check every second
}, 1000); // (Check every second)
};
const stopYTTracking = () => {
@ -145,7 +145,7 @@ onUnmounted(() => {
destroyYoutubePlayer();
});
// Watch for src change to re-init
// src (Watch for src change to re-init)
watch(() => props.src, (newSrc, oldSrc) => {
if (newSrc !== oldSrc) {
destroyYoutubePlayer();
@ -174,8 +174,8 @@ const togglePlay = () => {
playPromise.then(() => {
isPlaying.value = true;
}).catch(error => {
// Auto-play was prevented or play was interrupted
// We can safely ignore this error
// (Auto-play was prevented or play was interrupted)
// (We can safely ignore this error)
console.log("Video play request handled:", error.name);
});
}
@ -223,14 +223,14 @@ const handleVolumeChange = (val: any) => {
setVolume(newVol);
};
// Expose video ref for parent to control if needed
// video ref (Expose video ref for parent to control if needed)
defineExpose({
videoRef,
pause: () => videoRef.value?.pause(),
currentTime: () => videoRef.value?.currentTime || 0
});
// Watch for volume/mute changes to apply to video element
// / (Watch for volume/mute changes to apply to video element)
watch([volume, isMuted], () => {
if (videoRef.value) applyTo(videoRef.value);
});
@ -238,7 +238,7 @@ watch([volume, isMuted], () => {
<template>
<div class="bg-black rounded-xl overflow-hidden shadow-2xl mb-6 aspect-video relative group ring-1 ring-white/10">
<!-- 1. YouTube Player -->
<!-- 1. เครองเล YouTube (YouTube Player) -->
<iframe
v-if="isYoutube"
id="youtube-iframe"
@ -249,7 +249,7 @@ watch([volume, isMuted], () => {
allowfullscreen
></iframe>
<!-- 2. Standard HTML5 Video Player -->
<!-- 2. เครองเลนวโอ HTML5 มาตรฐาน (Standard HTML5 Video Player) -->
<div v-else class="w-full h-full relative group/video cursor-pointer">
<video
ref="videoRef"
@ -262,9 +262,9 @@ watch([volume, isMuted], () => {
@ended="handleEnded"
/>
<!-- Custom Controls Overlay (Only for HTML5 Video) -->
<!-- เลเยอรควบคมแบบกำหนดเอง (Overlay) เฉพาะสำหรบวโอ HTML5 เทาน (Custom Controls Overlay (Only for HTML5 Video)) -->
<div class="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-black/90 via-black/40 to-transparent transition-opacity opacity-0 group-hover/video:opacity-100 flex flex-col gap-3">
<!-- Progress Bar -->
<!-- แถบแสดงความคบหน (Progress Bar) -->
<div class="relative flex-grow h-1.5 bg-white/20 rounded-full cursor-pointer group/progress overflow-hidden" @click="seek">
<div class="absolute top-0 left-0 h-full bg-blue-500 rounded-full group-hover/progress:bg-blue-400 transition-all shadow-[0_0_12px_rgba(59,130,246,0.6)]" :style="{ width: videoProgress + '%' }"></div>
</div>
@ -275,7 +275,7 @@ watch([volume, isMuted], () => {
<div class="flex-grow"></div>
<!-- Volume Control -->
<!-- วควบคมระดบเสยง (Volume Control) -->
<div class="flex items-center gap-2 group/volume relative">
<q-btn flat round dense :icon="volumeIcon" @click.stop="handleToggleMute" color="white" class="hover:scale-110 transition-transform" />
<div class="w-0 group-hover/volume:w-24 overflow-hidden transition-all duration-300 flex items-center bg-black/60 backdrop-blur-md rounded-full px-2">

View file

@ -1,8 +1,8 @@
<script setup lang="ts">
/**
* @file FormInput.vue
* @description Reusable input component with label, error handling, and support for disabled/required states.
* Now supports password visibility toggle.
* @description คอมโพเนนตองกรอกขอม (Input) แบบนำกลบมาใชใหมได พรอมรองรบขอความปายกำก, ดการขอผดพลาด และสถานะปดใชงาน/งคบกรอก
* รองรบการสลบซอน/แสดงรหสผาน
*/
const props = defineProps<{
@ -16,19 +16,19 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
/** Update v-model value */
/** อัปเดตค่า v-model (Update v-model value) */
'update:modelValue': [value: string]
}>()
// Password visibility state
// / (Password visibility state)
const showPassword = ref(false)
// Toggle function
// (Toggle function)
const togglePassword = () => {
showPassword.value = !showPassword.value
}
// Compute input type based on visibility state
// بناءً pada state (Compute input type based on visibility state)
const inputType = computed(() => {
if (props.type === 'password') {
return showPassword.value ? 'text' : 'password'
@ -59,7 +59,7 @@ const updateValue = (event: Event) => {
@input="updateValue"
>
<!-- Password Toggle Button -->
<!-- มสลบซอน/แสดงรหสผาน (Password Toggle Button) -->
<button
v-if="type === 'password'"
type="button"
@ -67,13 +67,13 @@ const updateValue = (event: Event) => {
@click="togglePassword"
tabindex="-1"
>
<!-- Eye Icon (Show) -->
<!-- ไอคอนเปดตา (แสดงรหสผาน) (Eye Icon - Show) -->
<svg v-if="!showPassword" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<!-- Eye Off Icon (Hide) -->
<!-- ไอคอนปดตา (อนรหสผาน) (Eye Off Icon - Hide) -->
<svg v-else xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/>
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/>

View file

@ -1,20 +1,20 @@
<script setup lang="ts">
/**
* @file GlobalLoader.vue
* @description Global full-screen loading overlay that triggers during page navigation.
* Uses a premium pulsing logo animation.
* @description คอมโพเนนตหนาจอโหลดแบบเตมจอ (Global full-screen loading) แสดงผลตอนเปลยนหน
* พรมแอนเมชนโลโกขยบไดแบบพรเมยม
*/
const nuxtApp = useNuxtApp()
const isLoading = ref(false)
// Hook into Nuxt page transitions
// Nuxt hook (Hook into Nuxt page transitions)
nuxtApp.hook('page:start', () => {
isLoading.value = true
})
nuxtApp.hook('page:finish', () => {
// Add a small delay for better UX (prevents flickering on fast loads)
// (Add a small delay for better UX)
setTimeout(() => {
isLoading.value = false
}, 500)
@ -25,14 +25,14 @@ nuxtApp.hook('page:finish', () => {
<Transition name="fade">
<div v-if="isLoading" class="fixed inset-0 z-[99999] flex flex-col items-center justify-center bg-white dark:bg-[#0f172a] transition-colors duration-300">
<div class="relative flex flex-col items-center">
<!-- Main Logo Box -->
<!-- กลองโลโกหล (Main Logo Box) -->
<div class="w-20 h-20 bg-blue-50 dark:bg-blue-900/20 rounded-2xl flex items-center justify-center mb-6 animate-pulse-soft">
<div class="w-12 h-12 bg-blue-600 rounded-xl flex items-center justify-center shadow-lg shadow-blue-600/30 animate-bounce-subtle">
<span class="text-2xl font-black text-white">E</span>
</div>
</div>
<!-- Loading Text -->
<!-- อความระหวางโหลด (Loading Text) -->
<div class="flex flex-col items-center gap-2">
<h3 class="text-lg font-bold text-slate-800 dark:text-white tracking-wide">e-Learning</h3>
<div class="flex gap-1">

View file

@ -1,13 +1,13 @@
<script setup lang="ts">
/**
* @file LanguageSwitcher.vue
* @description Language switcher component using Quasar dropdown.
* Allows switching between Thai (th) and English (en) locales.
* @description คอมโพเนนตวสลบภาษาใช Dropdown ของ Quasar
* ใชสลบระหวางภาษาไทย (th) และภาษาองกฤษ (en)
*/
const { locale, setLocale, locales } = useI18n()
// Get available locales with their names
// (Get available locales with their names)
const availableLocales = computed(() => {
return (locales.value as Array<{ code: string; name: string }>).map((loc) => ({
code: loc.code,
@ -15,13 +15,13 @@ const availableLocales = computed(() => {
}))
})
// Get flag image path for a locale
// (Get flag image path for a locale)
const getFlagPath = (code: string) => `/flags/${code}.png`
// Handle locale change
// (Handle locale change)
const changeLocale = async (code: string) => {
await setLocale(code as 'th' | 'en')
// Cookie is automatically handled by @nuxtjs/i18n with detectBrowserLanguage.useCookie
// (Cookie) @nuxtjs/i18n detectBrowserLanguage.useCookie
}
</script>
@ -32,7 +32,7 @@ const changeLocale = async (code: string) => {
class="language-btn"
:aria-label="$t('language.label')"
>
<!-- Show current locale flag -->
<!-- แสดงธงชาตตามภาษาทใชอย (Show current locale flag) -->
<img
:src="getFlagPath(locale)"
:alt="locale.toUpperCase()"
@ -178,7 +178,7 @@ const changeLocale = async (code: string) => {
</style>
<style>
/* Global styles for teleported menu */
/* สไตล์ Global สำหรับเมนูที่ถูกข้ามไปแสดงผลที่อื่นด้วย Teleport (Global styles for teleported menu) */
.language-menu {
border-radius: 16px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);

View file

@ -1,4 +1,8 @@
<script setup lang="ts">
/**
* @file LoadingSkeleton.vue
* @description คอมโพเนนต Skeleton สำหรบแสดงโครงรางหนาจอระหวางรอโหลดขอม (Loading Skeleton Component)
*/
defineProps<{
type?: 'text' | 'avatar' | 'card' | 'button'
width?: string
@ -9,6 +13,7 @@ defineProps<{
<template>
<div class="skeleton-wrapper">
<!-- กรณเปนโครงรางประเภทการ (Card type skeleton) -->
<template v-if="type === 'card'">
<div v-for="i in (count || 1)" :key="i" class="skeleton-card">
<div class="skeleton skeleton-image"/>
@ -20,14 +25,17 @@ defineProps<{
</div>
</template>
<!-- กรณเปนโครงรางประเภทรปโปรไฟล (Avatar type skeleton) -->
<template v-else-if="type === 'avatar'">
<div class="skeleton skeleton-avatar"/>
</template>
<!-- กรณเปนโครงรางประเภทปมกด (Button type skeleton) -->
<template v-else-if="type === 'button'">
<div class="skeleton skeleton-button" :style="{ width: width || '120px' }"/>
</template>
<!-- กรณนๆ จะแสดงเปนบรรทดขอความ (Fallback/Text type skeleton) -->
<template v-else>
<div
v-for="i in (count || 1)"

View file

@ -1,4 +1,8 @@
<script setup lang="ts">
/**
* @file LoadingSpinner.vue
* @description ไอคอนหมนแสดงการโหลด (Loading Spinner Component) เหมาะสำหรบใชตรงจดเลกๆ หรอตอนโหลดหนาเว
*/
defineProps<{
size?: 'sm' | 'md' | 'lg'
text?: string

View file

@ -1,8 +1,8 @@
<script setup lang="ts">
/**
* @file CourseCard.vue
* @description Standardized Course Card Component.
* Usage: <CourseCard :id="1" title="..." ... />
* @description คอมโพเนนตการดแสดงคอรสเรยนมาตรฐาน (Standardized Course Card Component)
* ใชงาน: <CourseCard :id="1" title="..." ... />
*/
interface CourseCardProps {
@ -20,11 +20,13 @@ interface CourseCardProps {
image?: string
loading?: boolean
// Action Flags
// (Action Flags)
showViewDetails?: boolean
showContinue?: boolean
showCertificate?: boolean
showStudyAgain?: boolean
hideProgress?: boolean
hideActions?: boolean
}
const props = withDefaults(defineProps<CourseCardProps>(), {
@ -55,9 +57,9 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</script>
<template>
<div class="group relative flex flex-col bg-white dark:!bg-[#0f172a] rounded-3xl overflow-hidden border border-slate-200 dark:border-slate-800 shadow-sm hover:shadow-xl dark:shadow-none hover:-translate-y-1 transition-all duration-300 h-full">
<div class="group relative flex flex-col bg-white dark:!bg-slate-900 rounded-3xl overflow-hidden border border-slate-200 dark:border-white/5 shadow-sm hover:shadow-xl dark:shadow-none hover:-translate-y-1 transition-all duration-300 h-full">
<!-- Thumbnail Section -->
<!-- วนรปหนาปก (Thumbnail Section) -->
<div class="relative w-full aspect-video overflow-hidden">
<img
v-if="image"
@ -69,12 +71,12 @@ const displayCategory = computed(() => getLocalizedText(props.category))
<q-icon name="image" size="48px" class="text-slate-300 dark:text-slate-600" />
</div>
<!-- Overlays -->
<!-- เลเยอรลเตอรอนท (Overlays) -->
<div class="absolute inset-0 bg-gradient-to-t from-slate-900/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"></div>
<!-- Completed Badge -->
<!-- ายแสดงสถานะเรยนจบ (Completed Badge) -->
<div v-if="completed" class="absolute inset-0 bg-emerald-900/40 backdrop-blur-[2px] flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<span class="bg-emerald-500 text-white w-12 h-12 rounded-full flex items-center justify-center shadow-lg transform scale-75 group-hover:scale-100 transition-transform">
<q-icon name="check" size="24px" />
@ -82,9 +84,9 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</div>
</div>
<!-- Content Section -->
<!-- วนเนอหาขอม (Content Section) -->
<div class="p-6 flex flex-col flex-grow">
<!-- Meta Info (Lessons/Duration) -->
<!-- อมลประกอบยอย เช บทเรยน/ระยะเวลา (Meta Info - Lessons/Duration) -->
<div class="flex items-center gap-3 text-xs font-bold text-slate-500 dark:text-slate-400 mb-3 uppercase tracking-wider">
<span v-if="lessons" class="flex items-center gap-1">
<q-icon name="menu_book" size="14px" /> {{ lessons }} {{ $t('course.lessonsUnit') }}
@ -94,19 +96,19 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</span>
</div>
<!-- Title -->
<!-- อคอร (Title) -->
<h3 class="text-lg font-black text-slate-900 dark:text-white mb-2 line-clamp-2 leading-tight group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
{{ displayTitle }}
</h3>
<!-- Description -->
<!-- รายละเอยดเพมเต (Description) -->
<p v-if="displayDescription" class="text-sm text-slate-500 dark:text-slate-400 line-clamp-2 mb-6">
{{ displayDescription }}
</p>
<div class="mt-auto pt-4">
<!-- Progress Bar -->
<div v-if="progress !== undefined && !completed" class="mb-4">
<!-- หลอดความคบหน (Progress Bar) -->
<div v-if="progress !== undefined && !completed && !hideProgress" class="mb-4">
<div class="flex justify-between text-[10px] font-bold uppercase mb-1">
<span class="text-slate-500 dark:text-slate-400">{{ $t('course.progress') }}</span>
<span class="text-blue-600 dark:text-blue-400">{{ progress }}%</span>
@ -116,18 +118,19 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</div>
</div>
<!-- Action Buttons -->
<!-- View Details (Secondary Action) -->
<!-- มปฏการตางๆ (Action Buttons) -->
<div v-if="!hideActions" class="flex flex-col gap-3">
<!-- มดรายละเอยด (มรอง) (View Details - Secondary Action) -->
<q-btn
v-if="showViewDetails && !completed && !progress"
flat
rounded
class="w-full font-bold !text-blue-600 !bg-blue-50 hover:!bg-blue-100 dark:!bg-blue-900/40 dark:!text-blue-300 dark:hover:!bg-blue-900/60"
class="w-full font-bold !text-blue-600 !bg-blue-50 hover:!bg-blue-100 dark:!bg-blue-500/10 dark:!text-blue-400 dark:hover:!bg-blue-500/20"
:label="$t('menu.viewDetails')"
:to="`/course/${id}`"
/>
<!-- Continue Learning (Primary Action) -->
<!-- มเรยนต/เรมเรยน (มหล) (Continue Learning - Primary Action) -->
<q-btn
v-if="showContinue || (progress && !completed) || (progress === 0 && !completed)"
unelevated
@ -136,9 +139,10 @@ const displayCategory = computed(() => getLocalizedText(props.category))
:label="(!progress || progress === 0) ? $t('course.startLearning') : $t('course.continueLearning')"
:to="`/classroom/learning?course_id=${id}`"
/>
</div>
<div v-if="completed" class="space-y-2">
<!-- Study Again -->
<!-- มเรยนอกคร (Study Again) -->
<q-btn
v-if="showStudyAgain"
unelevated
@ -148,7 +152,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))
:to="`/classroom/learning?course_id=${id}`"
/>
<!-- Download Certificate -->
<!-- มดาวนโหลดใบรบรอง (Download Certificate) -->
<q-btn
v-if="showCertificate"
unelevated
@ -164,5 +168,5 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</template>
<style scoped>
/* Scoped overrides if needed */
/* ใส่โค้ด CSS เพิ่มได้ถ้าต้องการครอบคลุมเฉพาะไฟล์นี้ (Scoped overrides if needed) */
</style>

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
/**
* @file CategorySidebar.vue
* @description Sidebar for filtering courses by category
* @description แถบเมนานขางสำหรบกรองคอรสตามหมวดหม (Sidebar for filtering courses by category)
*/
const props = defineProps<{
@ -81,13 +81,13 @@ const toggleCategory = (id: number) => {
{{ getLocalizedText(cat.name) }}
</span>
<!-- Active Indicator Dot -->
<!-- ดแสดงสถานะเมอถกเลอก (Active Indicator Dot) -->
<div v-if="modelValue.includes(cat.id)" class="ml-auto w-1.5 h-1.5 rounded-full bg-blue-600 dark:bg-blue-400 shadow-lg shadow-blue-500/50"></div>
</div>
</div>
</div>
<!-- Show More/Less Action -->
<!-- มแสดงเพมเต/แสดงนอยลง (Show More/Less Action) -->
<div
v-if="categories.length > 5"
@click="showAllCategories = !showAllCategories"

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
/**
* @file CourseDetailView.vue
* @description Quick view of course details including video preview, curriculum, and enroll logic
* @description แสดงรายละเอยดคอรสแบบรวดเร รวมถงตวอยางวโอ, หลกสตร, และระบบการลงทะเบยน
*/
import { ref, computed } from 'vue'
@ -33,30 +33,44 @@ const formatPrice = (price: number) => {
}
const enrollmentLoading = ref(false);
const activeTab = ref('curriculum');
const totalLessons = computed(() => {
if (!props.course?.chapters) return 0;
return props.course.chapters.reduce((acc: number, chapter: any) => acc + (chapter.lessons?.length || 0), 0);
});
const totalDuration = computed(() => {
if (!props.course?.chapters) return 0;
return props.course.chapters.reduce((acc: number, chapter: any) => {
return acc + (chapter.lessons?.reduce((lAcc: number, lesson: any) => lAcc + (lesson.duration_minutes || 0), 0) || 0);
}, 0);
});
const handleEnroll = () => {
if(!props.course) return;
enrollmentLoading.value = true;
emit('enroll', props.course.id);
// Loading state reset depends on parent, but locally we can reset after emit or keep until prop changes
// In this pattern, we just emit.
setTimeout(() => enrollmentLoading.value = false, 2000); // Safety timeout
// Loading event prop
// event (just emit)
setTimeout(() => enrollmentLoading.value = false, 2000); // (Safety timeout)
};
const instructorData = computed(() => {
if (props.course?.instructors && props.course.instructors.length > 0) {
const primary = props.course.instructors.find((i: any) => i.is_primary);
return primary ? primary.user : props.course.instructors[0].user;
}
return props.course?.creator || null;
});
</script>
<template>
<div class="animate-fade-in-up">
<div class="flex items-center gap-2 mb-8 group cursor-pointer" @click="emit('back')">
<q-icon name="arrow_back" size="20px" class="text-slate-400 group-hover:text-blue-600 transition-colors" />
<span class="text-sm font-bold text-slate-500 group-hover:text-blue-600 transition-colors uppercase tracking-widest">{{ $t('common.back') }}</span>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Left: Content Detail -->
<!-- านซาย: รายละเอยดเนอหา (Left: Content Detail) -->
<div class="lg:col-span-2 space-y-8">
<!-- Video Preview Section -->
<!-- วนแสดงตวอยางวโอ (Video Preview Section) -->
<div class="relative aspect-video rounded-3xl overflow-hidden shadow-2xl group cursor-pointer bg-slate-900 border-4 border-white dark:border-slate-800 transition-transform duration-500 hover:scale-[1.01]">
<template v-if="course.media?.video_url">
<video
@ -67,19 +81,19 @@ const handleEnroll = () => {
<source :src="course.media.video_url" type="video/mp4">
{{ $t('course.videoNotSupported') }}
</video>
<!-- Custom Play Overlay when not playing - simple version is often best -->
<!-- มเลนวโอแบบปรบแตงเองตอนยงไมเล (Custom Play Overlay when not playing) -->
</template>
<!-- Beautiful Image Showcase if no video -->
<!-- แสดงรปภาพสวยๆ กรณไมโอ (Beautiful Image Showcase if no video) -->
<template v-else>
<div class="w-full h-full flex items-center justify-center relative overflow-hidden bg-slate-950 group">
<!-- Blurred background fill -->
<!-- ปพนหลงเบลอ (Blurred background fill) -->
<img
v-if="course.thumbnail_url || course.cover_image"
:src="course.thumbnail_url || course.cover_image"
class="absolute inset-0 w-full h-full object-cover opacity-40 blur-2xl scale-125"
/>
<!-- Main Sharp Image -->
<!-- ปหลกแบบคมช (Main Sharp Image) -->
<img
v-if="course.thumbnail_url || course.cover_image"
:src="course.thumbnail_url || course.cover_image"
@ -93,7 +107,7 @@ const handleEnroll = () => {
</template>
</div>
<!-- Course Title & Description -->
<!-- อคอรสและรายละเอยด (Course Title & Description) -->
<div>
<h1 class="text-3xl md:text-4xl font-extrabold text-slate-900 dark:text-white mb-4 leading-tight">
{{ getLocalizedText(course.title) }}
@ -104,19 +118,37 @@ const handleEnroll = () => {
</div>
</div>
<!-- Curriculum Preview -->
<div class="bg-slate-50 dark:bg-slate-900 rounded-3xl p-6 md:p-8 border border-slate-200 dark:border-white/5">
<div class="flex items-center justify-between mb-8">
<!-- รายละเอยดคอร - แแบบหนาเดยว (Course Detail - Single Page Layout) -->
<div class="space-y-10">
<!-- อมลผสอน (Instructor Info) -->
<div class="flex flex-col sm:flex-row gap-6 items-start sm:items-center pb-8 border-b border-slate-200 dark:border-slate-800">
<q-avatar size="64px">
<img :src="instructorData?.profile?.avatar_url || 'https://cdn.quasar.dev/img/boy-avatar.png'" />
</q-avatar>
<div>
<h3 class="text-xl font-black text-slate-900 dark:text-white mb-1 flex items-center gap-2">
<div class="text-sm text-slate-500 mb-1 font-bold uppercase tracking-wider">{{ $t('course.instructor') }}</div>
<div class="font-bold text-xl text-slate-800 dark:text-white">
{{ instructorData?.profile?.first_name || 'Unknown' }} {{ instructorData?.profile?.last_name || 'Instructor' }}
</div>
<div class="text-slate-500 text-sm mt-1">{{ instructorData?.email || 'No contact info' }}</div>
</div>
</div>
<!-- รายละเอยดหลกสตร / บทเรยน (Curriculum / Lesson Details) -->
<div>
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-bold text-slate-900 dark:text-white">
{{ $t('course.courseContent') }}
</h3>
<div class="text-sm font-bold text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-white/5 px-4 py-2 rounded-full">
{{ totalLessons }} {{ $t('course.lessons') }} {{ totalDuration }} {{ $t('quiz.minutes') }}
</div>
<q-icon name="keyboard_command_key" class="text-slate-200 dark:text-slate-800" size="32px" />
</div>
<div class="space-y-4">
<div v-for="(chapter, idx) in course.chapters" :key="chapter.id" class="group">
<!-- วนหวของบท (Chapter Header) -->
<div class="px-6 py-4 bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-white/5 font-black text-slate-800 dark:text-white flex justify-between items-center mb-2 shadow-sm">
<span class="flex items-center gap-3">
<span class="w-7 h-7 flex items-center justify-center bg-slate-100 dark:bg-white/10 rounded-lg text-xs font-bold font-mono">{{ Number(idx) + 1 }}</span>
@ -124,20 +156,24 @@ const handleEnroll = () => {
</span>
<span class="text-[10px] uppercase font-black tracking-widest text-slate-400 opacity-60">{{ chapter.lessons?.length || 0 }} {{ $t('course.lessonsUnit') }}</span>
</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 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
:name="lesson.type === 'VIDEO' ? 'play_arrow' : 'article'"
size="16px"
/>
</div>
<span class="flex-1 font-bold">{{ getLocalizedText(lesson.title) }}</span>
<span v-if="lesson.duration_minutes" class="text-slate-400 dark:text-slate-500 text-[10px] font-bold">{{ lesson.duration_minutes }} {{ $t('quiz.minutes') }}</span>
<q-icon v-if="lesson.is_locked !== false" name="lock" size="14px" class="text-slate-300 dark:text-slate-600" />
<span class="flex-1 font-bold truncate">{{ getLocalizedText(lesson.title) }}</span>
<span v-if="lesson.duration_minutes" class="text-slate-400 dark:text-slate-500 text-[10px] font-bold shrink-0">{{ lesson.duration_minutes }} {{ $t('quiz.minutes') }}</span>
<q-icon v-if="lesson.is_locked !== false" name="lock" size="14px" class="text-slate-300 dark:text-slate-600 shrink-0" />
</div>
</div>
</div>
<!-- กรณไมอม (Empty State) -->
<div v-if="!course.chapters || course.chapters.length === 0" class="flex flex-col items-center justify-center py-12 text-slate-400 dark:text-slate-500 bg-white/50 dark:bg-slate-900/50 rounded-2xl border-2 border-dashed border-slate-200 dark:border-slate-800">
<q-icon name="menu_book" size="40px" class="mb-2 opacity-50" />
<p class="text-sm font-medium">{{ $t('course.noContent') }}</p>
@ -147,11 +183,13 @@ const handleEnroll = () => {
</div>
<!-- Right: Enrollment Card -->
</div>
<!-- านขวา: การดลงทะเบยน (Right: Enrollment Card) -->
<div class="lg:col-span-1">
<div class="sticky top-24">
<div class="bg-white dark:bg-slate-900 rounded-3xl shadow-2xl shadow-blue-500/10 dark:shadow-none p-8 border border-slate-100 dark:border-white/5 relative overflow-hidden group">
<!-- Decorative background glow -->
<!-- กเลนแสงพนหลงตกแต (Decorative background glow) -->
<div class="absolute -top-12 -right-12 w-48 h-48 bg-blue-500/10 rounded-full blur-3xl group-hover:bg-blue-500/20 transition-colors"></div>
<div class="relative">

View file

@ -1,84 +1,116 @@
<script setup lang="ts">
/**
* @file AppHeader.vue
* @description The main header for the authenticated application dashboard.
* Uses Quasar QToolbar.
* @description แถบเมนานบนหล (Header) สำหรบหนาแดชบอร (Dashboard) ของระบบ EduLearn
*/
defineProps<{
/** Controls visibility of the search bar */
showSearch?: boolean
}>()
const props = defineProps<{
/** ควบคุมการแสดงผลของปุ่มเปิด/ปิดแถบเมนูด้านข้าง (Sidebar) */
showSidebarToggle?: boolean;
}>();
const emit = defineEmits<{
/** Emitted when the hamburger menu is clicked */
toggleSidebar: []
}>()
/** ส่งสัญญาณ (Emit) เมื่อผู้ใช้คลิกที่ปุ่มแฮมเบอร์เกอร์เมนู */
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>
<template>
<q-toolbar class="bg-transparent text-slate-800 dark:text-white h-16 px-4">
<!-- Menu Toggle (Always Visible) -->
<q-toolbar class="bg-white dark:!bg-[#020617] text-slate-900 dark:!text-white h-20 border-b border-slate-50 dark:border-slate-800/50 px-6">
<!-- านซาย: มยอขยายแถบเมนานขาง (Hamburger Toggle) -->
<q-btn
flat
round
dense
icon="menu"
@click="emit('toggleSidebar')"
class="mr-2 text-slate-900 dark:text-white bg-slate-100 dark:bg-slate-700/50 hover:bg-slate-200 dark:hover:bg-slate-600"
aria-label="Menu"
class="text-slate-400 hover:text-blue-600 transition-colors"
size="16px"
@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 dark:text-white group-hover:text-blue-600 transition-colors">E-Learning</span>
<span class="text-[10px] font-bold uppercase tracking-[0.2em] leading-none mt-1 text-slate-500 dark:text-slate-400">Platform</span>
</div>
</div>
<q-space />
<!-- Center Search (Optional) -->
<div v-if="showSearch !== false" class="hidden md:block w-1/3 max-w-md mx-4">
<q-input
<!-- วนการตงคาทางดานขวา (Right Section) -->
<div class="flex items-center gap-2 sm:gap-4 md:gap-6 no-wrap">
<!-- มสลบธ (Theme Toggle) -->
<q-btn
flat
round
dense
outlined
rounded
v-model="searchText"
:placeholder="$t('menu.searchCourses')"
class="bg-slate-50 dark:bg-slate-700/50 search-input"
bg-color="transparent"
:icon="isDark ? 'dark_mode' : 'light_mode'"
:class="isDark ? 'text-blue-400' : 'text-amber-500'"
class="transition-all active:scale-90"
size="12px"
@click="toggleTheme"
>
<template v-slot:prepend>
<q-icon name="search" class="text-slate-400" />
</template>
</q-input>
<q-tooltip>{{ isDark ? 'โหมดกลางคืน' : 'โหมดกลางวัน' }}</q-tooltip>
</q-btn>
<!-- วสลบภาษาแบบแคปซ (Language Switcher) -->
<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>
<q-space />
<!-- เสนค (Divider) -->
<div class="hidden sm:block w-[1px] h-8 bg-slate-100 dark:bg-slate-800"></div>
<!-- Right Actions -->
<div class="flex items-center gap-2">
<!-- Language Switcher -->
<LanguageSwitcher />
<!-- วนขอมลผใชงาน (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 Dropdown -->
<UserMenu />
</div>
</q-toolbar>
</template>
<style scoped>
.search-input :deep(.q-field__control) {
border-radius: 9999px; /* Full rounded */
/* บังคับให้ความสูงของ Header เท่ากันเสมอ (Ensure toolbar height is consistent) */
:deep(.q-toolbar) {
min-height: 80px;
}
/* ซ่อนชื่อผู้ใช้ไว้เฉพาะบนหน้าจอมือถือขนาดเล็กเท่านั้น (Hide user name only on small mobile screens) */
@media (max-width: 600px) {
.user-info-text {
display: none !important;
}
.search-input :deep(.q-field__control:before) {
border-color: #e2e8f0; /* slate-200 */
}
</style>

View file

@ -1,97 +1,145 @@
<script setup lang="ts">
/**
* @file AppSidebar.vue
* @description Sidebar navigation for the authenticated dashboard.
* Uses Quasar QList for structure.
* @description เมนานขางสำหรบการนำทาง (Sidebar Navigation)
*/
// 1. Composables
import { useQuasar } from 'quasar'
const { t } = useI18n()
const route = useRoute()
const $q = useQuasar()
const { logout } = useAuth()
const { isDark } = useThemeMode()
const navItems = computed(() => [
{
to: "/dashboard",
label: t('sidebar.overview'),
icon: "dashboard", // Using Material Icons names where possible or SVG paths
isSvg: false
},
{
to: "/browse/discovery",
label: t('sidebar.browseCourses'),
icon: "explore",
isSvg: false
},
{
to: "/dashboard/my-courses",
label: t('sidebar.myCourses'),
icon: "school",
isSvg: false
}
]);
// 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) => {
if (import.meta.client) {
window.location.href = path
}
// 3. (Account Navigation)
const accountItems = computed(() => [
{ 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>
<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 -->
<q-list padding class="text-slate-600 dark:text-slate-400 flex-grow px-3 pt-6">
<q-item
v-for="item in navItems"
<!-- การนำทางหล (Main Navigation) -->
<div class="space-y-1 mb-8">
<NuxtLink
v-for="item in menuItems"
:key="item.to"
clickable
v-ripple
@click="handleNavigate(item.to)"
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') }"
:to="item.to"
class="flex items-center gap-4 px-4 py-3 rounded-2xl transition-all group relative 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-item-section avatar>
<q-icon :name="item.icon" size="22px" />
</q-item-section>
<q-icon :name="item.icon" size="24px" class="transition-colors" />
<span class="font-bold text-[15px]">{{ item.label }}</span>
<q-item-section>
<q-item-label class="font-bold text-sm">{{ item.label }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
<!-- วบงชหนาปจจ (Active Indicator) -->
<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>
</NuxtLink>
</div>
<!-- หมวดหมญช (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>
</template>
<style scoped>
.sidebar-item--active {
background: #eff6ff !important; /* blue-50 */
color: #1d4ed8 !important; /* blue-700 */
position: relative;
.nav-item.active {
background: #EFF6FF;
color: #2563EB;
}
.sidebar-item--active::before {
content: '';
position: absolute;
left: 0;
top: 15%;
height: 70%;
width: 4px;
background: #2563eb;
border-radius: 0 4px 4px 0;
.dark .nav-item.active {
background: rgba(37,99,235,0.1);
color: #60A5FA;
}
/* Dark Mode Active State Enhancement */
.dark .sidebar-item--active {
background: rgba(59, 130, 246, 0.12) !important;
color: #60a5fa !important; /* blue-400 */
.nav-item.active .q-icon {
color: #2563EB;
}
.dark .sidebar-item--active .q-icon {
color: #60a5fa !important; /* blue-400 */
}
.dark .sidebar-item--active::before {
background: #3b82f6;
.dark .nav-item.active .q-icon {
color: #60A5FA;
}
</style>

View file

@ -0,0 +1,95 @@
<script setup lang="ts">
/**
* @file LandingFooter.vue
* @description วนทายของหนาแรก (Footer component for the landing page)
*/
</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) -->
<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 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 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

@ -1,50 +1,64 @@
<script setup lang="ts">
/**
* @file LandingHeader.vue
* @description The main header for the public landing pages.
* Features a transparent background that becomes solid/glass upon scrolling.
* @description คอมโพเนนตแถบเมนานบน (Header Navigation) สำหรบหน Landing Page และหนาเปดอนๆ
* รองรบการเปลยนภาษา เปลยนโหมดสวาง/ และเขาถงเมนใช (Profile/Logout)
*/
// Track scrolling state to adjust header styling
// (scroll) Header
const isScrolled = ref(false)
const { isAuthenticated } = useAuth()
// (Mobile Drawer State)
// / (Mobile Drawer)
const mobileMenuOpen = ref(false)
const handleScroll = () => {
isScrolled.value = window.scrollY > 20
}
// (Lock body scroll)
watch(mobileMenuOpen, (isOpen) => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
})
onMounted(() => {
// Add scroll listener to toggle 'isScrolled' class
window.addEventListener('scroll', () => {
isScrolled.value = window.scrollY > 20
window.addEventListener('scroll', handleScroll)
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
document.body.style.overflow = '' // (Cleanup)
})
</script>
<template>
<!--
Header Container
- Transitions between transparent and glass effect based on scroll.
คอนเทนเนอรของ Header (Header Container)
- เปลยนจากสใส (transparent) เปนเอฟเฟกตกระจก (glass effect) เมอเลอนเมาสลง
-->
<header
class="landing-header transition-all duration-300"
:class="[isScrolled ? 'h-16 glass-nav shadow-lg' : 'h-24 bg-transparent']"
class="fixed top-0 left-0 right-0 z-[100] transition-all"
: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">
<!--
Left Section: Logo & Desktop Navigation
-->
<div class="flex items-center gap-12">
<!-- Logo -->
<div class="container mx-auto px-6 md:px-12 h-full flex items-center justify-between">
<!-- านซาย: โลโกแบรนด (Left Section: Logo) -->
<NuxtLink to="/" class="flex items-center gap-3 group">
<div class="logo-box bg-blue-600 text-white font-black rounded-xl w-10 h-10 flex items-center justify-center shadow-lg shadow-blue-600/30 group-hover:scale-110 transition-transform">
E
<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-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']"
>
E-Learning
EduLearn
</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']"
>
Platform
@ -52,81 +66,157 @@ onMounted(() => {
</div>
</NuxtLink>
<!-- Desktop Links -->
<nav class="hidden md:block">
<ul class="flex items-center gap-8 text-sm font-bold">
<li>
<!-- การนำทางสำหรบเดสกอป (แสดงผลเปนคาเรมต, อนบนมอถอผาน CSS 'desktop-nav') -->
<!-- <nav class="flex desktop-nav items-center gap-8 text-[16px] font-medium">
<NuxtLink
to="/browse"
class="transition-colors relative group"
:class="[isScrolled ? 'text-slate-400 hover:text-white' : 'text-slate-600 hover:text-blue-600']"
class="transition-colors relative group py-2"
:class="[isScrolled ? 'text-white hover:text-white' : 'text-grey-8 hover:text-blue-600']"
>
{{ $t('landing.allCourses') }}
<span class="absolute -bottom-1 left-0 w-0 h-0.5 bg-blue-600 transition-all group-hover:w-full"/>
{{ $t('sidebar.onlineCourses') }}
<span class="absolute bottom-0 left-0 w-0 h-0.5 bg-blue-600 transition-all group-hover:w-full"/>
</NuxtLink>
</li>
<li>
<NuxtLink
to="/browse/discovery"
class="transition-colors relative group"
:class="[isScrolled ? 'text-slate-400 hover:text-white' : 'text-slate-600 hover:text-blue-600']"
to="/browse/recommended"
class="transition-colors relative group py-2"
:class="[isScrolled ? 'text-white hover:text-white' : 'text-grey-8 hover:text-blue-600']"
>
{{ $t('landing.discovery') }}
<span class="absolute -bottom-1 left-0 w-0 h-0.5 bg-blue-600 transition-all group-hover:w-full"/>
{{ $t('sidebar.recommendedCourses') }}
<span class="absolute bottom-0 left-0 w-0 h-0.5 bg-blue-600 transition-all group-hover:w-full"/>
</NuxtLink>
</li>
</ul>
</nav>
</div>
</nav> -->
<!--
Right Section: Action Buttons (Login/Register or Dashboard)
-->
<div class="flex items-center gap-4">
<!-- มปฏการสำหรบเดสกอป (แสดงผลเปนคาเรมต, อนบนมอถอผาน CSS 'desktop-nav') -->
<div class="flex desktop-nav items-center gap-4">
<template v-if="!isAuthenticated">
<!-- มเขาสระบบ (Login Button) -->
<NuxtLink
to="/auth/login"
class="btn-secondary-premium shadow-sm"
:class="[isScrolled ? 'border-white/20 text-white hover:bg-white/10' : 'bg-blue-50 border-blue-100 text-blue-600 hover:bg-blue-100 hover:border-blue-200']"
class="px-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' : 'border-white text-grey-9 bg-white hover:bg-blue-100 hover:border-blue-200']"
>
{{ $t('auth.login') }}
</NuxtLink>
<NuxtLink to="/auth/register" class="btn-primary-premium shadow-lg shadow-blue-600/20">
<!-- มสมครสมาช (Register Button) -->
<NuxtLink
to="/auth/register"
class="px-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') }}
</NuxtLink>
</template>
<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') }}
</NuxtLink>
</template>
</div>
<!-- มเปดเมนบนมอถ (แสดงผลเฉพาะบน Mobile) -->
<button
class="md:hidden mobile-toggle ml-auto relative z-[120] w-10 h-10 flex items-center justify-center rounded-full transition-colors"
:class="[isScrolled ? 'text-white hover:bg-white/10' : 'text-slate-900 hover:bg-slate-100', mobileMenuOpen ? 'text-slate-900 z-[120]' : '']"
@click="mobileMenuOpen = !mobileMenuOpen"
>
<q-icon :name="mobileMenuOpen ? 'close' : 'menu'" size="24px" />
</button>
</div>
</header>
<!-- นชกเมนานขางสำหรบมอถ (Mobile Navigation Drawer - แยกสวนไปย body เพอไมใหญหา z-index หรอถกบ) -->
<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>
<style scoped>
/* Header content */
.landing-header {
width: 100%;
z-index: 100;
transition: all 0.3s ease;
}
/* Glassmorphism Effect for Scrolled Header */
/* เอฟเฟกต์ Glassmorphism สำหรับ Header ตอนเลื่อนเมาส์ลง */
.glass-nav {
background: rgba(15, 23, 42, 0.8);
backdrop-filter: blur(12px);
background: rgba(15, 23, 42, 0.95); /* พื้นหลังเข้มขึ้นเพื่อให้อ่านตัวหนังสือชัดเจน */
backdrop-filter: blur(16px);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.container {
max-width: 1440px;
margin: 0 auto;
padding: 0 24px;
}
/* Premium Primary Button Styling */
/* สไตล์ปุ่มหลัก (Primary Button) แบบพรีเมียม */
.btn-primary-premium {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
@ -145,7 +235,7 @@ onMounted(() => {
box-shadow: 0 8px 20px -4px rgba(37, 99, 235, 0.5);
}
/* Secondary Premium Button Styling */
/* สไตล์ปุ่มดรอง (Secondary Button) แบบพรีเมียม */
.btn-secondary-premium {
padding: 0.75rem 1.5rem;
border-radius: 0.75rem;
@ -167,4 +257,21 @@ onMounted(() => {
padding: 0 16px;
}
}
/*
โลจกบงคบการแสดงผล เพอแกญหาการคอมไฟลของ Tailwind
นยนวาสวน Desktop และ Mobile เลยเอาตแยกจากกนอยางชดเจน
*/
.desktop-nav {
display: flex; /* แสดงผลเป็นค่าเริ่มต้น */
}
@media (max-width: 767.98px) {
.desktop-nav {
display: none !important;
}
.mobile-toggle {
display: flex !important;
}
}
</style>

View file

@ -1,11 +1,6 @@
<script setup lang="ts">
const { t } = useI18n()
const navItems = computed(() => [
{ to: '/dashboard', icon: 'dashboard', label: t('sidebar.overview') },
{ to: '/browse/discovery', icon: 'explore', label: t('sidebar.browseCourses') },
{ to: '/dashboard/my-courses', icon: 'school', label: t('sidebar.myCourses') }
])
const { mobileItems } = useNavItems()
const navItems = mobileItems
const handleNavigate = (path: string) => {
if (import.meta.client) {
@ -27,7 +22,7 @@ const handleNavigate = (path: string) => {
:key="item.to"
@click="handleNavigate(item.to)"
:icon="item.icon"
:label="item.label"
:label="$t(item.labelKey)"
no-caps
class="py-2"
:class="{ 'q-tab--active text-primary': $route.path === item.to }"
@ -36,7 +31,7 @@ const handleNavigate = (path: string) => {
</template>
<style scoped>
/* Optional shadow for better separation */
/* เงาด้านบนแบบบางๆ เพื่อแบ่งส่วนล่างให้ชัดเจนขึ้น (Optional shadow for better separation) */
.shadow-up-1 {
box-shadow: 0 -1px 3px rgba(0,0,0,0.05);
}

View file

@ -1,12 +1,13 @@
<script setup lang="ts">
/**
* @file PasswordChangeForm.vue
* @description From for changing user password
* @description ฟอรมสำหรบเปลยนรหสผานของผใช (From for changing user password)
*/
const props = defineProps<{
modelValue: any; // passwordForm (currentPassword, newPassword, confirmPassword)
loading: boolean;
flat?: boolean;
}>();
const emit = defineEmits<{
@ -33,11 +34,15 @@ const showConfirmPassword = ref(false);
</script>
<template>
<div class="card-premium p-8 h-fit">
<h2 class="text-xl font-bold flex items-center gap-3 text-slate-900 dark:text-white mb-6">
<q-icon name="lock" class="text-amber-500 text-2xl" />
<div :class="[!flat ? 'card-premium p-6 md:p-8' : '']" class="h-fit">
<div v-if="!flat" class="flex items-center gap-3 mb-8">
<div class="w-10 h-10 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
<q-icon name="lock" class="text-blue-600 dark:text-blue-400 text-xl" />
</div>
<h2 class="text-xl font-black text-slate-900 dark:text-white">
{{ $t('profile.security') }}
</h2>
</div>
<q-form @submit="emit('submit')" class="flex flex-col gap-6">
<div class="text-sm text-slate-500 dark:text-slate-400 mb-2">
@ -113,8 +118,8 @@ const showConfirmPassword = ref(false);
type="submit"
unelevated
rounded
class="w-full py-3 font-bold text-base shadow-lg shadow-amber-500/20"
style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: white;"
class="w-full py-3 font-bold text-base shadow-lg shadow-blue-500/20"
style="background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white;"
:label="$t('profile.changePasswordBtn')"
:loading="loading"
/>
@ -125,7 +130,12 @@ const showConfirmPassword = ref(false);
<style scoped>
.card-premium {
@apply bg-white dark:bg-[#1e293b] border-slate-200 dark:border-white/5;
background-color: white;
border-color: #e2e8f0;
}
:global(.dark) .card-premium {
background-color: #1e293b;
border-color: rgba(255, 255, 255, 0.05);
border-radius: 1.5rem;
border-width: 1px;
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.05);

View file

@ -1,13 +1,14 @@
<script setup lang="ts">
/**
* @file ProfileEditForm.vue
* @description From for editing user personal information
* @description ฟอรมสำหรบแกไขขอมลสวนตวของผใช (Form for editing user personal information)
*/
const props = defineProps<{
modelValue: any; // userData (firstName, lastName, phone, etc.)
loading: boolean;
verifying?: boolean;
flat?: boolean;
}>();
const emit = defineEmits<{
@ -67,11 +68,15 @@ const onPhoneKeydown = (e: KeyboardEvent) => {
</script>
<template>
<div class="card-premium p-8 h-fit">
<h2 class="text-xl font-bold flex items-center gap-3 text-slate-900 dark:text-white mb-6">
<q-icon name="person" class="text-blue-500 text-2xl" />
<div :class="[!flat ? 'card-premium p-6 md:p-8' : '']" class="h-fit">
<div v-if="!flat" class="flex items-center gap-3 mb-8">
<div class="w-10 h-10 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
<q-icon name="person" class="text-blue-600 dark:text-blue-400 text-xl" />
</div>
<h2 class="text-xl font-black text-slate-900 dark:text-white">
{{ $t('profile.editPersonalDesc') }}
</h2>
</div>
<div class="flex flex-col gap-6">
@ -88,14 +93,14 @@ const onPhoneKeydown = (e: KeyboardEvent) => {
<div class="absolute inset-0 bg-black/40 rounded-2xl flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<q-icon name="camera_alt" class="text-white text-xl" />
</div>
<!-- Hidden Input -->
<!-- องเลอกไฟลกซอนไว (Hidden Input) -->
<input ref="fileInput" type="file" class="hidden" accept="image/*" @change="handleFileUpload" >
</div>
<div class="flex flex-col gap-2">
<div class="font-bold text-slate-900 dark:text-white mb-1">{{ $t('profile.yourAvatar') }}</div>
<!-- Buttons Row -->
<!-- แถวปมกด (Buttons Row) -->
<div class="flex items-center gap-3">
<template v-if="modelValue.photoURL">
<q-btn
@ -119,7 +124,7 @@ const onPhoneKeydown = (e: KeyboardEvent) => {
</template>
</div>
<!-- Add Limit Text -->
<!-- อความจำกดขนาดไฟล (Add Limit Text) -->
<div class="mt-1 text-xs text-slate-500 dark:text-slate-400">
{{ $t('profile.uploadLimit') }}
</div>
@ -243,7 +248,12 @@ const onPhoneKeydown = (e: KeyboardEvent) => {
<style scoped>
.card-premium {
@apply bg-white dark:bg-[#1e293b] border-slate-200 dark:border-white/5;
background-color: white;
border-color: #e2e8f0;
}
:global(.dark) .card-premium {
background-color: #1e293b;
border-color: rgba(255, 255, 255, 0.05);
border-radius: 1.5rem;
border-width: 1px;
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.05);

View file

@ -1,4 +1,8 @@
<script setup lang="ts">
/**
* @file UserAvatar.vue
* @description คอมโพเนนตแสดงรปโปรไฟลใช หากไมปจะแสดงตวอกษรยอของช
*/
const props = defineProps<{
size?: number | string
photoURL?: string
@ -19,7 +23,7 @@ const avatarSize = computed(() => {
const initials = computed(() => {
const getFirstChar = (name?: string) => {
if (!name) return ''
// For Thai names, if the first char is a leading vowel ( ), skip it to get the consonant
// ( )
const leadingVowels = ['เ', 'แ', 'โ', 'ใ', 'ไ']
if (leadingVowels.includes(name.charAt(0)) && name.length > 1) {
return name.charAt(1)
@ -36,7 +40,7 @@ const handleImageError = () => {
imageError.value = true
}
// Watch for photoURL changes to reset error state
// (Watch for photoURL changes to reset error state)
watch(() => props.photoURL, () => {
imageError.value = false
})

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
/**
* @file UserMenu.vue
* @description User profile dropdown menu component using Quasar.
* @description คอมโพเนนตเมน Dropdown ของโปรไฟลใช ใช Quasar
*/
import { ref, computed, onMounted } from 'vue'
@ -12,7 +12,7 @@ const { currentUser, logout } = useAuth()
const { t } = useI18n()
const $q = useQuasar()
// Use centralized theme management
// (Use centralized theme management)
const { isDark, set } = useThemeMode()
const isHydrated = ref(false)
@ -21,7 +21,7 @@ onMounted(() => {
isHydrated.value = true
})
// User Initials
// (User Initials)
const userInitials = computed(() => {
if (!currentUser.value) return ''
const f = currentUser.value.firstName?.charAt(0).toUpperCase() || 'U'
@ -29,12 +29,9 @@ const userInitials = computed(() => {
return f + l
})
const menuItems = computed(() => [
{ label: t('userMenu.home'), to: '/dashboard' },
{ label: t('userMenu.courseList'), to: '/browse/discovery' },
{ label: t('userMenu.myCourses'), to: '/dashboard/my-courses' },
{ label: t('userMenu.settings'), to: '/dashboard/profile' }
])
const { userMenuItems } = useNavItems()
const menuItems = userMenuItems
const handleLogout = async () => {
await logout()
@ -63,14 +60,14 @@ const handleLogout = async () => {
<q-list class="py-2">
<q-item
v-for="item in menuItems"
:key="item.label"
:key="item.labelKey"
clickable
v-close-popup
@click="navigateTo(item.to)"
class="hover:bg-slate-100 dark:hover:bg-white/5 transition-colors"
>
<q-item-section>
<q-item-label class="font-bold text-sm text-slate-800 dark:text-slate-100">{{ item.label }}</q-item-label>
<q-item-label class="font-bold text-sm text-slate-800 dark:text-slate-100">{{ $t(item.labelKey) }}</q-item-label>
</q-item-section>
</q-item>

View file

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

View file

@ -1,18 +1,21 @@
// Interface สำหรับข้อมูลหมวดหมู่ (Category)
/**
* @interface Category
* @description (Category)
*/
export interface Category {
id: number
name: {
name: { // ชื่อหมวดหมู่รองรับ 2 ภาษา
th: string
en: string
[key: string]: string
}
slug: string
description: {
slug: string // Slug สำหรับใช้งานบน URL
description: { // รายละเอียดหมวดหมู่
th: string
en: string
[key: string]: string
}
icon: string
icon: string // ไอคอนของหมวดหมู่อ้างอิงจาก Material Icons
sort_order: number
is_active: boolean
created_at: string
@ -20,7 +23,7 @@ export interface Category {
}
export interface CategoryData {
total: number
total: number // จำนวนหมวดหมู่ทั้งหมด
categories: Category[]
}
@ -30,18 +33,24 @@ interface CategoryResponse {
data: CategoryData
}
// Composable สำหรับจัดการข้อมูลหมวดหมู่
/**
* @composable useCategory
* @description (Categories) Cache ()
*/
export const useCategory = () => {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBase as string
const { token } = useAuth()
// ใช้ useState เพื่อเก็บ Cached Data ระดับ Global (แชร์กันทุก Component)
// เก็บ Cache การดึงข้อมูลหมวดหมู่ในระดับ Global (ใช้ข้าม Component ได้โดยไม่ต้องโหลดใหม่)
const categoriesState = useState<Category[]>('categories_cache', () => [])
const isLoaded = useState<boolean>('categories_loaded', () => false)
// ฟังก์ชันดึงข้อมูลหมวดหมู่ทั้งหมด
// Endpoint: GET /categories
/**
* @function fetchCategories
* @description API (GET /categories)
* State (forceRefresh)
*/
const fetchCategories = async (forceRefresh = false) => {
// ถ้ามีข้อมูลอยู่แล้วและไม่สั่งบังคับโหลดใหม่ ให้ใช้ข้อมูลเดิมทันที
if (isLoaded.value && !forceRefresh && categoriesState.value.length > 0) {
@ -62,7 +71,7 @@ export const useCategory = () => {
const categories = response.data?.categories || []
// เก็บข้อมูลลง State
// บันทึกรายการหมวดหมู่ลง State Cache
categoriesState.value = categories
isLoaded.value = true
@ -74,9 +83,9 @@ export const useCategory = () => {
} catch (err: any) {
console.error('Fetch categories failed:', err)
// Retry logic for 429 Too Many Requests
// กรณีเกิด Error 429 ระบบจะทำการหน่วงเวลา (1.5 วิ) และลองโหลดข้อมูลใหม่อีก 1 ครั้ง (Retry)
if (err.statusCode === 429 || err.status === 429) {
await new Promise(resolve => setTimeout(resolve, 1500)); // Wait 1.5s
await new Promise(resolve => setTimeout(resolve, 1500)); // หน่วงเวลา 1.5 วินาที
try {
const retryRes = await $fetch<CategoryResponse>(`${API_BASE_URL}/categories`, {
method: 'GET',

View file

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

View file

@ -1,3 +1,7 @@
/**
* @interface ValidationRule
* @description ( , , )
*/
export interface ValidationRule {
required?: boolean
minLength?: number
@ -8,10 +12,18 @@ export interface ValidationRule {
custom?: (value: string) => string | null
}
/**
* @interface FieldErrors
* @description (Key )
*/
export interface FieldErrors {
[key: string]: string
}
/**
* @composable useFormValidation
* @description (Validate)
*/
export function useFormValidation() {
const errors = ref<FieldErrors>({})
@ -52,6 +64,7 @@ export function useFormValidation() {
return null
}
// ฟังก์ชันหลักที่เอาแบบฟอร์ม (formData) มาตรวจกับเช็คลิสต์ทั้งหมด (validationRules)
const validate = (
formData: Record<string, string>,
validationRules: Record<string, { rules: ValidationRule; label: string; messages?: Record<string, string> }>
@ -67,14 +80,18 @@ export function useFormValidation() {
}
}
// บันทึกข้อผิดพลาดที่พบทั้งหมดลงใน State
errors.value = newErrors
// คืนค่าบอกว่า "ฟอร์มนี้ผ่านทั้งหมดไหม" (true คือผ่านหมด)
return isValid
}
// ฟังก์ชันลบข้อผิดพลาดทิ้งทั้งหมด (สำหรับตอนเริ่มกรอกใหม่)
const clearErrors = () => {
errors.value = {}
}
// ฟังก์ชันลบข้อผิดพลาดเฉพาะฟิลด์ที่กำหนด
const clearFieldError = (field: string) => {
if (errors.value[field]) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete

View file

@ -1,22 +1,27 @@
/**
* @composable useMediaPrefs
* @description (Mute) /
* <video>
*/
export const useMediaPrefs = () => {
// 1. Global State
// ใช้ useState เพื่อแชร์ค่าเดียวกันทั่วทั้ง App (เช่น เปลี่ยนหน้าแล้วเสียงยังเท่าเดิม)
// 1. สถานะส่วนกลาง (Global State)
// ใช้ useState เพื่อแชร์ค่าเดียวกันทั่วหน้าเว็บ (เช่น เปลี่ยนหน้าแล้วระดับเสียงยังคงที่)
const volume = useState<number>('media_prefs_volume', () => 1)
const muted = useState<boolean>('media_prefs_muted', () => false)
const { user } = useAuth()
// 2. Storage Key Helper (User Specific)
// 2. ฟังก์ชันช่วยสร้าง Key สำหรับ Storage (เก็บแยกตาม User)
const getStorageKey = () => {
const userId = user.value?.id || 'guest'
return `media:prefs:v1:${userId}`
}
// 3. Save Logic (Throttled)
// 3. ระบบบันทึกการตั้งค่าลงเบราว์เซอร์ (Throttled เพื่อไม่ให้บันทึกถี่เกินไป)
let saveTimeout: ReturnType<typeof setTimeout> | null = null
const save = () => {
if (import.meta.server) return
if (import.meta.server) return // เลี่ยงไม่ได้ต้องทำงานบนฝั่ง Client เท่านั้น
if (saveTimeout) clearTimeout(saveTimeout)
saveTimeout = setTimeout(() => {
@ -29,12 +34,12 @@ export const useMediaPrefs = () => {
}
localStorage.setItem(key, JSON.stringify(data))
} catch (e) {
console.error('Failed to save media prefs', e)
console.error('ไม่สามารถบันทึกการตั้งค่าสื่อได้', e)
}
}, 500) // Throttle 500ms
}, 500) // หน่วงเวลา 500ms
}
// 4. Load Logic
// 4. ระบบโหลดการตั้งค่าเก่าขึ้นมา (Load Logic)
const load = () => {
if (import.meta.server) return
@ -51,20 +56,20 @@ export const useMediaPrefs = () => {
}
}
} catch (e) {
console.error('Failed to load media prefs', e)
console.error('ไม่สามารถโหลดการตั้งค่าสื่อได้', e)
}
}
// 5. Setters (With Logic)
// 5. ฟังก์ชันสำหรับอัปเดตและสั่งบันทึกการตั้งค่า (Setters)
const setVolume = (val: number) => {
const clamped = Math.max(0, Math.min(1, val))
volume.value = clamped
// Auto unmute if volume increased from 0
// ยกเลิกปิดเสียงอัตโนมัติ ถ้าระดับเสียงเพิ่มขึ้นจาก 0
if (clamped > 0 && muted.value) {
muted.value = false
}
// Auto mute if volume set to 0
// ปิดเสียงอัตโนมัติ ถ้าระดับเสียงกลายเป็น 0
if (clamped === 0 && !muted.value) {
muted.value = true
}
@ -75,7 +80,7 @@ export const useMediaPrefs = () => {
const setMuted = (val: boolean) => {
muted.value = val
// Logic: Unmuting should restore volume if it was 0
// หากผู้ใช้กดยกเลิกการปิดเสียงขณะที่ระดับเสียงเคยเป็น 0 ควรตั้งค่าเริ่มต้นให้เป็น 1
if (!val && volume.value === 0) {
volume.value = 1
}
@ -83,15 +88,15 @@ export const useMediaPrefs = () => {
save()
}
// 6. Apply & Bind to Element (The Magic)
// 6. ฟังก์ชันจับคู่ใช้กับการเล่นสื่อ (ตย. <video ref="videoEl"> -> applyTo(videoEl.value))
const applyTo = (el: HTMLMediaElement | null | undefined) => {
if (!el) return () => {}
// Initial Apply
// ใส่ค่าตั้งต้นให้กับออบเจ็กต์สื่อ
el.volume = volume.value
el.muted = muted.value
// A. Watch State -> Update Element
// A. สังเกตการเปลี่ยนแปลงจาก State -> เพื่อส่งไปอัปเดต Element สื่อ
const stopVolWatch = watch(volume, (v) => {
if (Math.abs(el.volume - v) > 0.01) el.volume = v
})
@ -99,9 +104,9 @@ export const useMediaPrefs = () => {
if (el.muted !== m) el.muted = m
})
// B. Listen Element -> Update State (e.g. Native Controls)
// B. สังเกตการเปลี่ยนแปลงจาก Element (เช่น ผู้ใช้กดปุ่มเร่งเสียงในวิดีโอตรงๆ) -> เพื่อเอาค่ามาอัปเดต State
const onVolumeChange = () => {
// Update state only if diff allows (prevent loop)
// อัปเดตเฉพาะเมื่อมีความแตกต่างเพื่อหลีกเลี่ยง Loop อนันต์
if (Math.abs(el.volume - volume.value) > 0.01) {
volume.value = el.volume
save()
@ -113,7 +118,7 @@ export const useMediaPrefs = () => {
}
el.addEventListener('volumechange', onVolumeChange)
// Cleanup function
// ฟังก์ชันล้างค่าเพื่อเลิกติดตาม (Cleanup แบบส่งกลับ (Return))
return () => {
stopVolWatch()
stopMutedWatch()
@ -121,11 +126,11 @@ export const useMediaPrefs = () => {
}
}
// 7. Lifecycle & Sync
// 7. จังหวะวงจรชีวิตตอนโหลดเสร็จและระบบ Sync
if (import.meta.client) {
onMounted(() => {
load()
// Cross-tab sync
// ระบบ Sync กับแท็บหรือหน้าต่างเดียวกันหากถูกเปิดไว้
window.addEventListener('storage', (e) => {
if (e.key === getStorageKey()) {
load()

View file

@ -0,0 +1,79 @@
/**
* @file useNavItems.ts
* @description (Navigation Items)
* ( , , )
*/
export interface NavItem {
to: string // ลิงก์ปลายทาง
labelKey: string // คีย์ภาษาสำหรับ i18n
icon: string // ไอคอนจาก Material Icons
showOn: ('sidebar' | 'mobile' | 'userMenu')[] // กำหนดให้โชว์ที่ส่วนไหนบ้าง
roles?: string[] // กำหนดสิทธิ์ผู้ใช้ที่จะเห็น (ถ้ามี)
}
export const useNavItems = () => {
// เมนูทั้งหมดในระบบ กำหนดไว้ที่เดียว
const allNavItems: NavItem[] = [
{
to: '/dashboard',
labelKey: 'sidebar.overview',
icon: 'dashboard',
showOn: ['sidebar', 'mobile', 'userMenu']
},
{
to: '/browse',
labelKey: 'sidebar.onlineCourses',
icon: 'video_library',
showOn: ['mobile']
},
{
to: '/browse/discovery',
labelKey: 'sidebar.recommendedCourses',
icon: 'auto_awesome',
showOn: ['mobile']
},
{
to: '/browse/discovery',
labelKey: 'sidebar.browseCourses',
icon: 'explore',
showOn: ['sidebar', 'mobile', 'userMenu']
},
{
to: '/dashboard/my-courses',
labelKey: 'sidebar.myCourses',
icon: 'school',
showOn: ['sidebar', 'mobile', 'userMenu']
},
{
to: '/dashboard/announcements',
labelKey: 'sidebar.announcements',
icon: 'campaign',
showOn: ['mobile']
},
{
to: '/dashboard/profile',
labelKey: 'sidebar.profile',
icon: 'person',
showOn: [] // Was ['sidebar']
},
{
to: '/dashboard/profile',
labelKey: 'userMenu.settings',
icon: 'settings',
showOn: ['userMenu']
}
]
// คัดกรองเมนูที่จะเอาไปแสดงแต่ละตำแหน่ง
const sidebarItems = computed(() => allNavItems.filter(item => item.showOn.includes('sidebar')))
const mobileItems = computed(() => allNavItems.filter(item => item.showOn.includes('mobile')))
const userMenuItems = computed(() => allNavItems.filter(item => item.showOn.includes('userMenu')))
return {
allNavItems,
sidebarItems,
mobileItems,
userMenuItems
}
}

View file

@ -19,95 +19,107 @@ export interface AnswerState {
/**
* @composable useQuizRunner
* @description Manages the state and logic for running a quiz activity.
* @description State () Logic (Quiz)
* , ,
*/
export const useQuizRunner = () => {
// State
const questions = useState<QuizQuestion[]>('quiz-questions', () => []);
const answers = useState<Record<number, AnswerState>>('quiz-answers', () => ({}));
const currentQuestionIndex = useState<number>('quiz-current-index', () => 0);
const loading = useState<boolean>('quiz-loading', () => false);
const lastError = useState<string | null>('quiz-error', () => null);
// ================= State (สถานะเก็บค่าต่างๆ ของข้อสอบ) =================
const questions = useState<QuizQuestion[]>('quiz-questions', () => []); // เก็บรายการคำถามทั้งหมด
const answers = useState<Record<number, AnswerState>>('quiz-answers', () => ({})); // เก็บคำตอบที่ผู้ใช้ตอบ แยกตาม ID คำถาม
const currentQuestionIndex = useState<number>('quiz-current-index', () => 0); // ลำดับคำถามที่กำลังทำอยู่ปัจจุบัน (เริ่มที่ 0)
const loading = useState<boolean>('quiz-loading', () => false); // สถานะตอนกำลังกดเซฟหรือโหลดข้อมูล
const lastError = useState<string | null>('quiz-error', () => null); // เก็บข้อความแจ้งเตือนข้อผิดพลาดล่าสุด
// Getters
const currentQuestion = computed(() => questions.value[currentQuestionIndex.value]);
// ================= Getters (ดึงค่าที่ถูกประมวลผลแล้ว) =================
const currentQuestion = computed(() => questions.value[currentQuestionIndex.value]); // ดึงคำถามข้อปัจจุบัน
const currentAnswer = computed(() => {
const currentAnswer = computed(() => { // ดึงคำตอบในข้อปัจจุบัน
if (!currentQuestion.value) return null;
return answers.value[currentQuestion.value.id];
});
const totalQuestions = computed(() => questions.value.length);
const isLastQuestion = computed(() => currentQuestionIndex.value === questions.value.length - 1);
const isFirstQuestion = computed(() => currentQuestionIndex.value === 0);
const totalQuestions = computed(() => questions.value.length); // จำนวนคำถามทั้งหมดในแบบทดสอบ
const isLastQuestion = computed(() => currentQuestionIndex.value === questions.value.length - 1); // เช็คว่าใช่คำถามข้อสุดท้ายหรือไม่
const isFirstQuestion = computed(() => currentQuestionIndex.value === 0); // เช็คว่าใช่คำถามข้อแรกหรือไม่
// Actions
// ================= Actions (ฟังก์ชันหลักสำหรับการทำงาน) =================
// ฟังก์ชันเริ่มต้นสร้าง/โหลดข้อสอบ (กำหนดโครงสร้างพื้นฐาน)
function initQuiz(quizData: any) {
if (!quizData || !quizData.questions) return;
questions.value = quizData.questions;
currentQuestionIndex.value = 0;
currentQuestionIndex.value = 0; // รีเซ็ตไปที่ข้อ 1 ใหม่
answers.value = {};
lastError.value = null;
// เตรียมโครงสร้างคำตอบรองรับทุกข้อ
questions.value.forEach(q => {
answers.value[q.id] = {
questionId: q.id,
value: null,
is_saved: false,
status: 'not_started',
touched: false,
is_saved: false, // บันทึกและส่ง API เรียบร้อยหรือยัง
status: 'not_started', // สถานะเริ่มต้นของคำถาม
touched: false, // ผู้ใช้เคยเปิดเข้ามาดูข้อนีัหรือยัง
};
});
// เริ่มต้นบันทึกเวลา/เข้าสู่ข้อที่ 1 ทันทีเมื่ออธิบายเสร็จ
if (questions.value.length > 0) {
enterQuestion(questions.value[0].id);
}
}
// ฟังก์ชันสลับสถานะเมื่อกดเข้ามาที่คำถามนั้นๆ
function enterQuestion(qId: number) {
const ans = answers.value[qId];
if (ans) {
ans.touched = true;
if (ans.status === 'not_started' || ans.status === 'skipped') {
ans.status = 'in_progress';
ans.status = 'in_progress'; // เปลี่ยนสถานะเป็น 'กำลังทำ'
}
}
}
// ตรวจสอบเงื่อนไขว่าผู้ใช้สามารถออกจากคำถามปัจจุบันไปยังข้ออื่นได้หรือไม่
function canLeaveCurrent(): { allowed: boolean; reason?: string } {
if (!currentQuestion.value) return { allowed: true };
const q = currentQuestion.value;
const a = answers.value[q.id];
// สามารถออกได้ถ้าคำถามทำถูกหรือโจทย์อนุญาตให้ข้ามได้
if (a.status === 'completed' || a.is_saved) return { allowed: true };
if (q.is_skippable) return { allowed: true };
// บังคับให้ตอบถ้าไม่ได้อนุญาตให้ข้าม และไม่ได้ตอบ
if (!a.is_saved && a.value === null) {
return { allowed: false, reason: 'This question is required.' };
return { allowed: false, reason: 'ต้องการคำตอบสำหรับข้อบังคับนี้' };
}
return { allowed: true };
}
// ฟังก์ชันอัปเดตค่าตัวเลือกที่ผู้ใช้กดเลือกในข้อปัจจุบัน
function updateAnswer(val: any) {
if (!currentQuestion.value) return;
const qId = currentQuestion.value.id;
answers.value[qId].value = val;
// หากมีแก้ไขคำตอบหลังจากกดเซฟไปแล้ว ให้เปลี่ยนสถานะให้ระบบรู้ว่าต้องเซฟใหม่
if (answers.value[qId].is_saved) {
answers.value[qId].is_saved = false;
answers.value[qId].status = 'in_progress';
}
}
// ล็อกและบันทึกข้อสอบเมื่อกดปุ่ม "ตกลง/ส่งคำตอบ" สำหรับข้อนั้นๆ
async function saveCurrentAnswer() {
if (!currentQuestion.value) return;
const qId = currentQuestion.value.id;
const ans = answers.value[qId];
if (ans.value === null) {
lastError.value = "Please provide an answer.";
lastError.value = "กรุณาเลือกคำตอบอย่างน้อย 1 ตัวเลือก";
return false;
}
@ -115,30 +127,34 @@ export const useQuizRunner = () => {
lastError.value = null;
try {
// หมายเหตุ: การเชื่อมต่อ API หลักต้องทำที่ไฟล์ component, ตัวนี้จัดการแค่เรื่อง State
ans.is_saved = true;
ans.status = 'completed';
ans.status = 'completed'; // มาร์คว่าเป็นข้อที่ทำเสร็จแล้ว
ans.last_saved_at = new Date().toISOString();
return true;
} catch (e) {
lastError.value = "Failed to save answer.";
lastError.value = "เกิดข้อผิดพลาดในการบันทึกคำตอบ";
return false;
} finally {
loading.value = false;
}
}
// วินิจฉัยก่อนสั่งผู้ใช้ย้ายไปยังคำถามอื่นหน้าอื่นตามดัชนีระบุ
function handleLeaveLogic(targetIndex: number) {
if (targetIndex === currentQuestionIndex.value) return;
// ตรวจสอบขั้นสุดท้าย ป้องกันคนคลิกแอบหนีข้อที่บังคับทำ
const check = canLeaveCurrent();
if (!check.allowed) {
lastError.value = check.reason || "Required question.";
lastError.value = check.reason || "จำเป็นต้องตอบข้อนี้ก่อนข้าม";
return false;
}
const currQ = currentQuestion.value;
if (currQ) {
const currAns = answers.value[currQ.id];
// หากผู้ใช้ทิ้งขว้างโดยที่ไม่บังคับ ให้ทิ้งสถานะเป็นข้าม ('skipped')
if (currAns.status !== 'completed' && !currAns.is_saved) {
currAns.status = 'skipped';
}
@ -147,6 +163,7 @@ export const useQuizRunner = () => {
currentQuestionIndex.value = targetIndex;
lastError.value = null;
// ติดตามสถานะ 'touched' ในข้อใหม่ที่เข้าไปล่าสุด
if (questions.value[targetIndex]) {
enterQuestion(questions.value[targetIndex].id);
}

View file

@ -1,26 +1,41 @@
import { useQuasar } from 'quasar'
/**
* @composable useThemeMode
* @description / (Light/Dark Theme)
* , Tailwind Sync Quasar UI
*/
export const useThemeMode = () => {
const $q = useQuasar()
// deterministic on SSR: default = light
// สถานะเริ่มต้นของโหมดมืด (สำหรับการทำ SSR ถูกเซ็ตเป็น false (สว่าง) ไว้ก่อน)
const isDark = useState<boolean>('theme:isDark', () => false)
// ฟังก์ชันใช้คลาสกับ Tag <html> เพื่อให้ Tailwind หรือ CSS รันโหมดมืด
const applyTheme = (value: boolean) => {
if (!process.client) return
if (!process.client) return // หากทำงานฝั่งเซิร์ฟเวอร์ จะไม่สั่งให้รัน DOM
// สลับคลาส 'dark' หรือปิด
document.documentElement.classList.toggle('dark', value)
// สั่งให้ Quasar (UI Framework) ปรับโหมดสีให้ตรงกัน (มืด/สว่าง)
$q.dark.set(value)
// บันทึกการตั้งค่าลงเครื่องระยะยาวเบราว์เซอร์
localStorage.setItem('theme', value ? 'dark' : 'light')
}
// จับตาดูเมื่อตัวแปรเปลี่ยนค่าค่อยทำการเปลี่ยนโหมดบนหน้าจอ
watch(isDark, (v) => applyTheme(v))
// ฟังก์ชันสั่งสลับโหมด ไปมา (Toggle)
const toggle = () => {
isDark.value = !isDark.value
}
// ฟังก์ชันสำหรับกำหนดตั้งค่าโหมดแบบเจาะจง
const set = (v: boolean) => {
isDark.value = v
}

View file

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

View file

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

View file

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

View file

@ -19,7 +19,7 @@ onMounted(() => {
</script>
<template>
<!-- Auth Shell: Wrapper for authentication pages (Login, Register, etc.) -->
<!-- Auth Shell: ครอบคลมการแสดงผลหนาสำหรบเขาสระบบหรอสมครสมาช -->
<div class="auth-shell bg-white w-full min-h-screen">
<slot />
</div>

View file

@ -0,0 +1,165 @@
<script setup lang="ts">
/**
* @file dashboard-index.vue
* @description เลยเอาตสำหรบหนาแรกของ Dashboard (ไมแผงเมนานขางเพอเนนพนทเนอหา)
* ใชโครงสรางจาก Quasar QLayout เพอใหรองร Responsive
*/
// Global
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>
<!-- แถบลนชกมอถอหล (เมนรวมทกอยางเมอปดหนาจอ/กดไอคอนบนสดสวนเล) -->
<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) ไซนพรเมยม -->
<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" />
<!-- เครองมอทวไปและการตงคาระบบ -->
<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 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. วนลางส เช อกเอาต หรอระบเวอรนระบบ -->
<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 เปนลนชกซาย -->
<!-- นทแสดงเนอหาหล -->
<q-page-container>
<q-page class="relative">
<slot />
</q-page>
</q-page-container>
<!-- เมนมกดลางหนาจอบนมอถ (สำหรบชวยใหเขาถงหนาหลกไดไวข) -->
<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,61 +1,66 @@
<script setup lang="ts">
/**
* @file default.vue
* @description Layout หลกสำหรบหนาเวบของผใช (Authenticated Users)
* Uses Quasar QLayout for responsive structure.
* @description เลยเอาตหล (Master Layout) สำหรบผใชงานทเขาสระบบแล
* ประกอบดวยแถบเมนานบน (Header), แถบเมนานขาง (Sidebar) และพนทเนอหา
*/
// Initialize global theme management
useThemeMode()
const leftDrawerOpen = ref(false)
const leftDrawerOpen = ref(true)
const toggleLeftDrawer = () => {
leftDrawerOpen.value = !leftDrawerOpen.value
}
const route = useRoute()
// path (Sidebar)
const isDashboardRoute = computed(() => {
const routes = ['/dashboard', '/browse', '/classroom', '/course']
return routes.some(r => route.path.startsWith(r))
})
</script>
<template>
<q-layout view="hHh LpR lFf" class="bg-slate-50 dark:!bg-[#020617] text-slate-900 dark:!text-slate-50 font-sans">
<!-- Header -->
<q-layout view="lHh Lpr lFf" class="bg-[#F8FAFC] dark:!bg-[#020617] text-slate-900 dark:!text-slate-50">
<!-- วนห (Header) -->
<q-header
bordered
class="bg-white/80 dark:!bg-[#0f172a]/80 backdrop-blur-md text-slate-900 dark:!text-white border-b border-slate-200 dark:border-slate-800"
class="bg-transparent text-slate-900 dark:!text-white border-none shadow-none"
>
<AppHeader @toggleSidebar="toggleLeftDrawer" />
</q-header>
<!-- Sidebar (Drawer) -->
<!-- แถบเมนานขาง (Navigation Sidebar) -->
<q-drawer
v-model="leftDrawerOpen"
show-if-above
bordered
:width="280"
class="bg-white dark:!bg-[#0f172a] border-r border-slate-200 dark:border-slate-800"
side="left"
bordered
class="bg-white dark:!bg-[#0f172a] border-none"
>
<AppSidebar />
</q-drawer>
<!-- Main Content -->
<!-- นทแสดงเนอหาหล (Main Content Area) -->
<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 />
</div>
</q-page>
</q-page-container>
<!-- Mobile Bottom Nav -->
<q-footer
v-if="$q.screen.lt.md"
bordered
class="!bg-white dark:!bg-[#1e293b] text-primary border-t border-slate-200 dark:border-slate-700"
>
<MobileNav />
</q-footer>
</q-layout>
</template>
<style>
/* Ensure fonts are applied */
.font-inter {
font-family: 'Inter', sans-serif;
/* Global Layout Adjustments */
.q-drawer--bordered {
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>

View file

@ -20,28 +20,25 @@ onMounted(() => {
<template>
<q-layout view="lHh LpR lFf" class="bg-white text-slate-900 font-inter">
<q-layout view="lHh LpR lFf" class="bg-white text-slate-900">
<!-- Header (Transparent & Overlay) -->
<!-- วนหวของเพจ (แบบโปรงใส และซอนทบใหโชวเนอหาพนหลงได) -->
<q-header class="bg-transparent" style="height: auto;">
<LandingHeader />
</q-header>
<!-- Main Content -->
<!-- padding-top: 0 forces content to go under the header (Hero effect) -->
<!-- วนเนอหาหล -->
<!-- แทรก style padding-top: 0 งคบใหเนอหาใต Header ชนชดดานบนส (ทำเป Hero section สวยๆ) -->
<q-page-container style="padding-top: 0 !important;">
<q-page>
<slot />
</q-page>
</q-page-container>
<!-- วนทายของเพจ -->
<LandingFooter />
</q-layout>
</template>
<style>
.font-inter {
font-family: 'Inter', sans-serif;
}
</style>

View file

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

File diff suppressed because it is too large Load diff

View file

@ -19,9 +19,14 @@
},
"devDependencies": {
"@nuxt/eslint-config": "^1.12.1",
"@nuxt/test-utils": "^4.0.0",
"@nuxtjs/i18n": "^10.2.1",
"@playwright/test": "^1.58.2",
"@types/node": "^22.19.8",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.39.2",
"typescript": "^5.4.5"
"jsdom": "^28.1.0",
"typescript": "^5.4.5",
"vitest": "^4.0.18"
}
}

View file

@ -23,7 +23,7 @@ const isLoading = ref(false)
const rememberMe = ref(false)
const showPassword = ref(false)
// Form data model
//
const loginForm = reactive({
email: '',
password: ''
@ -31,7 +31,7 @@ const loginForm = reactive({
type LoginField = keyof typeof loginForm
// Validation rules definition
// (Validation Rules)
// (Validation Rules)
const loginRules = {
email: {
@ -108,12 +108,12 @@ const handleLogin = async () => {
}
// Show error on specific fields
// Show generic error for security (or specific if role mismatch)
// ()
//
if (result.error === 'Email ไม่ถูกต้อง') {
errors.value.email = result.error // Role mismatch case
errors.value.email = result.error // Role
} else {
// Generic login failure (401, 404, etc.)
// ( , )
const msg = 'กรุณาเช็ค Email หรือ รหัสผ่านใหม่อีกครั้ง'
errors.value.email = msg
errors.value.password = msg
@ -147,7 +147,7 @@ onMounted(() => {
========================================== -->
<div class="w-full max-w-[460px] relative z-10 slide-up">
<!-- Header / Logo -->
<!-- วนหวโปรไฟล / โลโก (Header / Logo) -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-gradient-to-tr from-blue-600 to-indigo-600 text-white shadow-lg shadow-blue-600/20 mb-6">
<span class="font-black text-2xl">E</span>
@ -158,10 +158,10 @@ onMounted(() => {
<div class="bg-white rounded-[2rem] p-8 md:p-10 shadow-xl shadow-slate-200/50 border border-slate-100 relative overflow-hidden">
<!-- Form -->
<!-- ฟอรมเขาสระบบ (Login Form) -->
<form @submit.prevent="handleLogin" class="flex flex-col gap-5">
<!-- Email Input -->
<!-- องกรอกอเมล (Email Input) -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-2 ml-1">เมล</label>
<div class="relative group">
@ -180,7 +180,7 @@ onMounted(() => {
<span v-if="errors.email" class="text-xs text-red-500 font-medium ml-1 mt-1 block slide-up-sm">{{ errors.email }}</span>
</div>
<!-- Password Input -->
<!-- องกรอกรหสผาน (Password Input) -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-2 ml-1">รหสผาน</label>
<div class="relative group">
@ -206,7 +206,7 @@ onMounted(() => {
<span v-if="errors.password" class="text-xs text-red-500 font-medium ml-1 mt-1 block slide-up-sm">{{ errors.password }}</span>
</div>
<!-- Options -->
<!-- วเลอกเพมเต (จดจำฉ, มรหสผาน) (Options) -->
<div class="flex items-center justify-between mt-1">
<label class="flex items-center gap-2.5 cursor-pointer group select-none">
<div class="relative flex items-center">
@ -227,7 +227,7 @@ onMounted(() => {
</NuxtLink>
</div>
<!-- Submit Button -->
<!-- มยนยนเขาสระบบ (Submit Button) -->
<button
type="submit"
:disabled="isLoading"
@ -237,17 +237,24 @@ onMounted(() => {
<div v-else class="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
</button>
</form>
<!-- Divider -->
<div class="my-8 flex items-center gap-4">
<div class="h-px bg-slate-200 flex-1"></div>
<span class="text-slate-400 text-xs font-medium uppercase tracking-wider">หร</span>
<div class="h-px bg-slate-200 flex-1"></div>
<!-- กลองแนะนำบญชสำหรบทดสอบ (Test Credentials Box) -->
<div class="mt-4 p-5 bg-blue-50/50 border border-blue-100 rounded-2xl flex flex-col items-center gap-2 animate-fade-in">
<div class="text-[11px] font-black uppercase tracking-[0.2em] text-blue-600 mb-1">ญชสำหรบทดสอบ (Test Account)</div>
<div class="flex flex-col items-center gap-1">
<div class="text-base font-black text-slate-900 select-all cursor-copy hover:text-blue-600 transition-colors">
studentedtest@example.com
</div>
<div class="flex items-center gap-2">
<span class="text-[11px] font-black uppercase tracking-wider text-slate-600">Password:</span>
<span class="text-base font-black select-all cursor-copy hover:text-blue-600 transition-colors text-slate-900">admin123</span>
</div>
</div>
</div>
<!-- Register Link -->
<div class="text-center">
</form>
<!-- งกสำหรบสมครสมาชกใหม (Register Link) -->
<div class="text-center mt-8">
<p class="text-slate-600 text-sm">
งไมญชสมาช?
<NuxtLink to="/auth/register" class="font-bold text-blue-600 hover:text-blue-700 transition-colors ml-1">
@ -258,7 +265,7 @@ onMounted(() => {
</div>
<!-- Back Link -->
<!-- งกอนกล (Back Link) -->
<div class="mt-8 text-center text-slate-500">
<NuxtLink to="/" class="inline-flex items-center gap-2 text-sm font-medium hover:text-slate-800 transition-colors group px-4 py-2 rounded-lg hover:bg-white/50">
<span class="group-hover:-translate-x-1 transition-transform"></span> กลบไปหนาแรก
@ -269,7 +276,7 @@ onMounted(() => {
</template>
<style scoped>
/* Animations */
/* เอฟเฟกต์การเคลื่อนไหว (Animations) */
@keyframes pulse-slow {
0%, 100% { opacity: 0.3; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.15); }

View file

@ -1,9 +1,7 @@
<script setup lang="ts">
/**
* @file discovery.vue
* @description Course Discovery / Catalog Page.
* Allows users to browse, filter, and view details of available courses.
* Includes a toggleable detailed view for course previews.
* @description Course Discovery / Catalog Page matching Figma Desktop Layout.
*/
definePageMeta({
@ -15,11 +13,19 @@ useHead({
title: "รายการคอร์ส - e-Learning",
});
// ==========================================
// 1. State Management
const { t, locale } = useI18n();
const { currentUser } = useAuth();
const $q = useQuasar();
const { fetchCategories } = useCategory();
const { fetchCourses, fetchCourseById, enrollCourse, getLocalizedText } = useCourse();
const showDetail = ref(false);
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 courses = ref<any[]>([]);
const selectedCourse = ref<any>(null);
@ -28,46 +34,43 @@ const isLoading = ref(false);
const isLoadingDetail = ref(false);
const isEnrolling = ref(false);
// Pagination State
const currentPage = ref(1);
const totalPages = ref(1);
const itemsPerPage = 12;
const { t, locale } = useI18n();
const { currentUser } = useAuth();
const $q = useQuasar();
const { fetchCategories } = useCategory();
const { fetchCourses, fetchCourseById, enrollCourse, getLocalizedText } = useCourse();
// 2. Computed Properties
const sortOption = ref(t('discovery.sortRecent'));
const sortOptions = computed(() => [t('discovery.sortRecent')]);
const filteredCourses = computed(() => {
let result = courses.value;
// If more than 1 category is selected, we still do client-side filtering
// 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));
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'
}
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
result = result.filter(c => {
const title = getLocalizedText(c.title).toLowerCase();
const desc = getLocalizedText(c.description).toLowerCase();
return title.includes(query) || (desc && desc.includes(query));
});
const formatPrice = (course: any) => {
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;
}
return result;
});
// 3. Helper Functions
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 || 'ผู้สอน';
};
// 4. API Actions
const loadCategories = async () => {
const res = await fetchCategories();
if (res.success) categories.value = res.data || [];
@ -75,19 +78,28 @@ const loadCategories = async () => {
const loadCourses = async (page = 1) => {
isLoading.value = true;
// Use server-side filtering if exactly one category is selected
const categoryId = selectedCategoryIds.value.length === 1 ? selectedCategoryIds.value[0] : undefined;
const categoryId = activeCategory.value === 'all' ? undefined : activeCategory.value as number;
const res = await fetchCourses({
category_id: categoryId,
search: searchQuery.value,
page: page,
limit: itemsPerPage,
forceRefresh: true
forceRefresh: true,
});
if (res.success) {
courses.value = res.data || [];
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;
currentPage.value = res.page || 1;
}
@ -108,194 +120,188 @@ const handleEnroll = async (id: number) => {
isEnrolling.value = true;
const res = await enrollCourse(id);
if (res.success) {
return navigateTo('/dashboard/my-courses?enrolled=true');
return navigateTo("/dashboard/my-courses?enrolled=true");
} else {
$q.notify({
type: 'negative',
message: res.error || t('enrollment.error'),
position: 'top',
type: "negative",
message: res.error || t("enrollment.error"),
position: "top",
timeout: 3000,
actions: [{ icon: 'close', color: 'white' }]
})
actions: [{ icon: "close", color: "white" }],
});
}
isEnrolling.value = false;
};
// Watch for category selection changes to reload courses
watch(selectedCategoryIds, () => {
watch(
activeCategory,
() => {
currentPage.value = 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(async () => {
await loadCategories();
// 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);
}
onMounted(() => {
loadCategories();
loadCourses();
await loadCourses(1);
if (route.query.course_id) {
selectCourse(Number(route.query.course_id));
}
});
</script>
<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">
<!-- Top Header Area -->
<div class="flex flex-col gap-6 mb-10">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
<!-- Title -->
<h1 class="text-3xl font-black text-slate-900 dark:text-white flex items-center gap-3">
<span class="w-1.5 h-8 bg-blue-600 rounded-full shadow-sm shadow-blue-500/50"></span>
{{ $t('discovery.title') }}
</h1>
<!-- Right Side: Search -->
<div class="flex items-center gap-3 w-full md:w-auto">
<q-input
v-model="searchQuery"
dense
outlined
rounded
:placeholder="$t('discovery.searchPlaceholder')"
class="w-full md:w-72 search-input shadow-sm"
bg-color="transparent"
>
<template v-slot:prepend>
<q-icon name="search" class="text-slate-400" />
</template>
</q-input>
<!-- วนหวและการคนหา -->
<div 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>
<div class="flex flex-wrap sm:flex-nowrap items-center gap-3 w-full md:w-auto">
<div class="relative w-full sm:w-[260px] flex-1">
<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]" />
<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="ค้นหาคอร์ส..." />
</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>
<!-- Unified Filter Section: Categories -->
<div class="flex flex-wrap items-center gap-2">
<q-btn
flat
rounded
dense
class="px-4 font-bold transition-all text-xs uppercase tracking-widest"
:class="selectedCategoryIds.length === 0 ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' : 'bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-white/5'"
@click="selectedCategoryIds = []"
:label="$t('discovery.showAll')"
/>
<q-btn
v-for="cat in categories"
:key="cat.id"
flat
rounded
dense
class="px-4 font-bold transition-all text-xs uppercase tracking-widest"
:class="selectedCategoryIds.includes(cat.id) ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' : 'bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-white/5'"
@click="toggleCategory(cat.id)"
:label="getLocalizedText(cat.name)"
/>
<!-- Filters Category -->
<div class="flex flex-col xl:flex-row xl:items-center justify-between gap-4 mb-10 w-full relative">
<!-- Figma Style: Separate pill buttons -->
<div class="flex flex-wrap items-center gap-3 w-full xl:w-auto">
<button
@click="activeCategory = 'all'"
: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="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="activeCategory === 'all' ? 'text-[#3B6BE8]' : 'text-slate-400'"/> งหมด
</button>
<button
v-for="cat in categories" :key="cat.id"
@click="activeCategory = cat.id"
: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'"
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">
<q-icon :name="getCategoryIcon(cat.name)" size="18px" :class="activeCategory === cat.id ? 'text-[#3B6BE8]' : 'text-slate-600 dark:text-slate-400'"/>
{{ getLocalizedText(cat.name) }}
</button>
</div>
</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>
<!-- Main Layout: Grid Only -->
<div class="w-full">
<div v-if="filteredCourses.length > 0" class="flex flex-col gap-12">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
<CourseCard
v-for="course in filteredCourses"
:key="course.id"
v-bind="{ ...course, image: course.thumbnail_url }"
show-view-details
@view-details="selectCourse(course.id)"
/>
<!-- 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">{{ getLocalizedText(course.title) }}</h3>
<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>
</div>
</div>
</div>
</div>
<!-- Pagination Controls -->
<div v-if="totalPages > 1" class="flex justify-center pb-10">
<q-pagination
v-model="currentPage"
:max="totalPages"
:max-pages="6"
boundary-numbers
direction-links
color="primary"
flat
active-design="unelevated"
active-color="primary"
@update:model-value="loadCourses"
/>
<!-- LIST VIEW -->
<div v-else class="flex flex-col gap-5">
<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)">
<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">
<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.5 rounded-full shadow-sm" style="border-radius: 9999px; padding: 4px 12px;">
{{ course.category_name }}
</div>
</div>
<div class="flex flex-col flex-1 py-1">
<div class="flex-1">
<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>
</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>
<!-- Empty State -->
<div
v-else
class="flex flex-col items-center justify-center py-20 bg-white dark:bg-slate-800/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-700 shadow-sm"
>
<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">
<q-icon name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" />
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ $t('discovery.emptyTitle') }}</h3>
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">
{{ $t('discovery.emptyDesc') }}
</p>
<button class="mt-6 font-bold text-blue-600 hover:text-blue-700 dark:hover:text-blue-400 transition-colors" @click="searchQuery = ''; selectedCategoryIds = []">
{{ $t('discovery.showAll') }}
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ $t("discovery.emptyTitle") }}</h3>
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">{{ $t("discovery.emptyDesc") }}</p>
<button class="mt-6 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''; activeCategory = 'all';">
{{ $t("discovery.showAll") }}
</button>
</div>
</div>
</div>
<!-- COURSE DETAIL VIEW: Detailed information about a specific course -->
<div v-else>
<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"
>
<button @click="showDetail = false" class="inline-flex items-center gap-2 text-slate-600 dark:text-white hover:text-blue-600 dark:hover:text-blue-300 mb-6 transition-all font-black text-lg md:text-xl group">
<q-icon name="arrow_back" size="24px" class="transition-transform group-hover:-translate-x-1" />
{{ $t('discovery.backToCatalog') }}
{{ $t("discovery.backToCatalog") }}
</button>
<div v-if="isLoadingDetail" class="flex justify-center py-20">
<q-spinner size="3rem" color="primary" />
<div v-if="isLoadingDetail" class="flex justify-center py-20"><q-spinner size="3rem" color="primary" /></div>
<CourseDetailView v-else-if="selectedCourse" :course="selectedCourse" :user="currentUser" @back="showDetail = false" @enroll="handleEnroll"/>
</div>
<CourseDetailView
v-else-if="selectedCourse"
:course="selectedCourse"
:user="currentUser"
@back="showDetail = false"
@enroll="handleEnroll"
/>
</div>
</div>
</template>
<style scoped>
/* Standard overrides for Quasar inputs to match Tailwind theme */
.search-input :deep(.q-field__control) {
border-radius: 9999px; /* Full rounded pill */
background-color: white !important;
transition: all 0.3s ease;
/* Disable default scrollbar for filter container */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.dark .search-input :deep(.q-field__control) {
background-color: #1e293b !important; /* slate-800: Inner card depth */
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;
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>

View file

@ -1,280 +1,269 @@
<script setup lang="ts">
/**
* @file courses.vue
* @description Page displaying all available courses in a public catalog format.
* Matches the premium dark theme of the landing page.
* @file index.vue
* @description หนาแสดงคอรสเรยนทงหมดในรปแบบแคตตาลอกสาธารณะ
* ไซนปรบใหนสมยเพอดงดดผใชงานใหม
*/
// Define page metadata using the landing layout (dark theme default)
definePageMeta({
layout: 'landing'
})
// Set the HTML head title for SEO
useHead({
title: 'คอร์สทั้งหมด - E-Learning System'
})
// Reactive state for the search input
const searchQuery = ref('')
const { fetchCourses } = useCourse()
const { fetchCategories, categories } = useCategory()
// Helper to handle localized text
const getLocalizedText = (text: string | { th: string; en: string } | undefined) => {
if (!text) return ''
if (typeof text === 'string') return text
return text.th || text.en || ''
}
// Fetch courses from API
const { data: coursesResponse, error } = await useAsyncData('courses-list', () => fetchCourses())
const route = useRoute()
const router = useRouter()
const selectedCategory = ref((route.query.category as string) || 'all')
const selectCategory = (slug: string) => {
if (slug === 'all') {
router.push({ query: { ...route.query, category: 'all' } })
} else {
router.push({ query: { ...route.query, category: slug } })
}
}
watch(() => route.query.category, (newCategory) => {
selectedCategory.value = (newCategory as string) || 'all'
})
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'
}
await useAsyncData('categories-list', () => fetchCategories())
const { data: coursesResponse, pending: isLoading, error, refresh } = await useAsyncData(
'browse-courses-list',
() => {
const params: any = {}
if (selectedCategory.value !== 'all') {
const category = categories.value.find(c => c.slug === selectedCategory.value)
if (category) {
params.category_id = category.id
}
}
return fetchCourses(params)
}
)
watch(selectedCategory, () => { refresh() })
const formatPrice = (course: any) => {
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(() => {
if (coursesResponse.value?.success) {
return coursesResponse.value.data
if (coursesResponse.value?.success && 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 []
})
/**
* @computed filteredCourses
* @description Filters the courses list based on the search query.
* Checks both the course title and description (case-insensitive).
*/
const filteredCourses = computed(() => {
const list = courses.value || []
if (!searchQuery.value) return list
const query = searchQuery.value.toLowerCase()
return list.filter(c => {
return list.filter((c: any) => {
const title = getLocalizedText(c.title).toLowerCase()
const desc = getLocalizedText(c.description).toLowerCase()
return title.includes(query) || desc.includes(query)
})
})
const viewMode = ref<'grid' | 'list'>('grid')
</script>
<template>
<!-- Main Container: Dark Theme Base -->
<div class="relative min-h-screen text-slate-600 bg-slate-50 transition-colors">
<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="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
Ambient glows matching the index.vue theme
========================================== -->
<div class="fixed inset-0 overflow-hidden pointer-events-none -z-10">
<div class="absolute top-[-20%] right-[-10%] w-[60%] h-[60%] rounded-full bg-blue-600/10 blur-[140px] animate-pulse-slow"/>
<div class="absolute bottom-[-20%] left-[-10%] w-[60%] h-[60%] rounded-full bg-indigo-600/10 blur-[140px] animate-pulse-slow" style="animation-delay: 3s;"/>
</div>
<!-- ==========================================
HERO SECTION
Title and subtitle area
========================================== -->
<section class="relative pt-32 pb-20 px-6 overflow-hidden">
<div class="container mx-auto max-w-6xl text-center relative z-10">
<!-- Tagline Badge -->
<div class="mb-8 slide-up">
<span class="px-5 py-2 rounded-full glass border border-blue-400/20 text-blue-400 text-[11px] font-black tracking-[0.25em] uppercase shadow-[0_0_20px_rgba(59,130,246,0.15)]">
EXPLORE COURSES
</span>
</div>
<!-- Main Title -->
<h1 class="text-4xl md:text-6xl font-black text-slate-900 mb-8 tracking-normal py-10 slide-up leading-[1.6]" style="animation-delay: 0.1s;">
คอรสเรยน<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-32">
<!-- Content Frame Container -->
<div class="glass-premium rounded-[3rem] p-8 md:p-12 shadow-xl shadow-blue-900/5">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-8 mb-12">
<h2 class="text-2xl font-black text-slate-900 flex items-center gap-3">
<span class="w-2 h-8 bg-blue-600 rounded-full"/>
รายการคอรสเรยน
</h2>
<!-- 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 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>
<div class="flex flex-wrap sm:flex-nowrap items-center gap-3 w-full md:w-auto">
<div class="relative w-full sm:w-[260px] flex-1">
<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]" />
<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>
<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>
<!-- 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"
>
<!-- วกรองหมวดหม (Filters Category) -->
<div class="flex flex-col xl:flex-row xl:items-center justify-between gap-4 mb-10 w-full relative">
<div class="flex flex-wrap items-center gap-3 w-full xl:w-auto">
<button
@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'"
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
v-for="cat in categories" :key="cat.id"
@click="selectCategory(cat.slug)"
: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="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">
<q-icon :name="getCategoryIcon(cat.name)" size="18px" :class="selectedCategory === cat.slug ? 'text-[#3B6BE8]' : 'text-slate-600 dark:text-slate-400'"/>
{{ getLocalizedText(cat.name) }}
</button>
</div>
</div>
<!-- Card Content Body -->
<div class="p-6 flex-1 flex flex-col border-t border-slate-100 ">
<h3 class="text-xl font-bold text-slate-900 mb-2 leading-snug group-hover:text-blue-600 transition-colors line-clamp-2">
{{ getLocalizedText(course.title) }}
</h3>
<p class="text-slate-500 text-xs mb-6 line-clamp-2 leading-relaxed flex-1">
{{ getLocalizedText(course.description) }}
</p>
<!-- วแสดงการโหลด (Loader) -->
<div v-if="isLoading" class="flex justify-center py-24">
<q-spinner size="3rem" color="primary" />
</div>
<!-- Card Footer -->
<div class="pt-4 border-t border-slate-100 flex items-center justify-between mt-auto">
<span class="text-lg font-black text-blue-600 tracking-tight">
{{ course.is_free ? 'ฟรี' : course.price }}
</span>
<NuxtLink
:to="`/course/${course.id}`"
class="px-4 py-2 bg-slate-50 hover:bg-blue-600 text-slate-600 hover:text-white rounded-lg text-xs font-bold transition-all border border-slate-200 hover:border-blue-500/50"
>
รายละเอยด
<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>
</div>
</div>
</NuxtLink>
</div>
<!-- มมองแบบรายการ (LIST VIEW) -->
<div v-else class="flex flex-col gap-5">
<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">
<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">
<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.5 rounded-full shadow-sm" style="border-radius: 9999px; padding: 4px 12px;">
{{ course.category_name }}
</div>
</div>
<div class="flex flex-col flex-1 py-1">
<div class="flex-1">
<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>
<div class="flex items-center gap-2 mb-3">
<span class="text-[13px] text-slate-500 font-medium">โดย {{ course.instructor_name }}</span>
</div>
<div class="flex items-center gap-1.5 mb-2">
<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>
</div>
</div>
</div>
</div>
<!-- Empty State (No Results) -->
<div v-else class="text-center py-20">
<div class="text-6xl mb-6 opacity-50 animate-bounce">🔭</div>
<h2 class="text-2xl font-black text-slate-900 mb-3">ไมพบคอรสทณคนหา</h2>
<p class="text-slate-400 mb-8 max-w-md mx-auto">
ลองใชคำคนหาอ หรอดคอรสทงหมดทเรามใหบรการ
</p>
<button
class="px-6 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition-all shadow-lg shadow-blue-600/20"
@click="searchQuery = ''"
>
<!-- กรณไมพบขอมลคอร (Empty State) -->
<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">
<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">{{ searchQuery ? 'ไม่พบคอร์สที่คุณค้นหา' : 'ไม่มีคอร์สในหมวดหมู่นี้' }}</h3>
<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';">
แสดงคอรสทงหมด
</button>
</div>
</div>
</section>
<!-- ==========================================
CTA SECTION
Call to action to register
========================================== -->
<section class="py-32 relative overflow-hidden">
<!-- Gradient Overlay -->
<div class="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent pointer-events-none"/>
<div class="container mx-auto max-w-4xl text-center relative z-10 px-6">
<h2 class="text-4xl md:text-5xl font-black text-slate-900 mb-8 tracking-tight">
พรอมจะเรมตนแลวหรอย?
</h2>
<p class="text-slate-400 text-xl mb-12 max-w-2xl mx-auto leading-relaxed">
ลงทะเบยนฟรนนเพอเขาถงบทเรยนพนฐาน และตดตามความคบหนาการเรยนของคณไดนท ไมาใชายแอบแฝง
</p>
<NuxtLink
to="/auth/register"
class="inline-flex items-center gap-3 px-10 py-5 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white rounded-2xl text-lg font-black shadow-2xl shadow-blue-900/40 hover:scale-105 transition-all duration-300"
>
<span></span>
<span>สมครสมาชกฟร</span>
</NuxtLink>
</div>
</section>
</div>
</template>
<style scoped>
/*
MATCHING INDEX.VUE STYLES
These styles ensure consistency with the landing page theme.
*/
/* 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;
/* ปิดการแสดงแถบเลื่อนบนคอนเทนเนอร์ของตัวกรอง (Disable default scrollbar for filter container) */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Premium Glass Effect for Containers */
.glass-premium {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(40px);
border: 1px solid rgba(255, 255, 255, 0.5);
}
/* Glass Card Style 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;
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>

View file

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

View file

@ -22,7 +22,7 @@ const { user } = useAuth()
const { fetchCourseLearningInfo, fetchLessonContent, saveVideoProgress, checkLessonAccess, fetchVideoProgress, fetchCourseAnnouncements, markLessonComplete, getLocalizedText } = useCourse()
const $q = useQuasar()
// State management
// (State management)
const sidebarOpen = ref(false)
const courseId = computed(() => Number(route.query.course_id))
@ -31,11 +31,11 @@ const courseId = computed(() => Number(route.query.course_id))
// ==========================================
// courseData: ()
const courseData = ref<any>(null)
const announcements = ref<any[]>([]) // Announcements state
const showAnnouncementsModal = ref(false) // Modal state
const hasUnreadAnnouncements = ref(false) // Unread state tracking
const announcements = ref<any[]>([]) // (Announcements state)
const showAnnouncementsModal = ref(false) // (Modal state)
const hasUnreadAnnouncements = ref(false) // (Unread state tracking)
// Helper for persistent read status
// (Helper for persistent read status)
const getAnnouncementStorageKey = () => {
if (!user.value?.id || !courseId.value) return ''
return `read_announcements:${user.value.id}:${courseId.value}`
@ -61,17 +61,17 @@ const checkUnreadAnnouncements = () => {
const lastReadDate = new Date(lastRead).getTime()
const hasNew = announcements.value.some(a => {
const annDate = new Date(a.created_at || Date.now()).getTime()
// Check if announcement is strictly newer than last read
// (Check if announcement is strictly newer than last read)
return annDate > lastReadDate
})
hasUnreadAnnouncements.value = hasNew
}
// Handler for opening announcements
// (Handler for opening announcements)
const handleOpenAnnouncements = () => {
showAnnouncementsModal.value = true
hasUnreadAnnouncements.value = false // Clear unread badge on click
hasUnreadAnnouncements.value = false // (Clear unread badge on click)
const key = getAnnouncementStorageKey()
if (key) {
@ -90,6 +90,7 @@ const isPlaying = ref(false)
const videoProgress = ref(0)
const currentTime = ref(0)
const duration = ref(0)
const activeTab = ref('content')
@ -97,7 +98,7 @@ const toggleSidebar = () => {
sidebarOpen.value = !sidebarOpen.value
}
// Logic Quiz Attempt Management
// (Logic Quiz Attempt Management)
const quizStatus = computed(() => {
if (!currentLesson.value || currentLesson.value.type !== 'QUIZ' || !currentLesson.value.quiz) return null
@ -105,7 +106,7 @@ const quizStatus = computed(() => {
const latestAttempt = quiz.latest_attempt
const allowMultiple = quiz.allow_multiple_attempts
// If never attempted
// (If never attempted)
if (!latestAttempt) {
return {
canStart: true,
@ -115,7 +116,7 @@ const quizStatus = computed(() => {
}
}
// If multiple attempts allowed
// (If multiple attempts allowed)
if (allowMultiple) {
return {
canStart: true,
@ -127,8 +128,8 @@ const quizStatus = computed(() => {
}
}
// allowMultiple is false (Single attempt only)
// Lock the quiz regardless of pass/fail once attempted
// (allowMultiple is false (Single attempt only))
// (Lock the quiz regardless of pass/fail once attempted)
return {
canStart: false,
label: latestAttempt.is_passed ? t('quiz.passedStatus') : t('quiz.failedStatus'),
@ -144,7 +145,7 @@ const handleStartQuiz = () => {
const quiz = currentLesson.value.quiz
// If multiple attempts are disabled and it's the first time
// (If multiple attempts are disabled and it's the first time)
if (!quiz.allow_multiple_attempts && !quiz.latest_attempt) {
$q.dialog({
title: `<div class="text-slate-900 dark:text-white font-black text-xl">${t('quiz.warningTitle')}</div>`,
@ -192,18 +193,18 @@ const resetAndNavigate = (path: string) => {
}
}
// 2. Clear all localStorage
// 2. localStorage (Clear all localStorage)
localStorage.clear()
// 3. Restore whitelisted keys
Object.entries(whitelist).forEach(([key, value]) => {
localStorage.setItem(key, value)
// 3. (Restore ONLY whitelisted keys)
Object.keys(whitelist).forEach(key => {
localStorage.setItem(key, whitelist[key])
})
// 4. Force hard reload to the new path
// 4. (Force hard reload to the new path)
window.location.href = path
} else {
// SSR Fallback
// SSR (SSR Fallback)
router.push(path)
}
}
@ -212,13 +213,13 @@ const resetAndNavigate = (path: string) => {
const handleLessonSelect = (lessonId: number) => {
if (currentLesson.value?.id === lessonId) return
// 1. Update URL query params
// 1. URL (Update URL query params)
router.push({ query: { ...route.query, lesson_id: lessonId.toString() } })
// 2. Load content without refresh
// 2. (Load content without refresh)
loadLesson(lessonId)
// Close sidebar on mobile
// (Close sidebar on mobile)
if (sidebarOpen.value) {
sidebarOpen.value = false
}
@ -244,7 +245,7 @@ const loadCourseData = async () => {
if (res.success) {
courseData.value = res.data
// Auto-load logic: Check URL first, fallback to first available lesson
// : URL (Auto-load logic: Check URL first, fallback to first available lesson)
const urlLessonId = route.query.lesson_id ? Number(route.query.lesson_id) : null
if (urlLessonId) {
@ -257,7 +258,7 @@ const loadCourseData = async () => {
}
}
// Fetch Course Announcements
// (Fetch Course Announcements)
const annRes = await fetchCourseAnnouncements(courseId.value)
if (annRes.success) {
announcements.value = annRes.data || []
@ -274,20 +275,26 @@ const loadCourseData = async () => {
const loadLesson = async (lessonId: number) => {
if (currentLesson.value?.id === lessonId) return
// Clear previous video state & unload component to force reset
// (Clear previous video state & unload component to force reset)
isPlaying.value = false
videoProgress.value = 0
currentTime.value = 0
currentLesson.value = null // This will unmount VideoPlayer and hide content
initialSeekTime.value = 0
maxWatchedTime.value = 0
lastSavedTime.value = -1
lastSavedTimestamp.value = 0
lastLocalSaveTimestamp.value = 0
currentDuration.value = 0
currentLesson.value = null // (This will unmount VideoPlayer and hide content)
isLessonLoading.value = true
try {
// Optional: Check access first
// : (Optional: Check access first)
const accessRes = await checkLessonAccess(courseId.value, lessonId)
if (accessRes.success && !accessRes.data.is_accessible) {
let msg = t('classroom.notAvailable')
// Handle specific lock reasons
// (Handle specific lock reasons)
if (accessRes.data.lock_reason) {
msg = accessRes.data.lock_reason
} else if (accessRes.data.required_quiz_pass && !accessRes.data.required_quiz_pass.is_passed) {
@ -307,46 +314,54 @@ const loadLesson = async (lessonId: number) => {
return
}
// 1. Fetch content
// 1. (Fetch content)
const res = await fetchLessonContent(courseId.value, lessonId)
if (res.success) {
currentLesson.value = res.data
// Initialize progress object if missing (Critical for New Users)
// () (Initialize progress object if missing)
if (!currentLesson.value.progress) {
currentLesson.value.progress = {}
}
// Update Lesson Completion UI status safely
// UI (Update Lesson Completion UI status safely)
if (currentLesson.value?.progress?.is_completed && courseData.value) {
for (const chapter of courseData.value.chapters) {
const lesson = chapter.lessons.find((l: any) => l.id === lessonId)
if (lesson) {
if (!lesson.progress) lesson.progress = {}
lesson.progress.is_completed = true
lesson.is_completed = true // Standardize completion property
lesson.is_completed = true // (Standardize completion property)
break
}
}
}
// 2. Fetch Initial Progress (Resume Playback)
// 2. (Fetch Initial Progress (Resume Playback))
if (currentLesson.value.type === 'VIDEO') {
// A. Server Progress
// (If already completed, clear local resume point to allow fresh re-watch)
const isCompleted = currentLesson.value.progress?.is_completed || false
if (isCompleted) {
const key = getLocalProgressKey(lessonId)
if (key && typeof window !== 'undefined') {
localStorage.removeItem(key)
}
initialSeekTime.value = 0
maxWatchedTime.value = 0
currentTime.value = 0
} else {
// ? (Not completed? Resume from where we left off)
const progressRes = await fetchVideoProgress(lessonId)
let serverProgress = 0
if (progressRes.success && progressRes.data?.video_progress_seconds) {
serverProgress = progressRes.data.video_progress_seconds
}
// B. Local Progress (Buffer)
const localProgress = getLocalProgress(lessonId)
// C. Hybrid Resume (Max Wins)
const resumeTime = Math.max(serverProgress, localProgress)
if (resumeTime > 0) {
initialSeekTime.value = resumeTime
maxWatchedTime.value = resumeTime
currentTime.value = resumeTime
@ -356,6 +371,7 @@ const loadLesson = async (lessonId: number) => {
}
}
}
}
} catch (error) {
console.error('Error loading lesson:', error)
} finally {
@ -363,24 +379,24 @@ const loadLesson = async (lessonId: number) => {
}
}
// Video Player Ref (Component)
// (Video Player Ref (Component))
const videoPlayerComp = ref(null)
// Video & Progress State
// (Video & Progress State)
const initialSeekTime = ref(0)
const maxWatchedTime = ref(0) // Anti-rewind monotonic tracking
const maxWatchedTime = ref(0) // (Anti-rewind monotonic tracking)
const lastSavedTime = ref(-1)
const lastSavedTimestamp = ref(0) // Server throttle timestamp
const lastLocalSaveTimestamp = ref(0) // Local throttle timestamp
const currentDuration = ref(0) // Track duration for save logic
const lastSavedTimestamp = ref(0) // (Server throttle timestamp)
const lastLocalSaveTimestamp = ref(0) // (Local throttle timestamp)
const currentDuration = ref(0) // (Track duration for save logic)
// Helper: Get Local Storage Key
// : Local Storage (Helper: Get Local Storage Key)
const getLocalProgressKey = (lessonId: number) => {
if (!user.value?.id) return null
return `progress:${user.value.id}:${lessonId}`
}
// Helper: Get Local Progress
// : (Helper: Get Local Progress)
const getLocalProgress = (lessonId: number): number => {
try {
const key = getLocalProgressKey(lessonId)
@ -392,7 +408,7 @@ const getLocalProgress = (lessonId: number): number => {
}
}
// Helper: Save to Local Storage
// : (Helper: Save to Local Storage)
const saveLocalProgress = (lessonId: number, time: number) => {
try {
const key = getLocalProgressKey(lessonId)
@ -400,31 +416,31 @@ const saveLocalProgress = (lessonId: number, time: number) => {
localStorage.setItem(key, time.toString())
}
} catch (e) {
// Ignore storage errors
// (Ignore storage errors)
}
}
// Handler: Video Time Update (from Component)
// ( Component) (Handler: Video Time Update (from Component))
const handleVideoTimeUpdate = (cTime: number, dur: number) => {
currentDuration.value = dur || 0
// Update Monotonic Progress
// (Update Monotonic Progress)
if (cTime > maxWatchedTime.value) {
maxWatchedTime.value = cTime
}
// Logic: Periodic Save
// : (Logic: Periodic Save)
if (currentLesson.value?.id) {
const now = Date.now()
// 1. Local Save Throttle (5 seconds)
// 1. (5 ) (Local Save Throttle (5 seconds))
if (now - lastLocalSaveTimestamp.value > 5000) {
saveLocalProgress(currentLesson.value.id, maxWatchedTime.value)
lastLocalSaveTimestamp.value = now
}
// 2. Server Save Throttle (handled inside performSaveProgress)
// Note: We don't check isPlaying here because if time is updating, it IS playing.
// 2. ( performSaveProgress)
// : isPlaying (Note: We don't check isPlaying here because if time is updating, it IS playing.)
performSaveProgress(false, false)
}
}
@ -435,49 +451,49 @@ const onVideoMetadataLoaded = (duration: number) => {
}
}
const isCompleting = ref(false) // Flag to prevent race conditions during completion
const isCompleting = ref(false) // (Flag to prevent race conditions during completion)
// -----------------------------------------------------
// ROBUST PROGRESS SAVING SYSTEM (Hybrid: Local + Server)
// (: + ) (ROBUST PROGRESS SAVING SYSTEM (Hybrid: Local + Server))
// -----------------------------------------------------
// Main Server Save Function
// (Main Server Save Function)
const performSaveProgress = async (force: boolean = false, keepalive: boolean = false) => {
const lesson = currentLesson.value
if (!lesson || lesson.type !== 'VIDEO') return
// Ensure progress object exists
// (Ensure progress object exists)
if (!lesson.progress) lesson.progress = {}
// 1. Completed Guard: Stop everything if already completed
// 1. : (Completed Guard: Stop everything if already completed)
if (lesson.progress.is_completed) return
// 2. Race Condition Guard: Stop if currently completing
// 2. : (Race Condition Guard: Stop if currently completing)
if (isCompleting.value) return
const now = Date.now()
const maxSec = Math.floor(maxWatchedTime.value) // Use max watched time
const maxSec = Math.floor(maxWatchedTime.value) // (Use max watched time)
const durationSec = Math.floor(currentDuration.value || 0)
// 3. Monotonic Check: Allow saving 0 if it's the very first save (lastSavedTime is -1)
// 3. : 0 (lastSavedTime is -1) (Monotonic Check: Allow saving 0 if it's the very first save)
if (!force) {
if (lastSavedTime.value === -1) {
// First time save: allow 0 or more
// : 0 (First time save: allow 0 or more)
if (maxSec < 0) return
} else if (maxSec <= lastSavedTime.value) {
// Subsequent saves: must be greater than last saved
// : (Subsequent saves: must be greater than last saved)
return
}
}
// 4. Throttle Check: Server Throttle (15 seconds)
// 4. : (15 ) (Throttle Check: Server Throttle (15 seconds))
if (!force && (now - lastSavedTimestamp.value < 15000)) return
// Prepare for Save
// (Prepare for Save)
lastSavedTime.value = maxSec
lastSavedTimestamp.value = now
// Check if this save might complete the lesson (e.g. 100% or forced end)
// ( 100% ) (Check if this save might complete the lesson)
const isFinishing = force || (durationSec > 0 && maxSec >= durationSec)
if (isFinishing) {
@ -487,8 +503,8 @@ const performSaveProgress = async (force: boolean = false, keepalive: boolean =
try {
const res = await saveVideoProgress(lesson.id, maxSec, durationSec, keepalive)
// Handle Completion (Frontend-only strategy: 95% threshold)
// This ensures the checkmark appears at 95% to match backend.
// (: 95%) (Handle Completion (Frontend-only strategy: 95% threshold))
// 95% (This ensures the checkmark appears at 95% to match backend.)
const progressPercentage = durationSec > 0 ? (maxSec / durationSec) : 0
const isCompletedNow = res.success && (res.data?.is_completed || progressPercentage >= 0.95)
@ -497,7 +513,7 @@ const performSaveProgress = async (force: boolean = false, keepalive: boolean =
markLessonAsCompletedLocally(lesson.id)
if (lesson.progress) lesson.progress.is_completed = true
// If newly completed, reload course data to unlock next lesson in sidebar
// (If newly completed, reload course data to unlock next lesson in sidebar)
if (!wasAlreadyCompleted) {
await loadCourseData()
}
@ -511,13 +527,13 @@ const performSaveProgress = async (force: boolean = false, keepalive: boolean =
}
}
// Helper to update Sidebar UI
// (Helper to update Sidebar UI)
const markLessonAsCompletedLocally = (lessonId: number) => {
if (courseData.value) {
for (const chapter of courseData.value.chapters) {
const lesson = chapter.lessons.find((l: any) => l.id === lessonId)
if (lesson) {
// Compatible with API structure
// API (Compatible with API structure)
lesson.is_completed = true
if (!lesson.progress) lesson.progress = {}
lesson.progress.is_completed = true
@ -531,11 +547,11 @@ const videoSrc = computed(() => {
if (!currentLesson.value) return ''
let url = ''
// Use explicit video_url from API first
// video_url API (Use explicit video_url from API first)
if (currentLesson.value.video_url) {
url = currentLesson.value.video_url
} else {
// Fallback (deprecated logic)
// ()
const content = getLocalizedText(currentLesson.value.content)
if (content && (content.startsWith('http') || content.startsWith('/')) && !content.includes(' ')) {
url = content
@ -544,7 +560,7 @@ const videoSrc = computed(() => {
if (!url) return ''
// Support Resume for YouTube
// YouTube (Support Resume for YouTube)
const isYoutube = url.toLowerCase().includes('youtube.com') || url.toLowerCase().includes('youtu.be')
if (isYoutube && initialSeekTime.value > 0) {
const separator = url.includes('?') ? '&' : '?'
@ -556,13 +572,19 @@ const videoSrc = computed(() => {
// (Complete)
const onVideoEnded = async () => {
// Safety check before saving
const lesson = currentLesson.value
if (!lesson || !lesson.progress || lesson.progress.is_completed || isCompleting.value) return
if (!lesson) return
// localStorage (Clear local storage on end since it's completed)
const key = getLocalProgressKey(lesson.id)
if (key && typeof window !== 'undefined') {
localStorage.removeItem(key)
}
if (lesson.progress?.is_completed || isCompleting.value) return
isCompleting.value = true
try {
// Force save progress at 100% to trigger backend completion
await performSaveProgress(true, false)
} catch (err) {
console.error('Failed to save progress on end:', err)
@ -576,67 +598,74 @@ onMounted(() => {
})
onBeforeUnmount(() => {
// Clear state when leaving the page to ensure fresh start on return
// (Clear state when leaving the page to ensure fresh start on return)
courseData.value = null
currentLesson.value = null
})
</script>
<template>
<q-layout view="hHh LpR lFf" class="bg-[var(--bg-body)] text-[var(--text-main)]">
<q-layout view="hHh lpR lFf" class="bg-[var(--bg-body)] text-[var(--text-main)]">
<!-- Header -->
<q-header bordered class="bg-[var(--bg-surface)] border-b border-gray-200 dark:border-white/5 text-[var(--text-main)] h-14">
<q-toolbar>
<!-- Exit/Back Button -->
<q-btn
flat
rounded
no-caps
color="primary"
class="mr-4 bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white font-bold hover:bg-slate-200"
@click="handleExit('/dashboard/my-courses')"
>
<q-icon name="close" size="18px" class="mr-1.5" />
<span class="hidden sm:inline">{{ $t('common.close') }}</span>
<q-tooltip>{{ $t('classroom.backToDashboard') }}</q-tooltip>
</q-btn>
<!-- 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-header bordered class="bg-[var(--bg-surface)] border-b border-gray-200 dark:border-white/5 text-[var(--text-main)] h-16">
<q-toolbar class="h-full px-4">
<!-- 1. Left Side: Back & Title -->
<div class="flex items-center gap-4 flex-grow overflow-hidden">
<!-- Back Button -->
<q-btn
flat
round
dense
icon="campaign"
@click="handleOpenAnnouncements"
class="text-slate-600 dark:text-slate-300 hover:text-blue-600 transition-colors"
color="primary"
class="bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
@click="handleExit('/dashboard/my-courses')"
>
<q-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-btn>
</div>
</q-toolbar>
</q-header>
<!-- Sidebar (Curriculum) -->
<!-- Sidebar (Curriculum) -->
<!-- แถบดานขาง (บทเรยน) - วางชดขวาผานพรอพพ -->
<CurriculumSidebar
v-model="sidebarOpen"
:courseData="courseData"
@ -647,14 +676,14 @@ onBeforeUnmount(() => {
@open-announcements="handleOpenAnnouncements"
/>
<!-- Main Content -->
<!-- นทเนอหาหล (Main Content) -->
<q-page-container class="bg-white dark:bg-slate-900">
<q-page class="flex flex-col h-full bg-slate-50 dark:bg-[#0B0F1A]">
<!-- Video Player & Content Area -->
<div class="w-full max-w-7xl mx-auto p-4 md:p-6 flex-grow">
<!-- 1. LOADING STATE (Comprehensive Skeleton) -->
<!-- กรอบวโอและพนทเนอหา (Video Player & Content Area) -->
<div class="w-full h-full p-4 md:p-6 flex-grow overflow-y-auto">
<!-- 1. สถานะกำลงโหลด (โครงสรางเสมอน (Skeleton) สมบรณแบบ) (LOADING STATE (Comprehensive Skeleton)) -->
<div v-if="isLessonLoading" class="animate-fade-in">
<!-- Video Skeleton -->
<!-- โครงภาพวโอ (Video Skeleton) -->
<div class="aspect-video bg-slate-200 dark:bg-slate-800 rounded-3xl animate-pulse flex items-center justify-center mb-10 overflow-hidden relative shadow-xl focus:outline-none">
<img
v-if="courseData?.course?.thumbnail_url"
@ -668,7 +697,7 @@ onBeforeUnmount(() => {
</div>
</div>
<!-- Info Skeleton -->
<!-- โครงขอม (Info Skeleton) -->
<div class="bg-white dark:bg-slate-800/50 p-8 rounded-3xl border border-slate-100 dark:border-white/5 shadow-sm">
<div class="h-10 bg-slate-200 dark:bg-slate-800 rounded-xl w-3/4 mb-4 animate-pulse"></div>
<div class="h-4 bg-slate-100 dark:bg-slate-800 rounded-lg w-full mb-2 animate-pulse"></div>
@ -676,9 +705,9 @@ onBeforeUnmount(() => {
</div>
</div>
<!-- 2. READY STATE (Real Lesson Content) -->
<!-- 2. สถานะพรอมใชงาน (อมลบทเรยนจร) (READY STATE (Real Lesson Content)) -->
<div v-else-if="currentLesson" class="animate-fade-in">
<!-- Video Player -->
<!-- วนการเลนวโอ (Video Player) -->
<VideoPlayer
v-if="videoSrc"
ref="videoPlayerComp"
@ -690,7 +719,7 @@ onBeforeUnmount(() => {
@loadedmetadata="(d: number) => onVideoMetadataLoaded(d)"
/>
<!-- Lesson Info -->
<!-- อมลบทเรยน (Lesson Info) -->
<div class="bg-[var(--bg-surface)] p-6 md:p-8 rounded-3xl shadow-sm border border-[var(--border-color)]">
<!-- ใชจากตวแปรกลาง: จะแยกโหมดใหตโนม (สวาง=ดำ / =ขาว) -->
<div class="flex items-start justify-between gap-4 mb-4">
@ -699,7 +728,7 @@ onBeforeUnmount(() => {
<p class="text-slate-600 dark:text-slate-400 text-base md:text-lg leading-relaxed mb-6" v-if="currentLesson.description">{{ getLocalizedText(currentLesson.description) }}</p>
<!-- Lesson Content Area (Text/HTML) -->
<!-- องบทเรยน (Text/HTML) (Lesson Content Area) -->
<div v-if="currentLesson.type === 'QUIZ'" class="p-8 bg-gradient-to-br from-blue-50/50 to-indigo-50/50 dark:from-slate-800/50 dark:to-slate-900/50 rounded-2xl border border-blue-100 dark:border-white/5 text-center">
<div class="bg-white dark:bg-slate-800 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-4 shadow-sm text-blue-500 dark:text-blue-400 border dark:border-white/10">
<q-icon name="quiz" size="40px" />
@ -754,7 +783,7 @@ onBeforeUnmount(() => {
<div v-html="getLocalizedText(currentLesson.content)" class="leading-relaxed text-slate-800 dark:text-slate-200"></div>
</div>
<!-- Attachments Section -->
<!-- วนเอกสารแนบ (Attachments Section) -->
<div v-if="currentLesson.attachments && currentLesson.attachments.length > 0" class="mt-8 pt-6 border-t border-gray-100 dark:border-white/5">
<h3 class="text-lg font-bold mb-4 text-slate-900 dark:text-white flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-orange-100 dark:bg-orange-900/30 text-orange-600 flex items-center justify-center">

View file

@ -1,9 +1,9 @@
<script setup lang="ts">
/**
* @file quiz.vue
* @description Quiz Interface.
* Manages the entire quiz lifecycle: Start -> Taking -> Results -> Review.
* Features a timer, question navigation, and detailed result analysis.
* @description หนาสำหรบทำแบบทดสอบ (Quiz Interface)
* ดการวงจรชตของการทำแบบทดสอบทงหมด: เรมต -> ทำขอสอบ -> ผลลพธ -> ทบทวน
* เจอรบเวลา การนำทางระหวางคำถาม และการวเคราะหผลลพธอยางละเอยด
*/
definePageMeta({
@ -17,7 +17,7 @@ const router = useRouter()
const $q = useQuasar()
const { fetchCourseLearningInfo, fetchLessonContent, submitQuiz: apiSubmitQuiz, markLessonComplete } = useCourse()
// State Management
// (State Management)
const currentScreen = ref<'start' | 'taking' | 'result' | 'review'>('start')
const timeLeft = ref(0)
let timerInterval: ReturnType<typeof setInterval> | null = null
@ -30,20 +30,24 @@ const quizData = ref<any>(null)
const isLoading = ref(true)
const isSubmitting = ref(false)
// Quiz Taking State
// (Quiz Taking State)
const currentQuestionIndex = ref(0)
const userAnswers = ref<Record<number, number>>({}) // questionId -> choiceId
const visitedQuestions = ref<Set<number>>(new Set()) // Track visited indices
const userAnswers = ref<Record<number, number>>({}) // ID -> ID (questionId -> choiceId)
const visitedQuestions = ref<Set<number>>(new Set()) // (Track visited indices)
const quizResult = ref<any>(null)
// Tracking visited questions
const questionPageSize = 10
const questionPage = ref(0)
// (Tracking visited questions)
watch(currentQuestionIndex, (newVal) => {
visitedQuestions.value.add(newVal)
questionPage.value = Math.floor(newVal / questionPageSize)
}, { immediate: true })
// Helper: Get Status Color Class
// : (Helper: Get Status Color Class)
const getQuestionStatusClass = (index: number, questionId: number) => {
// 1. Current = Blue
// 1. = (Current = Blue)
if (index === currentQuestionIndex.value) {
return 'bg-blue-500 text-white border-blue-600 ring-2 ring-blue-200 dark:ring-blue-900'
}
@ -51,32 +55,29 @@ const getQuestionStatusClass = (index: number, questionId: number) => {
const hasAnswer = userAnswers.value[questionId] !== undefined
const isVisited = visitedQuestions.value.has(index)
// 2. Completed = Green
// 2. = (Completed = Green)
if (hasAnswer) {
return 'bg-emerald-500 text-white border-emerald-600'
}
// 3. Skipped = Orange (Visited but no answer)
// Note: If we are strictly following "Skipped" definition:
// "user pressed Skip or moved forward on a skippable question without saving an answer"
// In this linear flow, merely visiting and leaving empty counts as skipped.
// 3. = () (Skipped = Orange (Visited but no answer))
// :
//
if (isVisited && !hasAnswer) {
return 'bg-orange-500 text-white border-orange-600'
}
// 4. Not Started = Grey
// 4. = (Not Started = Grey)
return 'bg-slate-200 text-slate-400 border-slate-300 dark:bg-white/5 dark:border-white/5 dark:text-slate-600 hover:bg-slate-300 dark:hover:bg-white/10'
}
const jumpToQuestion = (targetIndex: number) => {
if (targetIndex === currentQuestionIndex.value) return
// Validation before leaving current (same logic as Next)
// (Validation before leaving current)
if (targetIndex > currentQuestionIndex.value) {
// If jumping forward, we must validate the CURRENT question requirements
// unless we treat grid jumps as free navigation?
// Req: "user cannot go Next until the question is answered and saved" (if not skippable).
// So we must check restriction on the current spot before leaving.
//
// ()
const isAnswered = userAnswers.value[currentQuestion.value.id] !== undefined
const isSkippable = quizData.value?.is_skippable
@ -91,12 +92,35 @@ const jumpToQuestion = (targetIndex: number) => {
}
}
// If jumping backward? Usually allowed freely.
// (If jumping backward? Usually allowed freely.)
currentQuestionIndex.value = targetIndex
}
// Computed
const totalQuestionPages = computed(() => Math.ceil(totalQuestions.value / questionPageSize))
const visibleQuestions = computed(() => {
if (!quizData.value?.questions) return []
const start = questionPage.value * questionPageSize
return quizData.value.questions.slice(start, start + questionPageSize).map((q: any, i: number) => ({
...q,
originalIndex: start + i
}))
})
const nextQuestionPage = () => {
if (questionPage.value < totalQuestionPages.value - 1) {
questionPage.value++
}
}
const prevQuestionPage = () => {
if (questionPage.value > 0) {
questionPage.value--
}
}
// Computed (Computed Properties)
const currentQuestion = computed(() => {
if (!quizData.value || !quizData.value.questions) return null
return quizData.value.questions[currentQuestionIndex.value]
@ -116,7 +140,7 @@ const timerDisplay = computed(() => {
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
})
// Helper for localization
// (Helper for localization)
const getLocalizedText = (text: any) => {
if (!text) return ''
if (typeof text === 'string') return text
@ -126,7 +150,7 @@ const getLocalizedText = (text: any) => {
const lessonProgress = ref<any>(null)
// Data Fetching
// (Data Fetching)
const loadData = async () => {
isLoading.value = true
try {
@ -138,9 +162,9 @@ const loadData = async () => {
if (courseId && lessonId) {
const lessonRes = await fetchLessonContent(courseId, lessonId)
if (lessonRes.success) {
// Determine if data is directly the quiz or nested
// (Determine if data is directly the quiz or nested)
quizData.value = lessonRes.data.quiz || lessonRes.data
lessonProgress.value = lessonRes.progress // Capture progress
lessonProgress.value = lessonRes.progress // (Capture progress)
if (quizData.value?.time_limit) {
timeLeft.value = quizData.value.time_limit * 60
}
@ -153,7 +177,7 @@ const loadData = async () => {
}
}
// Helper for shuffling
// (Helper for shuffling)
const shuffleArray = <T>(array: T[]): T[] => {
return array
.map(value => ({ value, sort: Math.random() }))
@ -161,18 +185,18 @@ const shuffleArray = <T>(array: T[]): T[] => {
.map(({ value }) => value)
}
// Quiz Actions
// (Quiz Actions)
const startQuiz = () => {
// Deep copy to reset and apply shuffle
// (Deep copy to reset and apply shuffle)
const rawQuiz = JSON.parse(JSON.stringify(quizData.value))
if (rawQuiz) {
// Shuffle Questions
// (Shuffle Questions)
if (rawQuiz.shuffle_questions && rawQuiz.questions) {
rawQuiz.questions = shuffleArray(rawQuiz.questions)
}
// Shuffle Choices
// (Shuffle Choices)
if (rawQuiz.shuffle_choices && rawQuiz.questions) {
rawQuiz.questions.forEach((q: any) => {
if (q.choices) {
@ -180,7 +204,7 @@ const startQuiz = () => {
}
})
}
// Update state with shuffled data
// (Update state with shuffled data)
quizData.value = rawQuiz
}
@ -196,7 +220,7 @@ const startQuiz = () => {
}, 1000)
}
// Mark first as visited
// (Mark first as visited)
visitedQuestions.value = new Set([0])
}
@ -209,12 +233,12 @@ const selectAnswer = (choiceId: number) => {
const nextQuestion = () => {
if (!currentQuestion.value) return
// Allow skip if quiz is skippable or question is answered
// (Allow skip if quiz is skippable or question is answered)
const isAnswered = userAnswers.value[currentQuestion.value.id] !== undefined
const isSkippable = quizData.value?.is_skippable
if (!isAnswered && !isSkippable) {
// Show warning
// (Show warning)
$q.notify({
type: 'warning',
message: t('quiz.pleaseSelectAnswer', 'กรุณาเลือกคำตอบ'),
@ -241,7 +265,7 @@ const retryQuiz = () => {
}
const submitQuiz = async (auto = false) => {
// 1. Manual Validation: Check if all questions are answered
// 1. (Manual Validation: Check if all questions are answered)
if (!auto) {
const answeredCount = Object.keys(userAnswers.value).length
if (answeredCount < totalQuestions.value) {
@ -254,7 +278,7 @@ const submitQuiz = async (auto = false) => {
return
}
// Premium Confirmation before submission
// (Premium Confirmation before submission)
$q.dialog({
title: `<div class="text-slate-900 dark:text-white font-black text-xl">${t('quiz.warningTitle')}</div>`,
message: `<div class="text-slate-600 dark:text-slate-300 text-base leading-relaxed mt-2">${t('quiz.submitConfirm')}</div>`,
@ -285,33 +309,33 @@ const submitQuiz = async (auto = false) => {
}
const processSubmitQuiz = async (auto = false) => {
// 2. Start Submission Process
// 2. (Start Submission Process)
if (timerInterval) clearInterval(timerInterval)
isSubmitting.value = true
currentScreen.value = 'result' // Switch to result screen to show progress
currentScreen.value = 'result' // (Switch to result screen to show progress)
try {
// Prepare Payload
// API (Prepare Payload)
const answersPayload = Object.entries(userAnswers.value).map(([qId, cId]) => ({
question_id: Number(qId),
choice_id: cId
}))
// Check if already passed
// (Check if already passed)
const alreadyPassed = lessonProgress.value?.is_passed || lessonProgress.value?.is_completed || false
// Call API
// API (Call API)
const res = await apiSubmitQuiz(courseId, lessonId, answersPayload, alreadyPassed)
if (res.success && res.data) {
quizResult.value = res.data
// Update local progress if passed and not previously passed
// (Update local progress if passed and not previously passed)
if (res.data.is_passed && !alreadyPassed) {
if (lessonProgress.value) lessonProgress.value.is_passed = true
}
} else {
// Fallback error handling
// (Fallback error handling)
$q.notify({
type: 'negative',
message: res.error || 'Failed to submit quiz'
@ -364,14 +388,14 @@ const reviewQuiz = () => {
currentScreen.value = 'review'
}
// Helper to get choice label (A, B, C...)
// ID (A, B, C...) (Helper to get choice label (A, B, C...))
const getChoiceLabel = (index: number) => {
return String.fromCharCode(65 + index) // 65 is 'A'
}
const getCorrectChoiceId = (questionId: number) => {
if (!quizResult.value?.answers_review) return null
// Type checking for safety
// (Type checking for safety)
const review = Array.isArray(quizResult.value.answers_review)
? quizResult.value.answers_review.find((r: any) => r.question_id === questionId)
: null
@ -384,7 +408,7 @@ const getCorrectChoiceId = (questionId: number) => {
<q-page-container>
<q-page>
<div class="quiz-shell min-h-screen bg-slate-50 dark:bg-[#0b0f1a] text-slate-900 dark:text-slate-200 antialiased selection:bg-blue-500/20 transition-colors">
<!-- Header -->
<!-- วนห (Header) -->
<header class="h-14 bg-white dark:!bg-[var(--bg-surface)] fixed top-0 inset-x-0 z-[100] flex items-center px-6 border-b border-slate-200 dark:border-white/5 transition-colors">
<div class="flex items-center w-full justify-between">
<div class="flex items-center">
@ -410,7 +434,7 @@ const getCorrectChoiceId = (questionId: number) => {
</div>
</header>
<!-- Main Content Area -->
<!-- นทเนอหาหล (Main Content Area) -->
<main class="pt-14 h-screen flex items-center justify-center overflow-y-auto px-4 custom-scrollbar">
<div v-if="isLoading" class="flex flex-col items-center gap-4">
@ -435,9 +459,9 @@ const getCorrectChoiceId = (questionId: number) => {
</div>
<template v-else>
<!-- 1. START SCREEN -->
<!-- 1. หนาเรมต (START SCREEN) -->
<div v-if="currentScreen === 'start'" class="w-full max-w-[640px] animate-fade-in py-12">
<!-- ... (Start Screen is unchanged but needs to be here for context) ... -->
<!-- ... (หนาแรกยงคงเหมอนเด แปะไวเป reference) ... -->
<div class="bg-white dark:!bg-[#1e293b] border border-slate-200 dark:border-white/5 rounded-[32px] p-8 md:p-14 shadow-lg dark:shadow-2xl relative overflow-hidden transition-colors">
<div class="flex justify-center mb-10">
@ -459,7 +483,7 @@ const getCorrectChoiceId = (questionId: number) => {
</p>
</div>
<!-- Instruction Box -->
<!-- กลองคำแนะนำ (Instruction Box) -->
<div class="bg-slate-50 dark:bg-[#0b121f]/80 p-8 rounded-3xl mb-8 border border-slate-100 dark:border-white/5">
<h3 class="text-[12px] font-black text-slate-500 dark:text-slate-400 mb-6 uppercase tracking-[0.2em] flex items-center gap-2">
{{ $t('quiz.instructionTitle') }}
@ -483,35 +507,58 @@ const getCorrectChoiceId = (questionId: number) => {
</div>
</div>
<!-- 2. TAKING SCREEN -->
<!-- 2. TAKING SCREEN -->
<!-- 2. หนาทำแบบทดสอบ (TAKING SCREEN) -->
<div v-if="currentScreen === 'taking'" class="w-full max-w-[840px] animate-fade-in py-12">
<div v-if="currentQuestion" class="bg-white dark:!bg-[#1e293b] border border-slate-200 dark:border-white/5 rounded-[32px] p-8 md:p-12 shadow-xl relative overflow-hidden">
<!-- Progress Bar -->
<!-- แถบความคบหน (Progress Bar) -->
<div class="absolute top-0 left-0 right-0 h-1.5 bg-slate-100 dark:bg-white/5">
<div class="h-full bg-blue-500 transition-all duration-300" :style="{ width: ((currentQuestionIndex + 1) / totalQuestions) * 100 + '%' }"></div>
</div>
<!-- Question Map / Pagination -->
<div class="flex flex-wrap gap-2 mb-8 mt-4">
<!-- แผนทคำถาม / การเปลยนหน (Question Map / Pagination) -->
<div class="flex items-center justify-center gap-2 mb-8 mt-4 select-none bg-slate-50 dark:bg-[#0b121f]/50 p-2 rounded-2xl border border-slate-100 dark:border-white/5 w-fit mx-auto">
<!-- มยอนกล (หนากอนหน) -->
<button
v-for="(q, idx) in quizData?.questions"
:key="q.id"
@click="jumpToQuestion(Number(idx))"
class="w-8 h-8 md:w-10 md:h-10 rounded-lg flex items-center justify-center text-xs md:text-sm font-bold transition-all border"
:class="getQuestionStatusClass(Number(idx), q.id)"
v-if="totalQuestionPages > 1"
@click="prevQuestionPage"
:disabled="questionPage === 0"
class="w-8 h-8 md:w-10 md:h-10 rounded-lg flex items-center justify-center transition-all flex-shrink-0"
:class="questionPage === 0 ? 'text-slate-300 dark:text-slate-600 bg-transparent cursor-not-allowed' : 'text-slate-600 dark:text-slate-300 bg-white dark:bg-[#1e293b] hover:bg-slate-200 dark:hover:bg-white/10 shadow-sm border border-slate-200 dark:border-white/10 text-lg hover:-translate-x-0.5'"
>
{{ Number(idx) + 1 }}
<q-icon name="chevron_left" />
</button>
<div class="flex flex-wrap items-center justify-center gap-2">
<button
v-for="q in visibleQuestions"
:key="q.id"
@click="jumpToQuestion(q.originalIndex)"
class="w-8 h-8 md:w-10 md:h-10 rounded-lg flex items-center justify-center text-xs md:text-sm font-bold transition-all border"
:class="getQuestionStatusClass(q.originalIndex, q.id)"
>
{{ q.originalIndex + 1 }}
</button>
</div>
<!-- Question Title -->
<!-- มถดไป (หนาถดไป) -->
<button
v-if="totalQuestionPages > 1"
@click="nextQuestionPage"
:disabled="questionPage === totalQuestionPages - 1"
class="w-8 h-8 md:w-10 md:h-10 rounded-lg flex items-center justify-center transition-all flex-shrink-0"
:class="questionPage === totalQuestionPages - 1 ? 'text-slate-300 dark:text-slate-600 bg-slate-100 dark:bg-white/5 cursor-not-allowed' : 'text-slate-600 dark:text-slate-300 bg-white dark:bg-[#1e293b] hover:bg-slate-100 dark:hover:bg-white/10 shadow-sm border border-slate-200 dark:border-white/10 text-lg hover:translate-x-0.5'"
>
<q-icon name="chevron_right" />
</button>
</div>
<!-- อคำถาม (Question Title) -->
<h3 class="text-xl md:text-2xl font-bold text-slate-900 dark:text-white mb-10 leading-relaxed">
{{ getLocalizedText(currentQuestion.question) }}
</h3>
<!-- Choices -->
<!-- วนการเลอกคำตอบ (Choices) -->
<div class="flex flex-col gap-4 mb-12">
<button
v-for="choice in currentQuestion.choices"
@ -533,7 +580,7 @@ const getCorrectChoiceId = (questionId: number) => {
<!-- Controls -->
<!-- มควบคมตางๆ (Controls) -->
<div class="flex justify-between items-center pt-8 border-t border-slate-100 dark:border-white/5">
<button
@click="prevQuestion"
@ -563,7 +610,7 @@ const getCorrectChoiceId = (questionId: number) => {
<!-- 3. RESULT SCREEN -->
<!-- 3. หนาผลลพธการทำแบบทดสอบ (RESULT SCREEN) -->
<div v-if="currentScreen === 'result'" class="w-full max-w-[640px] animate-fade-in py-12">
<div class="bg-white dark:!bg-[#1e293b] border border-slate-200 dark:border-white/5 rounded-[40px] p-10 shadow-2xl text-center relative overflow-hidden">
@ -586,7 +633,7 @@ const getCorrectChoiceId = (questionId: number) => {
</p>
</div>
<!-- Score Card -->
<!-- ตรแสดงคะแนน (Score Card) -->
<div class="bg-slate-50 dark:bg-[#0b121f] rounded-3xl p-6 mb-8 flex items-center justify-around border border-slate-100 dark:border-white/5">
<div class="text-center">
<div class="text-xs font-black text-slate-400 uppercase tracking-widest mb-1">{{ $t('quiz.scoreLabel') }}</div>
@ -626,7 +673,7 @@ const getCorrectChoiceId = (questionId: number) => {
</div>
</div>
<!-- 4. REVIEW SCREEN -->
<!-- 4. หนาทบทวนขอสอบ (REVIEW SCREEN) -->
<div v-if="currentScreen === 'review'" class="w-full max-w-[840px] animate-fade-in py-12 pb-24">
<div class="space-y-6">
<div
@ -652,7 +699,7 @@ const getCorrectChoiceId = (questionId: number) => {
'border-slate-100 dark:border-white/5 opacity-80 dark:opacity-40': userAnswers[question.id] !== choice.id && choice.id !== getCorrectChoiceId(question.id)
}"
>
<!-- Indicator Icon -->
<!-- ไอคอนสถานะ (Indicator Icon) -->
<div
class="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 font-bold text-sm border-2"
:class="{
@ -668,7 +715,7 @@ const getCorrectChoiceId = (questionId: number) => {
<span class="font-medium text-slate-700 dark:text-slate-300">{{ getLocalizedText(choice.text) }}</span>
<!-- Label Badge -->
<!-- ายแสดงสถานะ (Label Badge) -->
<div v-if="choice.id === getCorrectChoiceId(question.id)" class="ml-auto px-2 py-0.5 bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 text-xs font-bold rounded uppercase tracking-wider">
{{ $t('quiz.correctLabel', 'Correct') }}
</div>
@ -695,11 +742,11 @@ const getCorrectChoiceId = (questionId: number) => {
</main>
</div> <!-- Close quiz-shell -->
</div> <!-- ดสวนเปลอกขอสอบ (Close quiz-shell) -->
<!-- Question Navigator Sidebar/Floating (Desktop) - Outside Main Flow -->
<!-- Using QPageSticky properly inside q-page/q-layout context we added -->
<!-- อปอปแผนทคำถาม (เดสกอป) - อยนอกพนททำงานปกต (Question Navigator Sidebar/Floating (Desktop) - Outside Main Flow) -->
<!-- ใชงาน QPageSticky ใหกท (Using QPageSticky properly inside q-page/q-layout context we added) -->
<q-page-sticky
v-if="false"
position="top-right"

View file

@ -1,8 +1,8 @@
<script setup lang="ts">
/**
* @file [id].vue
* @description Dynamic Course Detail Page.
* Displays detailed information about a specific course based on the ID.
* @description หนาแสดงรายละเอยดคอร (Dynamic Course Detail Page)
* งขอมลคอรสเรยนตาม ID งมาใน URL และมมสำหรบចทะเบยน
*/
definePageMeta({
@ -114,7 +114,7 @@ useHead({
/>
</div>
<!-- Loading / Error State -->
<!-- วนแสดงผลขณะโหลดขอมลหรอเกดขอผดพลาด (Loading / Error State) -->
<div v-else class="text-center py-20">
<div v-if="error" class="text-red-500 mb-4">
<p class="font-bold">เกดขอผดพลาดในการโหลดขอม</p>

View file

@ -1,17 +1,17 @@
<script setup lang="ts">
/**
* @file announcements.vue
* @description Page displaying system and course-related announcements.
* Uses the default layout and requires authentication.
* @description หนาแสดงประกาศระบบและขาวสารเกยวกบคอรสเรยน (Page displaying system and course-related announcements.)
* ใชเลยเอาตเรมตนและตองตรวจสอบสทธ (Uses the default layout and requires authentication.)
*/
// Define page metadata: usage of 'default' layout and 'auth' middleware
// metadata : 'default' 'auth' (Define page metadata: usage of 'default' layout and 'auth' middleware)
definePageMeta({
layout: 'default',
middleware: 'auth'
})
// Set page title for SEO
// SEO (Set page title for SEO)
useHead({
title: 'ประกาศ - e-Learning'
})
@ -19,22 +19,22 @@ useHead({
<template>
<div class="page-container">
<!-- Page Header -->
<!-- วนหวหนาเว (Page Header) -->
<h1 class="text-3xl font-black mb-10 text-slate-900 dark:text-white">ประกาศ</h1>
<!--
Main Layout: 12-column Grid
- Left Column (span-8): Main announcements content
- Right Column (span-4): Categories/Filter sidebar
โครงสรางหล: กร 12 คอลมน (Main Layout: 12-column Grid)
- คอลมนาย (span-8): เนอหาประกาศหล (Left Column: Main announcements content)
- คอลมนขวา (span-4): แถบหมวดหม/วกรอง (Right Column: Categories/Filter sidebar)
-->
<div class="grid-12">
<!-- ==========================================
MAIN CONTENT AREA (Left)
นทเนอหาหล (านซาย) (MAIN CONTENT AREA (Left))
========================================== -->
<div class="col-span-8">
<!-- Feature 1: Critical System Announcement -->
<!-- หนาท 1: ประกาศระบบสำค (Feature 1: Critical System Announcement) -->
<div class="card mb-6">
<div class="flex items-center gap-2 mb-2">
<span class="status-pill status-warning">สำค</span>
@ -42,13 +42,13 @@ useHead({
</div>
<h2 class="text-xl font-bold mb-4 text-slate-900 dark:text-white">แจงปดปรบปรงระบบ</h2>
<p class="mb-4">เราจะทำการปดปรบปรงระบบในวนท 25 .. เวลา 02:00 - 04:00 . ขออภยในความไมสะดวก</p>
<!-- Attachment Block -->
<!-- วนแนบไฟล (Attachment Block) -->
<div class="flex items-center gap-2 p-3 rounded" style="background: var(--neutral-50); border: 1px solid var(--border-color); width: fit-content;">
<span>📎</span> <span class="text-sm font-bold">ตารางการปดปรบปร.pdf</span>
</div>
</div>
<!-- Announcement: UX/UI Course Update -->
<!-- ประกาศ: ปเดตคอร UX/UI (Announcement: UX/UI Course Update) -->
<div class="card mb-4" style="border-left: 4px solid var(--primary);">
<div class="flex justify-between items-start mb-2" style="flex-wrap: wrap; gap: 8px;">
<div>
@ -61,7 +61,7 @@ useHead({
<NuxtLink to="/browse/discovery" class="text-sm" style="color: var(--primary);">รายละเอยดคอร</NuxtLink>
</div>
<!-- Announcement: Accessibility (WCAG) Material -->
<!-- ประกาศ: เอกสารประกอบการเรยนดานการเขาถงเว (WCAG) (Announcement: Accessibility (WCAG) Material) -->
<div class="card mb-4" style="border-left: 4px solid var(--success);">
<div class="flex justify-between items-start mb-2" style="flex-wrap: wrap; gap: 8px;">
<div>
@ -71,13 +71,13 @@ useHead({
<span class="text-sm text-slate-600 dark:text-slate-400">22 .. 2024</span>
</div>
<p class="text-slate-700 dark:text-slate-300 mb-2">เราไดเพมไฟล PDF สรปเกณฑ WCAG 2.2 ในสวนของเอกสารประกอบการเรยนแล...</p>
<!-- Small Attachment -->
<!-- ไฟลแนบแบบเล (Small Attachment) -->
<div class="flex items-center gap-2 p-2 rounded mt-2" style="background: var(--neutral-50); border: 1px dotted var(--border-color); width: fit-content;">
<span>📄</span> <span class="text-xs">WCAG_2.2_Summary.pdf</span>
</div>
</div>
<!-- Announcement: React Course Update -->
<!-- ประกาศ: ปเดตคอร React (Announcement: React Course Update) -->
<div class="card mb-4" style="border-left: 4px solid var(--warning);">
<div class="flex justify-between items-start mb-2" style="flex-wrap: wrap; gap: 8px;">
<div>
@ -90,7 +90,7 @@ useHead({
<NuxtLink to="/classroom/learning" class="btn btn-secondary text-sm" style="width: fit-content;">เขาสบทเรยน</NuxtLink>
</div>
<!-- Announcement: General New Course -->
<!-- ประกาศ: คอรสใหมวไป (Announcement: General New Course) -->
<div class="card mb-4">
<div class="flex justify-between items-start mb-2" style="flex-wrap: wrap; gap: 8px;">
<h3 class="font-bold text-slate-900 dark:text-white">คอรสใหม: Advanced Python</h3>
@ -100,7 +100,7 @@ useHead({
<a href="#" class="text-sm" style="color: var(--primary);">านเพมเต</a>
</div>
<!-- Announcement: Platform Update -->
<!-- ประกาศ: ปเดตแพลตฟอร (Announcement: Platform Update) -->
<div class="card mb-4">
<div class="flex justify-between items-start mb-2" style="flex-wrap: wrap; gap: 8px;">
<h3 class="font-bold text-slate-900 dark:text-white">นดอนรบสไซนใหม!</h3>
@ -112,24 +112,24 @@ useHead({
</div>
<!-- ==========================================
SIDEBAR (Right)
Category Filters
แถบดานขาง (านขวา) (SIDEBAR (Right))
วกรองหมวดหม (Category Filters)
========================================== -->
<div class="col-span-4">
<div class="card">
<h3 class="font-bold mb-4 text-slate-900 dark:text-white">หมวดหม</h3>
<ul class="flex flex-col gap-2">
<!-- Filter Option: All -->
<!-- วเลอกตวกรอง: งหมด (Filter Option: All) -->
<li class="flex justify-between items-center p-2 rounded cursor-pointer" style="background: var(--neutral-50);">
<span>งหมด</span>
<span class="text-muted">15</span>
</li>
<!-- Filter Option: System Updates -->
<!-- วเลอกตวกรอง: ปเดตระบบ (Filter Option: System Updates) -->
<li class="flex justify-between items-center p-2 rounded cursor-pointer">
<span>ปเดตระบบ</span>
<span class="text-muted">3</span>
</li>
<!-- Filter Option: Course News -->
<!-- วเลอกตวกรอง: าวสารคอร (Filter Option: Course News) -->
<li class="flex justify-between items-center p-2 rounded cursor-pointer">
<span>าวสารคอร</span>
<span class="text-muted">11</span>

View file

@ -1,116 +1,322 @@
<script setup lang="ts">
/**
* @file home.vue
* @description หนาแดชบอรดหล (Dashboard)
* แสดงขอความตอนร และคอรสแนะนำสำหรบผเรยน
* @file index.vue
* @description หนาหลกแดชบอร (Dashboard Home)
*/
// 1. MetaData
definePageMeta({
layout: 'default',
middleware: 'auth'
})
layout: "default",
middleware: "auth",
});
useHead({
title: 'Dashboard - e-Learning'
})
title: "Dashboard - e-Learning Platform",
});
const { currentUser } = useAuth()
const { fetchCourses, getLocalizedText } = useCourse() // Import useCourse
const { fetchCategories } = useCategory() // Import useCategory
// 2. Composables
const { currentUser } = useAuth();
const { fetchCourses, fetchEnrolledCourses, getLocalizedText } = useCourse();
const { fetchCategories } = useCategory();
const { t } = useI18n();
const { t } = useI18n()
// Recommended Courses State
// ( 3 )
const recommendedCourses = ref<any[]>([])
// 3. (State)
const enrolledCourses = ref<any[]>([]);
const recommendedCourses = ref<any[]>([]);
const libraryCourses = ref<any[]>([]);
const categories = ref<any[]>([]);
const isLoading = ref(true);
// 4. (Data Initialization)
onMounted(async () => {
// 1. Fetch Categories for mapping
const catRes = await fetchCategories()
const catMap = new Map()
isLoading.value = true;
try {
const [catRes, enrollRes, courseRes, allCoursesRes] = await Promise.all([
fetchCategories(),
fetchEnrolledCourses({ limit: 10 }), //
fetchCourses({
limit: 3,
random: true,
forceRefresh: true,
is_recommended: true,
}), //
fetchCourses({ limit: 1000 }) //
]);
if (catRes.success) {
catRes.data?.forEach((c: any) => catMap.set(c.id, c.name))
categories.value = catRes.data || [];
}
// 2. Fetch 3 Random Courses from Server
// Server ( API parameter random limit)
const res = await fetchCourses({ random: true, limit: 3, forceRefresh: true, is_recommended: true })
const catMap = new Map();
categories.value.forEach((c: any) => catMap.set(c.id, c.name));
if (res.success && res.data?.length) {
recommendedCourses.value = res.data.map((c: any) => ({
const catIdMap = new Map();
if (allCoursesRes && allCoursesRes.success && allCoursesRes.data) {
allCoursesRes.data.forEach((c: any) => catIdMap.set(c.id, c.category_id));
}
// (Mapping Enrolled Courses)
if (enrollRes.success && enrollRes.data) {
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,
title: item.course.title,
thumbnail_url: item.course.thumbnail_url,
progress: item.progress_percentage || 0,
total_lessons: item.course.total_lessons || 0,
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);
}
// (Mapping Recommended Courses)
if (courseRes.success && courseRes.data) {
recommendedCourses.value = courseRes.data.map((c: any) => ({
id: c.id,
title: c.title,
category: catMap.get(c.category_id),
lessons: c.lessons,
image: c.thumbnail_url || '',
badge: '',
badgeType: ''
}))
description: c.description,
lessons: c.total_lessons || 0,
image: c.thumbnail_url || "",
rating: c.rating,
price: c.price,
is_free: c.is_free,
}));
}
} catch (err) {
console.error("Failed to load dashboard data", err);
} finally {
isLoading.value = false;
}
});
// 5. Computed
const heroCourse = computed(() => enrolledCourses.value[0] || null);
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>
<template>
<div class="page-container !pt-4">
<!-- Welcome Header Section (Minimalist) -->
<div class="flex items-center gap-6 mb-10 py-2 animate-fade-in">
<!-- Avatar with premium shadow -->
<div class="relative group cursor-pointer" @click="$router.push('/dashboard/profile')">
<div class="absolute -inset-1 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-full blur opacity-25 group-hover:opacity-50 transition duration-1000 group-hover:duration-200"></div>
<q-avatar size="84px" class="relative shadow-2xl ring-4 ring-white dark:ring-slate-900 transition-all duration-500 overflow-hidden">
<img
:src="currentUser?.photoURL || 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y'"
class="object-cover"
/>
</q-avatar>
<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">
<!-- คอลมนาย (เนอหาหล) -->
<div class="xl:col-span-2 space-y-6">
<!-- ายตอนร (Welcome Banner) -->
<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)]">
<!-- ลวดลายพนหลงและดาวตกแต -->
<div class="absolute inset-0 bg-grid-pattern opacity-10 md:opacity-20 pointer-events-none"></div>
<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">
<q-icon name="auto_awesome" size="32px" md-size="48px" class="text-white" />
</div>
<div class="flex flex-col">
<h1 class="text-2xl md:text-4xl font-black text-slate-900 dark:text-white tracking-tight leading-tight mb-1">
{{ $t('dashboard.welcomeTitle') }}, {{ currentUser?.firstName }}!
</h1>
<div class="flex items-center gap-2">
<span class="text-slate-500 dark:text-slate-400 text-sm font-medium">{{ $t('dashboard.welcomeSubtitle') }}</span>
</div>
<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>
<!-- Main Content Area -->
<!-- การดหมวดหมวน (3 Stats Cards) -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<!-- การออกแบบ -->
<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-[#E9EFFD] dark:bg-blue-900/30 text-[#3B6BE8] flex items-center justify-center shrink-0">
<q-icon name="palette" size="24px" />
</div>
<div>
<!-- Section: Recommended Courses -->
<div class="mb-6">
<h2 class="text-xl font-black flex items-center gap-3 tracking-tight text-slate-900 dark:text-white">
<span class="w-1 h-6 bg-emerald-500 rounded-full shadow-sm shadow-emerald-500/50"/>
{{ $t('menu.recommendedCourses') }}
</h2>
<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>
<!-- Recommended Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 pb-20">
<CourseCard
v-for="course in recommendedCourses"
:key="course.id"
v-bind="course"
<!-- วนเรยนตอจากครงกอน (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>
</div>
<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">
<div class="w-full md:w-[35%] aspect-[4/3] rounded-[1.5rem] overflow-hidden flex-shrink-0 bg-slate-200">
<img :src="heroCourse.thumbnail_url" class="w-full h-full object-cover" />
</div>
<div class="flex-1 w-full flex flex-col">
<div class="flex items-center justify-between mb-3">
<!-- ายบอกหมวดหม (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>
<h3 class="text-2xl font-bold text-slate-900 dark:text-white mb-2 leading-snug line-clamp-2">
{{ getLocalizedText(heroCourse.title) || 'Advanced UI/UX Design มาสเตอร์คลาส' }}
</h3>
<!-- ไมแสดงเลขบทเรยนตามทไดบคำขอมา (Removed Lesson Title/Number as per request) -->
<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 class="h-2.5 w-full bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
<div class="h-full bg-[#3B6BE8] rounded-full transition-all duration-500" :style="{ width: `${heroCourse.progress || 0}%` }"></div>
</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 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>
</div>
</div>
<!-- คอลมนขวา (แถบขอมลโปรไฟลและคอรสแนะนำ) -->
<div class="xl:col-span-1 space-y-6">
<!-- ดเจตโปรไฟลใช -->
<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">
<!-- นหลงตกแต (decorative bg) -->
<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>
<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"
/>
</div>
<!-- Loading State for Recommended -->
<div v-if="recommendedCourses.length === 0" class="flex justify-center py-10 opacity-50">
<div class="animate-pulse">Loading recommendations...</div>
<h2 class="text-xl font-bold text-slate-900 dark:text-white relative z-10 tracking-tight">
{{ currentUser?.firstName ? `${currentUser.firstName} ${currentUser.lastName || ''}` : 'ผู้ใช้งาน' }}
</h2>
<p class="text-slate-500 dark:text-slate-400 text-xs mt-1 relative z-10 font-medium tracking-wide">{{ $t('common.student') }}</p>
<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">{{ enrolledCourses.length || 0 }}</span>
<span class="text-slate-400 text-[10px] font-bold tracking-wider">{{ $t('sidebar.onlineCourses') }}</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">{{ enrolledCourses.filter(c => c.progress >= 100).length || 0 }}</span>
<span class="text-slate-400 text-[10px] font-bold tracking-wider z-10">{{ $t('myCourses.filterCompleted') }}</span>
</div>
</div>
</div>
<!-- ดเจตคอรสแนะนำ -->
<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">
<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>
<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>
</template>
<style scoped>
@keyframes fade-in {
from { opacity: 0; transform: translateX(-20px); }
to { opacity: 1; transform: translateX(0); }
}
.animate-fade-in {
animation: fade-in 0.8s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
.bg-grid-pattern {
background-image:
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);
background-size: 24px 24px;
}
</style>

View file

@ -1,293 +1,369 @@
<script setup lang="ts">
/**
* @file my-courses.vue
* @description My Courses Page.
* Displays enrolled courses with filters for progress/completed.
* Handles enrollment success modals and certificate downloads.
* @description หนาคอรสของฉ (My Enrolled Courses)
*/
// 1. MetaData
definePageMeta({
layout: 'default',
middleware: 'auth'
})
useHead({
title: 'คอร์สของฉัน - e-Learning'
})
const { t, locale } = useI18n()
const route = useRoute()
const showEnrollModal = ref(false)
const activeFilter = ref<'all' | 'progress' | 'completed'>('all')
const quasar = useQuasar()
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 activeCategory = ref<number | 'all'>('all')
const viewMode = ref<'grid' | 'list'>('grid')
const showEnrollModal = ref(false)
// Check URL query parameters to show 'Enrollment Success' modal
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) => {
// 4. (Helper Functions)
const getLocalizedText = (text: any) => {
if (!text) return ''
if (typeof text === 'string') return text
const currentLocale = locale.value as 'th' | 'en'
return text[currentLocale] || text.th || text.en || ''
}
// Data Handling
const { fetchEnrolledCourses, getCertificate, generateCertificate } = useCourse()
const enrolledCourses = ref<any[]>([])
const isLoading = ref(false)
const isDownloadingCert = ref(false)
const loadEnrolledCourses = async () => {
isLoading.value = true
// FIX: For 'progress' tab, we want both ENROLLED and IN_PROGRESS.
// Since API takes single status, we fetch ALL and filter locally for 'progress'.
const apiStatus = activeFilter.value === 'completed'
? 'COMPLETED'
: undefined // 'all' or 'progress' -> fetch all
const res = await fetchEnrolledCourses({
status: apiStatus
})
if (res.success) {
let courses = (res.data || [])
// 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')
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'
}
enrolledCourses.value = courses.map(item => ({
// 5. (Data Loading)
const loadData = async () => {
isLoading.value = true
try {
const [catRes, courseRes, allCoursesRes] = await Promise.all([
fetchCategories(),
fetchEnrolledCourses({}),
fetchCourses({ limit: 1000 })
])
if (catRes.success) allCategories.value = catRes.data || []
const catMap = new Map()
allCategories.value.forEach(c => catMap.set(c.id, c))
const catIdMap = new Map()
if (allCoursesRes && allCoursesRes.success && allCoursesRes.data) {
allCoursesRes.data.forEach((c: any) => catIdMap.set(c.id, c.category_id))
}
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,
enrollment_id: item.id,
title: item.course.title,
progress: item.progress_percentage || 0,
lessons: item.course.total_lessons || 0,
lessons: item.course.total_lessons || 10,
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
}
})
}
} catch (err) {
console.error("Failed to load enrolled courses", err)
} finally {
isLoading.value = false
}
}
// Watch filter changes to reload
watch(activeFilter, () => {
loadEnrolledCourses()
// 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()
})
// Client-side Search Filtering
const filteredEnrolledCourses = computed(() => {
if (!searchQuery.value) return enrolledCourses.value
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()
return enrolledCourses.value.filter(c => {
const title = getLocalizedText(c.title).toLowerCase()
return title.includes(query)
result = result.filter(c => getLocalizedText(c.title).toLowerCase().includes(query))
}
return result
})
})
onMounted(() => {
if (route.query.enrolled) {
showEnrollModal.value = true
}
loadEnrolledCourses()
})
// 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 {
downloadingCourseId.value = null
}
}
const validCourseId = computed(() => {
const cid = route.query.course_id
if (!cid || cid === 'undefined' || cid === 'null' || cid === 'NaN') return null
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>
<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="flex flex-col gap-6 mb-10">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
<h1 class="text-3xl font-black text-slate-900 dark:text-white flex items-center gap-3">
<span class="w-1.5 h-8 bg-blue-600 rounded-full shadow-sm shadow-blue-500/50"></span>
{{ $t('sidebar.myCourses') }}
</h1>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<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">
<!-- วนรปภาพหนาปก (Image) -->
<div class="w-full sm:w-[160px] h-[120px] rounded-[1.25rem] overflow-hidden bg-slate-200 shrink-0 relative group">
<img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"/>
<!-- เลเยอรเลนวโอดวน (Quick play overlay) -->
<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 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 class="flex items-center justify-between gap-4 mt-auto">
<div class="flex-1">
<div class="flex items-center gap-2 text-[11px] font-bold text-slate-700 dark:text-slate-300 mb-1.5 tracking-wide">
{{ $t('course.progress') }}: {{ course.progress }}%
</div>
<div class="h-[6px] w-full bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
<div class="h-full bg-[#3B6BE8] rounded-full transition-all duration-500" :style="{ width: `${course.progress}%` }"></div>
</div>
</div>
<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>
</div>
</div>
</div>
</div>
<!-- Filter Tabs (Horizontal Bar) -->
<div class="flex flex-wrap items-center gap-2">
<q-btn
v-for="filter in ['all', 'progress', 'completed']"
:key="filter"
@click="activeFilter = filter as any"
flat
rounded
dense
class="px-4 font-bold transition-all text-xs uppercase tracking-widest"
:class="activeFilter === filter ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' : 'bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-white/5'"
:label="$t(`myCourses.filter${filter.charAt(0).toUpperCase() + filter.slice(1)}`)"
/>
<!-- วนท 2: คอรสของฉ (My Courses) -->
<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">
<!-- วนหวและการคนหา -->
<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">{{ $t('myCourses.title') }}</h2>
<div class="flex flex-wrap sm:flex-nowrap items-center gap-3 w-full md:w-auto">
<div class="relative w-full sm:w-[260px] flex-1">
<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]" />
<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')" />
</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>
<!-- Courses Grid -->
<div v-if="isLoading" class="flex justify-center py-20">
<q-spinner size="3rem" color="primary" />
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<template v-for="course in filteredEnrolledCourses" :key="course.id">
<!-- In Progress Course Card -->
<CourseCard
v-if="!course.completed"
:id="course.id"
:title="course.title"
:progress="course.progress"
:image="course.thumbnail_url"
show-continue
:show-view-details="false"
/>
<!-- Completed Course Card -->
<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 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>
<!-- Empty State -->
<div v-if="!isLoading && filteredEnrolledCourses.length === 0" class="flex flex-col items-center justify-center py-20 bg-slate-50 dark:bg-slate-800/50 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-700 mt-4">
<q-icon v-if="searchQuery" name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" />
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">
{{ searchQuery ? $t('discovery.emptyTitle') : $t('myCourses.emptyTitle') }}
</h3>
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">
{{ searchQuery ? $t('discovery.emptyDesc') : $t('myCourses.emptyDesc') }}
</p>
<NuxtLink v-if="!searchQuery" to="/browse/discovery" class="mt-6 px-6 py-2 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors">{{ $t('myCourses.goToDiscovery') }}</NuxtLink>
<button v-else class="mt-4 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''">
{{ $t('discovery.showAll') }}
<button
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>
<!-- MODAL: Enrollment Success -->
<!-- Active View -->
<div v-if="isLoading" class="flex justify-center py-24">
<q-spinner size="3rem" color="primary" />
</div>
<div v-else-if="filteredEnrolledCourses.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 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}`)">
<!-- วนรปภาพหนาปก (Thumbnail) -->
<div class="relative w-full aspect-[4/3] 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" />
<!-- ายหมวดหมบนมมซายของร (Badge inside Image Map Top Left) -->
<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 }}
</div>
</div>
<!-- วการ (Card Body) -->
<div class="p-5 flex flex-col flex-1">
<h3 class="font-bold text-slate-900 dark:text-white text-[14px] leading-snug line-clamp-2 mb-3">{{ getLocalizedText(course.title) }}</h3>
<div class="mt-auto flex items-center justify-between gap-4">
<div class="flex-1">
<div class="text-[10px] font-bold text-slate-700 dark:text-slate-300 mb-1.5 tracking-wide">{{ $t('course.progress') }}: {{ course.progress }}%</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 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>
<!-- มเรยนตอหรอเรยนซ (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>
<!-- หนาตางแจงเตอน: ลงทะเบยนสำเร (MODAL: Enrollment Success) -->
<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">
<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>
<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>
<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>
<div class="flex flex-col gap-3">
<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')"
/>
<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"
/>
<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')" />
<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>
</q-card>
</q-dialog>
</div>
</template>
<style scoped>
/* Custom Font for Signature/Name if desired */
.font-handwriting {
font-family: 'Dancing Script', cursive, serif; /* Fallback */
}
</style>

Some files were not shown because too many files have changed in this diff Show more