Compare commits

...

21 commits

Author SHA1 Message Date
supalerk-ar66
e02da48f7c feat: Implement the course discovery and catalog page, including filtering, search, and course detail view.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 16s
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-03-06 17:34:27 +07:00
Missez
ae32cfebe4 feat: add utils/date.ts and stores api/user/me
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 56s
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-03-06 17:33:01 +07:00
Missez
ea442d7815 del readme 2026-03-06 15:58:58 +07:00
Missez
ac768a3df4 add readme testresult
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 53s
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-03-06 15:53:13 +07:00
Missez
9e4fcbf04e Add tests result 2026-03-06 15:47:43 +07:00
supalerk-ar66
853c141910 feat: Add i18n support with English and Thai locales and introduce new browse pages.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 51s
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-03-06 13:33:58 +07:00
supalerk-ar66
b0b665f588 feat: Implement E2E tests for authentication, student account, discovery, and classroom features, alongside new browse pages and a useAuth composable.
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 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
2026-03-06 12:43:49 +07:00
Missez
0205aab461 feat: Introduce core authentication service, several new admin management pages, and instructor feature tests.
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 52s
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-03-06 11:24:10 +07:00
supalerk-ar66
000f9eea5c feat: Add Dockerfile for containerization and package-lock.json for dependency locking.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 1m31s
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-03-06 09:23:15 +07:00
JakkrapartXD
522a0eec8a refactor: update user identification to pass userId directly to services instead of JWT tokens.
Some checks failed
Build and Deploy Backend / Build Backend Docker Image (push) Successful in 48s
Build and Deploy Backend / Deploy E-learning Backend to Dev Server (push) Successful in 9s
Build and Deploy Backend / Notify Deployment Status (push) Successful in 2s
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Failing after 33s
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 1s
2026-03-04 17:19:58 +07:00
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 25s
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 1s
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
277 changed files with 13575 additions and 5709 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/bcrypt": "^5.0.2",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.7", "@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^1.4.12", "@types/multer": "^1.4.12",
"@types/node": "^22.10.5", "@types/node": "^22.10.5",

171
Backend/pnpm-lock.yaml generated
View file

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

View file

@ -24,9 +24,7 @@ export class AdminCourseApprovalController {
@Response('401', 'Unauthorized') @Response('401', 'Unauthorized')
@Response('403', 'Forbidden - Admin only') @Response('403', 'Forbidden - Admin only')
public async listPendingCourses(@Request() request: any): Promise<ListPendingCoursesResponse> { public async listPendingCourses(@Request() request: any): Promise<ListPendingCoursesResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await AdminCourseApprovalService.listPendingCourses(request.user.id);
if (!token) throw new ValidationError('No token provided');
return await AdminCourseApprovalService.listPendingCourses(token);
} }
/** /**
@ -41,9 +39,7 @@ export class AdminCourseApprovalController {
@Response('403', 'Forbidden - Admin only') @Response('403', 'Forbidden - Admin only')
@Response('404', 'Course not found') @Response('404', 'Course not found')
public async getCourseDetail(@Request() request: any, @Path() courseId: number): Promise<GetCourseDetailForAdminResponse> { public async getCourseDetail(@Request() request: any, @Path() courseId: number): Promise<GetCourseDetailForAdminResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await AdminCourseApprovalService.getCourseDetail(request.user.id, courseId);
if (!token) throw new ValidationError('No token provided');
return await AdminCourseApprovalService.getCourseDetail(token, courseId);
} }
/** /**
@ -62,10 +58,7 @@ export class AdminCourseApprovalController {
@Request() request: any, @Request() request: any,
@Path() courseId: number @Path() courseId: number
): Promise<ApproveCourseResponse> { ): Promise<ApproveCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await AdminCourseApprovalService.approveCourse(request.user.id, courseId, undefined);
if (!token) throw new ValidationError('No token provided');
return await AdminCourseApprovalService.approveCourse(token, courseId, undefined);
} }
/** /**
@ -85,13 +78,10 @@ export class AdminCourseApprovalController {
@Path() courseId: number, @Path() courseId: number,
@Body() body: RejectCourseBody @Body() body: RejectCourseBody
): Promise<RejectCourseResponse> { ): Promise<RejectCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
// Validate body // Validate body
const { error } = RejectCourseValidator.validate(body); const { error } = RejectCourseValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await AdminCourseApprovalService.rejectCourse(token, courseId, body.comment); return await AdminCourseApprovalService.rejectCourse(request.user.id, courseId, body.comment);
} }
} }

View file

@ -40,11 +40,6 @@ export class AuditController {
@Query() page?: number, @Query() page?: number,
@Query() limit?: number @Query() limit?: number
): Promise<ListAuditLogsResponse> { ): Promise<ListAuditLogsResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await auditService.getLogs({ return await auditService.getLogs({
userId, userId,
action, action,
@ -72,11 +67,6 @@ export class AuditController {
@Request() request: any, @Request() request: any,
@Path() logId: number @Path() logId: number
): Promise<AuditLogResponse> { ): Promise<AuditLogResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
const log = await auditService.getLogById(logId); const log = await auditService.getLogById(logId);
if (!log) { if (!log) {
throw new ValidationError('Audit log not found'); throw new ValidationError('Audit log not found');
@ -94,11 +84,6 @@ export class AuditController {
@Response('401', 'Unauthorized') @Response('401', 'Unauthorized')
@Response('403', 'Forbidden - Admin only') @Response('403', 'Forbidden - Admin only')
public async getAuditStats(@Request() request: any): Promise<AuditLogStats> { public async getAuditStats(@Request() request: any): Promise<AuditLogStats> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await auditService.getStats(); return await auditService.getStats();
} }
@ -118,11 +103,6 @@ export class AuditController {
@Path() entityType: string, @Path() entityType: string,
@Path() entityId: number @Path() entityId: number
): Promise<AuditLogResponse[]> { ): Promise<AuditLogResponse[]> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await auditService.getEntityHistory(entityType, entityId); return await auditService.getEntityHistory(entityType, entityId);
} }
@ -142,11 +122,6 @@ export class AuditController {
@Path() userId: number, @Path() userId: number,
@Query() limit?: number @Query() limit?: number
): Promise<AuditLogResponse[]> { ): Promise<AuditLogResponse[]> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await auditService.getUserActivity(userId, limit || 50); return await auditService.getUserActivity(userId, limit || 50);
} }
@ -164,11 +139,6 @@ export class AuditController {
@Request() request: any, @Request() request: any,
@Query() days: number = 90 @Query() days: number = 90
): Promise<{ deleted: number; message: string }> { ): Promise<{ deleted: number; message: string }> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
if (days < 6) { if (days < 6) {
throw new ValidationError('Cannot delete logs newer than 6 days'); throw new ValidationError('Cannot delete logs newer than 6 days');
} }

View file

@ -33,32 +33,6 @@ export class AuthController {
data: { data: {
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
user: {
id: 1,
username: 'admin',
email: 'admin@elearning.local',
email_verified_at: new Date('2024-01-01T00:00:00Z'),
updated_at: new Date('2024-01-01T00:00:00Z'),
created_at: new Date('2024-01-01T00:00:00Z'),
role: {
code: 'ADMIN',
name: {
th: 'ผู้ดูแลระบบ',
en: 'Administrator'
}
},
profile: {
prefix: {
th: 'นาย',
en: 'Mr.'
},
first_name: 'Admin',
last_name: 'User',
phone: null,
avatar_url: null,
birth_date: null
}
}
} }
}) })
public async login(@Body() body: LoginRequest): Promise<LoginResponse> { public async login(@Body() body: LoginRequest): Promise<LoginResponse> {

View file

@ -27,13 +27,11 @@ export class CategoriesAdminController {
@SuccessResponse('200', 'Category created successfully') @SuccessResponse('200', 'Category created successfully')
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
public async createCategory(@Request() request: any, @Body() body: createCategory): Promise<createCategoryResponse> { public async createCategory(@Request() request: any, @Body() body: createCategory): Promise<createCategoryResponse> {
const token = request.headers.authorization?.replace('Bearer ', '') || '';
// Validate body // Validate body
const { error } = CreateCategoryValidator.validate(body); const { error } = CreateCategoryValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await this.categoryService.createCategory(token, body); return await this.categoryService.createCategory(request.user.id, body);
} }
@Put('{id}') @Put('{id}')
@ -41,13 +39,11 @@ export class CategoriesAdminController {
@SuccessResponse('200', 'Category updated successfully') @SuccessResponse('200', 'Category updated successfully')
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
public async updateCategory(@Request() request: any, @Body() body: updateCategory): Promise<updateCategoryResponse> { public async updateCategory(@Request() request: any, @Body() body: updateCategory): Promise<updateCategoryResponse> {
const token = request.headers.authorization?.replace('Bearer ', '') || '';
// Validate body // Validate body
const { error } = UpdateCategoryValidator.validate(body); const { error } = UpdateCategoryValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await this.categoryService.updateCategory(token, body.id, body); return await this.categoryService.updateCategory(request.user.id, body.id, body);
} }
@Delete('{id}') @Delete('{id}')
@ -55,7 +51,6 @@ export class CategoriesAdminController {
@SuccessResponse('200', 'Category deleted successfully') @SuccessResponse('200', 'Category deleted successfully')
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
public async deleteCategory(@Request() request: any, @Path() id: number): Promise<deleteCategoryResponse> { public async deleteCategory(@Request() request: any, @Path() id: number): Promise<deleteCategoryResponse> {
const token = request.headers.authorization?.replace('Bearer ', '') || ''; return await this.categoryService.deleteCategory(request.user.id, id);
return await this.categoryService.deleteCategory(token, id);
} }
} }

View file

@ -1,5 +1,4 @@
import { Get, Post, Route, Tags, SuccessResponse, Response, Security, Path, Request } from 'tsoa'; import { Get, Post, Route, Tags, SuccessResponse, Response, Security, Path, Request } from 'tsoa';
import { ValidationError } from '../middleware/errorHandler';
import { CertificateService } from '../services/certificate.service'; import { CertificateService } from '../services/certificate.service';
import { import {
GenerateCertificateResponse, GenerateCertificateResponse,
@ -21,9 +20,7 @@ export class CertificateController {
@SuccessResponse('200', 'Certificates retrieved successfully') @SuccessResponse('200', 'Certificates retrieved successfully')
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
public async listMyCertificates(@Request() request: any): Promise<ListMyCertificatesResponse> { public async listMyCertificates(@Request() request: any): Promise<ListMyCertificatesResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await this.certificateService.listMyCertificates({ userId: request.user.id });
if (!token) throw new ValidationError('No token provided');
return await this.certificateService.listMyCertificates({ token });
} }
/** /**
@ -37,9 +34,7 @@ export class CertificateController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Response('404', 'Certificate not found') @Response('404', 'Certificate not found')
public async getCertificate(@Request() request: any, @Path() courseId: number): Promise<GetCertificateResponse> { public async getCertificate(@Request() request: any, @Path() courseId: number): Promise<GetCertificateResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await this.certificateService.getCertificate({ userId: request.user.id, course_id: courseId });
if (!token) throw new ValidationError('No token provided');
return await this.certificateService.getCertificate({ token, course_id: courseId });
} }
/** /**
@ -54,8 +49,6 @@ export class CertificateController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Response('404', 'Enrollment not found') @Response('404', 'Enrollment not found')
public async generateCertificate(@Request() request: any, @Path() courseId: number): Promise<GenerateCertificateResponse> { public async generateCertificate(@Request() request: any, @Path() courseId: number): Promise<GenerateCertificateResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await this.certificateService.generateCertificate({ userId: request.user.id, course_id: courseId });
if (!token) throw new ValidationError('No token provided');
return await this.certificateService.generateCertificate({ token, course_id: courseId });
} }
} }

View file

@ -65,14 +65,11 @@ export class ChaptersLessonInstructorController {
@Path() courseId: number, @Path() courseId: number,
@Body() body: CreateChapterBody @Body() body: CreateChapterBody
): Promise<CreateChapterResponse> { ): Promise<CreateChapterResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = CreateChapterValidator.validate(body); const { error } = CreateChapterValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.createChapter({ return await chaptersLessonService.createChapter({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
title: body.title, title: body.title,
description: body.description, description: body.description,
@ -96,14 +93,11 @@ export class ChaptersLessonInstructorController {
@Path() chapterId: number, @Path() chapterId: number,
@Body() body: UpdateChapterBody @Body() body: UpdateChapterBody
): Promise<UpdateChapterResponse> { ): Promise<UpdateChapterResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = UpdateChapterValidator.validate(body); const { error } = UpdateChapterValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.updateChapter({ return await chaptersLessonService.updateChapter({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
chapter_id: chapterId, chapter_id: chapterId,
...body, ...body,
@ -125,9 +119,7 @@ export class ChaptersLessonInstructorController {
@Path() courseId: number, @Path() courseId: number,
@Path() chapterId: number @Path() chapterId: number
): Promise<DeleteChapterResponse> { ): Promise<DeleteChapterResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await chaptersLessonService.deleteChapter({ userId: request.user.id, course_id: courseId, chapter_id: chapterId });
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.deleteChapter({ token, course_id: courseId, chapter_id: chapterId });
} }
/** /**
@ -143,14 +135,11 @@ export class ChaptersLessonInstructorController {
@Path() chapterId: number, @Path() chapterId: number,
@Body() body: ReorderChapterBody @Body() body: ReorderChapterBody
): Promise<ReorderChapterResponse> { ): Promise<ReorderChapterResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = ReorderChapterValidator.validate(body); const { error } = ReorderChapterValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.reorderChapter({ return await chaptersLessonService.reorderChapter({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
chapter_id: chapterId, chapter_id: chapterId,
sort_order: body.sort_order, sort_order: body.sort_order,
@ -174,9 +163,7 @@ export class ChaptersLessonInstructorController {
@Path() chapterId: number, @Path() chapterId: number,
@Path() lessonId: number @Path() lessonId: number
): Promise<GetLessonResponse> { ): Promise<GetLessonResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await chaptersLessonService.getLesson({ userId: request.user.id, course_id: courseId, chapter_id: chapterId, lesson_id: lessonId });
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.getLesson({ token, course_id: courseId, chapter_id: chapterId, lesson_id: lessonId });
} }
/** /**
@ -192,14 +179,11 @@ export class ChaptersLessonInstructorController {
@Path() chapterId: number, @Path() chapterId: number,
@Body() body: CreateLessonBody @Body() body: CreateLessonBody
): Promise<CreateLessonResponse> { ): Promise<CreateLessonResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = CreateLessonValidator.validate(body); const { error } = CreateLessonValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.createLesson({ return await chaptersLessonService.createLesson({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
chapter_id: chapterId, chapter_id: chapterId,
title: body.title, title: body.title,
@ -223,14 +207,11 @@ export class ChaptersLessonInstructorController {
@Path() lessonId: number, @Path() lessonId: number,
@Body() body: UpdateLessonBody @Body() body: UpdateLessonBody
): Promise<UpdateLessonResponse> { ): Promise<UpdateLessonResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = UpdateLessonValidator.validate(body); const { error } = UpdateLessonValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.updateLesson({ return await chaptersLessonService.updateLesson({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
chapter_id: chapterId, chapter_id: chapterId,
lesson_id: lessonId, lesson_id: lessonId,
@ -258,9 +239,7 @@ export class ChaptersLessonInstructorController {
@Path() chapterId: number, @Path() chapterId: number,
@Path() lessonId: number @Path() lessonId: number
): Promise<DeleteLessonResponse> { ): Promise<DeleteLessonResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await chaptersLessonService.deleteLesson({ userId: request.user.id, course_id: courseId, chapter_id: chapterId, lesson_id: lessonId });
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.deleteLesson({ token, course_id: courseId, chapter_id: chapterId, lesson_id: lessonId });
} }
/** /**
@ -276,14 +255,11 @@ export class ChaptersLessonInstructorController {
@Path() chapterId: number, @Path() chapterId: number,
@Body() body: ReorderLessonsBody @Body() body: ReorderLessonsBody
): Promise<ReorderLessonsResponse> { ): Promise<ReorderLessonsResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = ReorderLessonsValidator.validate(body); const { error } = ReorderLessonsValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.reorderLessons({ return await chaptersLessonService.reorderLessons({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
chapter_id: chapterId, chapter_id: chapterId,
lesson_id: body.lesson_id, lesson_id: body.lesson_id,
@ -309,14 +285,11 @@ export class ChaptersLessonInstructorController {
@Path() lessonId: number, @Path() lessonId: number,
@Body() body: AddQuestionBody @Body() body: AddQuestionBody
): Promise<AddQuestionResponse> { ): Promise<AddQuestionResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = AddQuestionValidator.validate(body); const { error } = AddQuestionValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.addQuestion({ return await chaptersLessonService.addQuestion({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
...body, ...body,
@ -338,14 +311,11 @@ export class ChaptersLessonInstructorController {
@Path() questionId: number, @Path() questionId: number,
@Body() body: UpdateQuestionBody @Body() body: UpdateQuestionBody
): Promise<UpdateQuestionResponse> { ): Promise<UpdateQuestionResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = UpdateQuestionValidator.validate(body); const { error } = UpdateQuestionValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.updateQuestion({ return await chaptersLessonService.updateQuestion({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
question_id: questionId, question_id: questionId,
@ -364,14 +334,11 @@ export class ChaptersLessonInstructorController {
@Path() questionId: number, @Path() questionId: number,
@Body() body: ReorderQuestionBody @Body() body: ReorderQuestionBody
): Promise<ReorderQuestionResponse> { ): Promise<ReorderQuestionResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = ReorderQuestionValidator.validate(body); const { error } = ReorderQuestionValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.reorderQuestion({ return await chaptersLessonService.reorderQuestion({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
question_id: questionId, question_id: questionId,
@ -393,10 +360,8 @@ export class ChaptersLessonInstructorController {
@Path() lessonId: number, @Path() lessonId: number,
@Path() questionId: number @Path() questionId: number
): Promise<DeleteQuestionResponse> { ): Promise<DeleteQuestionResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.deleteQuestion({ return await chaptersLessonService.deleteQuestion({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
question_id: questionId, question_id: questionId,
@ -417,14 +382,11 @@ export class ChaptersLessonInstructorController {
@Path() lessonId: number, @Path() lessonId: number,
@Body() body: UpdateQuizBody @Body() body: UpdateQuizBody
): Promise<UpdateQuizResponse> { ): Promise<UpdateQuizResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = UpdateQuizValidator.validate(body); const { error } = UpdateQuizValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.updateQuiz({ return await chaptersLessonService.updateQuiz({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
...body, ...body,

View file

@ -22,12 +22,10 @@ import {
GetCourseApprovalHistoryResponse, GetCourseApprovalHistoryResponse,
setCourseDraftResponse, setCourseDraftResponse,
CloneCourseResponse, CloneCourseResponse,
GetAllMyStudentsResponse,
} from '../types/CoursesInstructor.types'; } from '../types/CoursesInstructor.types';
import { CreateCourseValidator, UpdateCourseValidator, CloneCourseValidator } from "../validators/CoursesInstructor.validator"; import { CreateCourseValidator, UpdateCourseValidator, CloneCourseValidator } from "../validators/CoursesInstructor.validator";
import jwt from 'jsonwebtoken';
import { config } from '../config';
@Route('api/instructors/courses') @Route('api/instructors/courses')
@Tags('CoursesInstructor') @Tags('CoursesInstructor')
export class CoursesInstructorController { export class CoursesInstructorController {
@ -45,11 +43,7 @@ export class CoursesInstructorController {
@Request() request: any, @Request() request: any,
@Query() status?: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'ARCHIVED' @Query() status?: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'ARCHIVED'
): Promise<ListMyCourseResponse> { ): Promise<ListMyCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await CoursesInstructorService.listMyCourses({ userId: request.user.id, status });
if (!token) {
throw new ValidationError('No token provided');
}
return await CoursesInstructorService.listMyCourses({ token, status });
} }
/** /**
@ -67,9 +61,23 @@ export class CoursesInstructorController {
@Path() courseId: number, @Path() courseId: number,
@Query() query: string @Query() query: string
): Promise<SearchInstructorResponse> { ): Promise<SearchInstructorResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await CoursesInstructorService.searchInstructors({ userId: request.user.id, query, course_id: courseId });
if (!token) throw new ValidationError('No token provided'); }
return await CoursesInstructorService.searchInstructors({ token, query, course_id: courseId });
/**
* instructor
* Get all students enrolled in all of instructor's courses
*
* @returns total_enrolled total_completed
*/
@Get('my-students')
@Security('jwt', ['instructor'])
@SuccessResponse('200', 'Students retrieved successfully')
@Response('401', 'Unauthorized')
public async getMyAllStudents(
@Request() request: any
): Promise<GetAllMyStudentsResponse> {
return await CoursesInstructorService.getMyAllStudents(request.user.id);
} }
/** /**
@ -83,11 +91,7 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Response('404', 'Course not found') @Response('404', 'Course not found')
public async getMyCourse(@Request() request: any, @Path() courseId: number): Promise<GetMyCourseResponse> { public async getMyCourse(@Request() request: any, @Path() courseId: number): Promise<GetMyCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await CoursesInstructorService.getmyCourse({ userId: request.user.id, course_id: courseId });
if (!token) {
throw new ValidationError('No token provided');
}
return await CoursesInstructorService.getmyCourse({ token, course_id: courseId });
} }
/** /**
@ -101,13 +105,10 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Response('404', 'Course not found') @Response('404', 'Course not found')
public async updateCourse(@Request() request: any, @Path() courseId: number, @Body() body: UpdateMyCourse): Promise<UpdateMyCourseResponse> { public async updateCourse(@Request() request: any, @Path() courseId: number, @Body() body: UpdateMyCourse): Promise<UpdateMyCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = UpdateCourseValidator.validate(body.data); const { error } = UpdateCourseValidator.validate(body.data);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await CoursesInstructorService.updateCourse(token, courseId, body.data); return await CoursesInstructorService.updateCourse(request.user.id, courseId, body.data);
} }
/** /**
@ -126,10 +127,6 @@ export class CoursesInstructorController {
@FormField() data: string, @FormField() data: string,
@UploadedFile() thumbnail?: Express.Multer.File @UploadedFile() thumbnail?: Express.Multer.File
): Promise<createCourseResponse> { ): Promise<createCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
const parsed = JSON.parse(data); const parsed = JSON.parse(data);
const { error, value } = CreateCourseValidator.validate(parsed); const { error, value } = CreateCourseValidator.validate(parsed);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
@ -137,7 +134,7 @@ export class CoursesInstructorController {
// Validate thumbnail file type if provided // Validate thumbnail file type if provided
if (thumbnail && !thumbnail.mimetype?.startsWith('image/')) throw new ValidationError('Only image files are allowed for thumbnail'); if (thumbnail && !thumbnail.mimetype?.startsWith('image/')) throw new ValidationError('Only image files are allowed for thumbnail');
return await CoursesInstructorService.createCourse(value, decoded.id, thumbnail); return await CoursesInstructorService.createCourse(value, request.user.id, thumbnail);
} }
/** /**
@ -156,11 +153,9 @@ export class CoursesInstructorController {
@Path() courseId: number, @Path() courseId: number,
@UploadedFile() file: Express.Multer.File @UploadedFile() file: Express.Multer.File
): Promise<{ code: number; message: string; data: { course_id: number; thumbnail_url: string } }> { ): Promise<{ code: number; message: string; data: { course_id: number; thumbnail_url: string } }> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
if (!file.mimetype?.startsWith('image/')) throw new ValidationError('Only image files are allowed'); if (!file.mimetype?.startsWith('image/')) throw new ValidationError('Only image files are allowed');
return await CoursesInstructorService.uploadThumbnail(token, courseId, file); return await CoursesInstructorService.uploadThumbnail(request.user.id, courseId, file);
} }
/** /**
@ -174,9 +169,7 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Response('404', 'Course not found') @Response('404', 'Course not found')
public async deleteCourse(@Request() request: any, @Path() courseId: number): Promise<DeleteMyCourseResponse> { public async deleteCourse(@Request() request: any, @Path() courseId: number): Promise<DeleteMyCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await CoursesInstructorService.deleteCourse(request.user.id, courseId);
if (!token) throw new ValidationError('No token provided')
return await CoursesInstructorService.deleteCourse(token, courseId);
} }
/** /**
@ -196,14 +189,11 @@ export class CoursesInstructorController {
@Path() courseId: number, @Path() courseId: number,
@Body() body: { title: { th: string; en: string } } @Body() body: { title: { th: string; en: string } }
): Promise<CloneCourseResponse> { ): Promise<CloneCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = CloneCourseValidator.validate(body); const { error } = CloneCourseValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
const result = await CoursesInstructorService.cloneCourse({ const result = await CoursesInstructorService.cloneCourse({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
title: body.title title: body.title
}); });
@ -220,9 +210,7 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Response('404', 'Course not found') @Response('404', 'Course not found')
public async submitCourse(@Request() request: any, @Path() courseId: number): Promise<submitCourseResponse> { public async submitCourse(@Request() request: any, @Path() courseId: number): Promise<submitCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await CoursesInstructorService.sendCourseForReview({ userId: request.user.id, course_id: courseId });
if (!token) throw new ValidationError('No token provided')
return await CoursesInstructorService.sendCourseForReview({ token, course_id: courseId });
} }
/** /**
@ -236,9 +224,7 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Response('404', 'Course not found') @Response('404', 'Course not found')
public async setCourseDraft(@Request() request: any, @Path() courseId: number): Promise<setCourseDraftResponse> { public async setCourseDraft(@Request() request: any, @Path() courseId: number): Promise<setCourseDraftResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await CoursesInstructorService.setCourseDraft({ userId: request.user.id, course_id: courseId });
if (!token) throw new ValidationError('No token provided')
return await CoursesInstructorService.setCourseDraft({ token, course_id: courseId });
} }
/** /**
@ -253,9 +239,7 @@ export class CoursesInstructorController {
@Response('403', 'You are not an instructor of this course') @Response('403', 'You are not an instructor of this course')
@Response('404', 'Course not found') @Response('404', 'Course not found')
public async getCourseApprovals(@Request() request: any, @Path() courseId: number): Promise<GetCourseApprovalsResponse> { public async getCourseApprovals(@Request() request: any, @Path() courseId: number): Promise<GetCourseApprovalsResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await CoursesInstructorService.getCourseApprovals(request.user.id, courseId);
if (!token) throw new ValidationError('No token provided')
return await CoursesInstructorService.getCourseApprovals(token, courseId);
} }
/** /**
@ -269,9 +253,7 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Response('404', 'Instructors not found') @Response('404', 'Instructors not found')
public async listInstructorCourses(@Request() request: any, @Path() courseId: number): Promise<listinstructorCourseResponse> { public async listInstructorCourses(@Request() request: any, @Path() courseId: number): Promise<listinstructorCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await CoursesInstructorService.listInstructorsOfCourse({ userId: request.user.id, course_id: courseId });
if (!token) throw new ValidationError('No token provided')
return await CoursesInstructorService.listInstructorsOfCourse({ token, course_id: courseId });
} }
/** /**
@ -286,9 +268,7 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Response('404', 'Instructor not found') @Response('404', 'Instructor not found')
public async addInstructor(@Request() request: any, @Path() courseId: number, @Path() emailOrUsername: string): Promise<addinstructorCourseResponse> { public async addInstructor(@Request() request: any, @Path() courseId: number, @Path() emailOrUsername: string): Promise<addinstructorCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await CoursesInstructorService.addInstructorToCourse({ userId: request.user.id, course_id: courseId, email_or_username: emailOrUsername });
if (!token) throw new ValidationError('No token provided')
return await CoursesInstructorService.addInstructorToCourse({ token, course_id: courseId, email_or_username: emailOrUsername });
} }
/** /**
@ -303,9 +283,7 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Response('404', 'Instructor not found') @Response('404', 'Instructor not found')
public async removeInstructor(@Request() request: any, @Path() courseId: number, @Path() userId: number): Promise<removeinstructorCourseResponse> { public async removeInstructor(@Request() request: any, @Path() courseId: number, @Path() userId: number): Promise<removeinstructorCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await CoursesInstructorService.removeInstructorFromCourse({ userId: request.user.id, course_id: courseId, user_id: userId });
if (!token) throw new ValidationError('No token provided')
return await CoursesInstructorService.removeInstructorFromCourse({ token, course_id: courseId, user_id: userId });
} }
/** /**
@ -320,9 +298,7 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Response('404', 'Primary instructor not found') @Response('404', 'Primary instructor not found')
public async setPrimaryInstructor(@Request() request: any, @Path() courseId: number, @Path() userId: number): Promise<setprimaryCourseInstructorResponse> { public async setPrimaryInstructor(@Request() request: any, @Path() courseId: number, @Path() userId: number): Promise<setprimaryCourseInstructorResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await CoursesInstructorService.setPrimaryInstructor({ userId: request.user.id, course_id: courseId, user_id: userId });
if (!token) throw new ValidationError('No token provided')
return await CoursesInstructorService.setPrimaryInstructor({ token, course_id: courseId, user_id: userId });
} }
/** /**
@ -347,10 +323,8 @@ export class CoursesInstructorController {
@Query() search?: string, @Query() search?: string,
@Query() status?: 'ENROLLED' | 'COMPLETED' @Query() status?: 'ENROLLED' | 'COMPLETED'
): Promise<GetEnrolledStudentsResponse> { ): Promise<GetEnrolledStudentsResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await CoursesInstructorService.getEnrolledStudents({ return await CoursesInstructorService.getEnrolledStudents({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
page, page,
limit, limit,
@ -376,10 +350,8 @@ export class CoursesInstructorController {
@Path() courseId: number, @Path() courseId: number,
@Path() studentId: number @Path() studentId: number
): Promise<GetEnrolledStudentDetailResponse> { ): Promise<GetEnrolledStudentDetailResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await CoursesInstructorService.getEnrolledStudentDetail({ return await CoursesInstructorService.getEnrolledStudentDetail({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
student_id: studentId, student_id: studentId,
}); });
@ -410,10 +382,8 @@ export class CoursesInstructorController {
@Query() search?: string, @Query() search?: string,
@Query() isPassed?: boolean @Query() isPassed?: boolean
): Promise<GetQuizScoresResponse> { ): Promise<GetQuizScoresResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await CoursesInstructorService.getQuizScores({ return await CoursesInstructorService.getQuizScores({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
page, page,
@ -442,10 +412,8 @@ export class CoursesInstructorController {
@Path() lessonId: number, @Path() lessonId: number,
@Path() studentId: number @Path() studentId: number
): Promise<GetQuizAttemptDetailResponse> { ): Promise<GetQuizAttemptDetailResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await CoursesInstructorService.getQuizAttemptDetail({ return await CoursesInstructorService.getQuizAttemptDetail({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
student_id: studentId, student_id: studentId,
@ -467,8 +435,6 @@ export class CoursesInstructorController {
@Request() request: any, @Request() request: any,
@Path() courseId: number @Path() courseId: number
): Promise<GetCourseApprovalHistoryResponse> { ): Promise<GetCourseApprovalHistoryResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await CoursesInstructorService.getCourseApprovalHistory(request.user.id, courseId);
if (!token) throw new ValidationError('No token provided');
return await CoursesInstructorService.getCourseApprovalHistory(token, courseId);
} }
} }

View file

@ -36,11 +36,7 @@ export class CoursesStudentController {
@Response('404', 'Course not found') @Response('404', 'Course not found')
@Response('409', 'Already enrolled in this course') @Response('409', 'Already enrolled in this course')
public async enrollCourse(@Request() request: any, @Path() courseId: number): Promise<EnrollCourseResponse> { public async enrollCourse(@Request() request: any, @Path() courseId: number): Promise<EnrollCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await this.service.enrollCourse({ userId: request.user.id, course_id: courseId });
if (!token) {
throw new ValidationError('No token provided');
}
return await this.service.enrollCourse({ token, course_id: courseId });
} }
/** /**
@ -60,11 +56,7 @@ export class CoursesStudentController {
@Query() limit?: number, @Query() limit?: number,
@Query() status?: EnrollmentStatus @Query() status?: EnrollmentStatus
): Promise<ListEnrolledCoursesResponse> { ): Promise<ListEnrolledCoursesResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await this.service.GetEnrolledCourses({ userId: request.user.id, page, limit, status });
if (!token) {
throw new ValidationError('No token provided');
}
return await this.service.GetEnrolledCourses({ token, page, limit, status });
} }
/** /**
@ -79,11 +71,7 @@ export class CoursesStudentController {
@Response('403', 'Not enrolled in this course') @Response('403', 'Not enrolled in this course')
@Response('404', 'Course not found') @Response('404', 'Course not found')
public async getCourseLearning(@Request() request: any, @Path() courseId: number): Promise<GetCourseLearningResponse> { public async getCourseLearning(@Request() request: any, @Path() courseId: number): Promise<GetCourseLearningResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await this.service.getCourseLearning({ userId: request.user.id, course_id: courseId });
if (!token) {
throw new ValidationError('No token provided');
}
return await this.service.getCourseLearning({ token, course_id: courseId });
} }
/** /**
@ -103,11 +91,7 @@ export class CoursesStudentController {
@Path() courseId: number, @Path() courseId: number,
@Path() lessonId: number @Path() lessonId: number
): Promise<GetLessonContentResponse> { ): Promise<GetLessonContentResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await this.service.getlessonContent({ userId: request.user.id, course_id: courseId, lesson_id: lessonId });
if (!token) {
throw new ValidationError('No token provided');
}
return await this.service.getlessonContent({ token, course_id: courseId, lesson_id: lessonId });
} }
/** /**
@ -126,11 +110,7 @@ export class CoursesStudentController {
@Path() courseId: number, @Path() courseId: number,
@Path() lessonId: number @Path() lessonId: number
): Promise<CheckLessonAccessResponse> { ): Promise<CheckLessonAccessResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await this.service.checkAccessLesson({ userId: request.user.id, course_id: courseId, lesson_id: lessonId });
if (!token) {
throw new ValidationError('No token provided');
}
return await this.service.checkAccessLesson({ token, course_id: courseId, lesson_id: lessonId });
} }
/** /**
@ -149,14 +129,12 @@ export class CoursesStudentController {
@Path() lessonId: number, @Path() lessonId: number,
@Body() body: SaveVideoProgressBody @Body() body: SaveVideoProgressBody
): Promise<SaveVideoProgressResponse> { ): Promise<SaveVideoProgressResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = SaveVideoProgressValidator.validate(body); const { error } = SaveVideoProgressValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await this.service.saveVideoProgress({ return await this.service.saveVideoProgress({
token, userId: request.user.id,
lesson_id: lessonId, lesson_id: lessonId,
video_progress_seconds: body.video_progress_seconds, video_progress_seconds: body.video_progress_seconds,
video_duration_seconds: body.video_duration_seconds, video_duration_seconds: body.video_duration_seconds,
@ -178,11 +156,7 @@ export class CoursesStudentController {
@Request() request: any, @Request() request: any,
@Path() lessonId: number @Path() lessonId: number
): Promise<GetVideoProgressResponse> { ): Promise<GetVideoProgressResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await this.service.getVideoProgress({ userId: request.user.id, lesson_id: lessonId });
if (!token) {
throw new ValidationError('No token provided');
}
return await this.service.getVideoProgress({ token, lesson_id: lessonId });
} }
/** /**
@ -202,11 +176,7 @@ export class CoursesStudentController {
@Path() courseId: number, @Path() courseId: number,
@Path() lessonId: number @Path() lessonId: number
): Promise<CompleteLessonResponse> { ): Promise<CompleteLessonResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await this.service.completeLesson({ userId: request.user.id, lesson_id: lessonId });
if (!token) {
throw new ValidationError('No token provided');
}
return await this.service.completeLesson({ token, lesson_id: lessonId });
} }
/** /**
@ -227,14 +197,12 @@ export class CoursesStudentController {
@Path() lessonId: number, @Path() lessonId: number,
@Body() body: SubmitQuizBody @Body() body: SubmitQuizBody
): Promise<SubmitQuizResponse> { ): Promise<SubmitQuizResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = SubmitQuizValidator.validate(body); const { error } = SubmitQuizValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await this.service.submitQuiz({ return await this.service.submitQuiz({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
answers: body.answers, answers: body.answers,
@ -258,12 +226,8 @@ export class CoursesStudentController {
@Path() courseId: number, @Path() courseId: number,
@Path() lessonId: number @Path() lessonId: number
): Promise<GetQuizAttemptsResponse> { ): Promise<GetQuizAttemptsResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await this.service.getQuizAttempts({ return await this.service.getQuizAttempts({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
}); });

View file

@ -42,8 +42,6 @@ export class LessonsController {
@Path() lessonId: number, @Path() lessonId: number,
@UploadedFile() video: Express.Multer.File @UploadedFile() video: Express.Multer.File
): Promise<VideoOperationResponse> { ): Promise<VideoOperationResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
if (!video) { if (!video) {
throw new ValidationError('Video file is required'); throw new ValidationError('Video file is required');
@ -57,7 +55,7 @@ export class LessonsController {
}; };
return await chaptersLessonService.uploadVideo({ return await chaptersLessonService.uploadVideo({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
video: videoInfo, video: videoInfo,
@ -87,8 +85,6 @@ export class LessonsController {
@Path() lessonId: number, @Path() lessonId: number,
@UploadedFile() video: Express.Multer.File @UploadedFile() video: Express.Multer.File
): Promise<VideoOperationResponse> { ): Promise<VideoOperationResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
if (!video) { if (!video) {
throw new ValidationError('Video file is required'); throw new ValidationError('Video file is required');
@ -102,7 +98,7 @@ export class LessonsController {
}; };
return await chaptersLessonService.updateVideo({ return await chaptersLessonService.updateVideo({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
video: videoInfo, video: videoInfo,
@ -132,8 +128,6 @@ export class LessonsController {
@Path() lessonId: number, @Path() lessonId: number,
@UploadedFile() attachment: Express.Multer.File @UploadedFile() attachment: Express.Multer.File
): Promise<AttachmentOperationResponse> { ): Promise<AttachmentOperationResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
if (!attachment) { if (!attachment) {
throw new ValidationError('Attachment file is required'); throw new ValidationError('Attachment file is required');
@ -147,7 +141,7 @@ export class LessonsController {
}; };
return await chaptersLessonService.uploadAttachment({ return await chaptersLessonService.uploadAttachment({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
attachment: attachmentInfo, attachment: attachmentInfo,
@ -177,11 +171,9 @@ export class LessonsController {
@Path() lessonId: number, @Path() lessonId: number,
@Path() attachmentId: number @Path() attachmentId: number
): Promise<DeleteAttachmentResponse> { ): Promise<DeleteAttachmentResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.deleteAttachment({ return await chaptersLessonService.deleteAttachment({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
attachment_id: attachmentId, attachment_id: attachmentId,
@ -211,14 +203,12 @@ export class LessonsController {
@Path() lessonId: number, @Path() lessonId: number,
@Body() body: SetYouTubeVideoBody @Body() body: SetYouTubeVideoBody
): Promise<YouTubeVideoResponse> { ): Promise<YouTubeVideoResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = SetYouTubeVideoValidator.validate(body); const { error } = SetYouTubeVideoValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.setYouTubeVideo({ return await chaptersLessonService.setYouTubeVideo({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
lesson_id: lessonId, lesson_id: lessonId,
youtube_video_id: body.youtube_video_id, youtube_video_id: body.youtube_video_id,

View file

@ -1,5 +1,4 @@
import { Get, Path, Put, Query, Request, Response, Route, Security, SuccessResponse, Tags } from 'tsoa'; import { Get, Path, Put, Query, Request, Response, Route, Security, SuccessResponse, Tags } from 'tsoa';
import { ValidationError } from '../middleware/errorHandler';
import { RecommendedCoursesService } from '../services/RecommendedCourses.service'; import { RecommendedCoursesService } from '../services/RecommendedCourses.service';
import { import {
ListApprovedCoursesResponse, ListApprovedCoursesResponse,
@ -25,9 +24,7 @@ export class RecommendedCoursesController {
@Query() search?: string, @Query() search?: string,
@Query() categoryId?: number @Query() categoryId?: number
): Promise<ListApprovedCoursesResponse> { ): Promise<ListApprovedCoursesResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await RecommendedCoursesService.listApprovedCourses(request.user.id, { search, categoryId });
if (!token) throw new ValidationError('No token provided');
return await RecommendedCoursesService.listApprovedCourses(token, { search, categoryId });
} }
/** /**
@ -43,9 +40,7 @@ export class RecommendedCoursesController {
@Response('403', 'Forbidden - Admin only') @Response('403', 'Forbidden - Admin only')
@Response('404', 'Course not found') @Response('404', 'Course not found')
public async getCourseById(@Request() request: any, @Path() courseId: number): Promise<GetCourseByIdResponse> { public async getCourseById(@Request() request: any, @Path() courseId: number): Promise<GetCourseByIdResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await RecommendedCoursesService.getCourseById(request.user.id, courseId);
if (!token) throw new ValidationError('No token provided');
return await RecommendedCoursesService.getCourseById(token, courseId);
} }
/** /**
@ -65,8 +60,6 @@ export class RecommendedCoursesController {
@Path() courseId: number, @Path() courseId: number,
@Query() is_recommended: boolean @Query() is_recommended: boolean
): Promise<ToggleRecommendedResponse> { ): Promise<ToggleRecommendedResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await RecommendedCoursesService.toggleRecommended(request.user.id, courseId, is_recommended);
if (!token) throw new ValidationError('No token provided');
return await RecommendedCoursesService.toggleRecommended(token, courseId, is_recommended);
} }
} }

View file

@ -1,12 +1,10 @@
import { Get, Body, Post, Route, Tags, SuccessResponse, Response, Example, Controller, Security, Request, Put, UploadedFile } from 'tsoa'; import { Get, Body, Post, Route, Tags, SuccessResponse, Response, Security, Request, Put, UploadedFile } from 'tsoa';
import { ValidationError } from '../middleware/errorHandler'; import { ValidationError } from '../middleware/errorHandler';
import { UserService } from '../services/user.service'; import { UserService } from '../services/user.service';
import { import {
UserResponse, UserResponse,
ProfileResponse,
ProfileUpdate, ProfileUpdate,
ProfileUpdateResponse, ProfileUpdateResponse,
ChangePasswordRequest,
ChangePasswordResponse, ChangePasswordResponse,
updateAvatarResponse, updateAvatarResponse,
SendVerifyEmailResponse, SendVerifyEmailResponse,
@ -23,8 +21,6 @@ export class UserController {
/** /**
* Get current user profile * Get current user profile
* @summary Retrieve authenticated user's profile information
* @param request Express request object with JWT token in Authorization header
*/ */
@Get('me') @Get('me')
@SuccessResponse('200', 'User found') @SuccessResponse('200', 'User found')
@ -32,12 +28,7 @@ export class UserController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Security('jwt') @Security('jwt')
public async getMe(@Request() request: any): Promise<UserResponse> { public async getMe(@Request() request: any): Promise<UserResponse> {
// Extract token from Authorization header return await this.userService.getUserProfile(request.user.id);
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await this.userService.getUserProfile(token);
} }
@Put('me') @Put('me')
@ -47,34 +38,20 @@ export class UserController {
@Response('400', 'Validation error') @Response('400', 'Validation error')
public async updateProfile(@Request() request: any, @Body() body: ProfileUpdate): Promise<ProfileUpdateResponse> { public async updateProfile(@Request() request: any, @Body() body: ProfileUpdate): Promise<ProfileUpdateResponse> {
const { error } = profileUpdateSchema.validate(body); const { error } = profileUpdateSchema.validate(body);
if (error) { if (error) throw new ValidationError(error.details[0].message);
throw new ValidationError(error.details[0].message); return await this.userService.updateProfile(request.user.id, body);
}
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await this.userService.updateProfile(token, body);
} }
@Get('roles') @Get('roles')
@Security('jwt') @Security('jwt')
@SuccessResponse('200', 'Roles retrieved successfully') @SuccessResponse('200', 'Roles retrieved successfully')
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
public async getRoles(@Request() request: any): Promise<rolesResponse> { public async getRoles(): Promise<rolesResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await this.userService.getRoles();
if (!token) {
throw new ValidationError('No token provided');
}
return await this.userService.getRoles(token);
} }
/** /**
* Change password * Change password
* @summary Change user password using old password
* @param request Express request object with JWT token in Authorization header
* @param body Old password and new password
* @returns Success message
*/ */
@Post('change-password') @Post('change-password')
@Security('jwt') @Security('jwt')
@ -83,22 +60,12 @@ export class UserController {
@Response('400', 'Validation error') @Response('400', 'Validation error')
public async changePassword(@Request() request: any, @Body() body: ChangePassword): Promise<ChangePasswordResponse> { public async changePassword(@Request() request: any, @Body() body: ChangePassword): Promise<ChangePasswordResponse> {
const { error } = changePasswordSchema.validate(body); const { error } = changePasswordSchema.validate(body);
if (error) { if (error) throw new ValidationError(error.details[0].message);
throw new ValidationError(error.details[0].message); return await this.userService.changePassword(request.user.id, body.oldPassword, body.newPassword);
}
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await this.userService.changePassword(token, body.oldPassword, body.newPassword);
} }
/** /**
* Upload user avatar picture * Upload user avatar picture
* @param request Express request object with JWT token in Authorization header
* @param file Avatar image file
*/ */
@Post('upload-avatar') @Post('upload-avatar')
@Security('jwt') @Security('jwt')
@ -109,9 +76,6 @@ export class UserController {
@Request() request: any, @Request() request: any,
@UploadedFile() file: Express.Multer.File @UploadedFile() file: Express.Multer.File
): Promise<updateAvatarResponse> { ): Promise<updateAvatarResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
// Validate file type (images only) // Validate file type (images only)
if (!file.mimetype?.startsWith('image/')) throw new ValidationError('Only image files are allowed'); if (!file.mimetype?.startsWith('image/')) throw new ValidationError('Only image files are allowed');
@ -119,13 +83,11 @@ export class UserController {
const maxSize = 5 * 1024 * 1024; // 5MB const maxSize = 5 * 1024 * 1024; // 5MB
if (file.size > maxSize) throw new ValidationError('File size must be less than 5MB'); if (file.size > maxSize) throw new ValidationError('File size must be less than 5MB');
return await this.userService.uploadAvatarPicture(token, file); return await this.userService.uploadAvatarPicture(request.user.id, file);
} }
/** /**
* Send verification email to user * Send verification email to user
* @summary Send email verification link to authenticated user's email
* @param request Express request object with JWT token in Authorization header
*/ */
@Post('send-verify-email') @Post('send-verify-email')
@Security('jwt') @Security('jwt')
@ -133,9 +95,7 @@ export class UserController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
@Response('400', 'Email already verified') @Response('400', 'Email already verified')
public async sendVerifyEmail(@Request() request: any): Promise<SendVerifyEmailResponse> { public async sendVerifyEmail(@Request() request: any): Promise<SendVerifyEmailResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); return await this.userService.sendVerifyEmail(request.user.id);
if (!token) throw new ValidationError('No token provided');
return await this.userService.sendVerifyEmail(token);
} }
/** /**

View file

@ -37,10 +37,8 @@ export class AnnouncementsController {
@Query() page?: number, @Query() page?: number,
@Query() limit?: number @Query() limit?: number
): Promise<ListAnnouncementResponse> { ): Promise<ListAnnouncementResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await announcementsService.listAnnouncement({ return await announcementsService.listAnnouncement({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
page, page,
limit, limit,
@ -63,9 +61,6 @@ export class AnnouncementsController {
@FormField() data: string, @FormField() data: string,
@UploadedFiles() files?: Express.Multer.File[] @UploadedFiles() files?: Express.Multer.File[]
): Promise<CreateAnnouncementResponse> { ): Promise<CreateAnnouncementResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
// Parse JSON data field // Parse JSON data field
const parsed = JSON.parse(data) as CreateAnnouncementBody; const parsed = JSON.parse(data) as CreateAnnouncementBody;
@ -74,7 +69,7 @@ export class AnnouncementsController {
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await announcementsService.createAnnouncement({ return await announcementsService.createAnnouncement({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
title: parsed.title, title: parsed.title,
content: parsed.content, content: parsed.content,
@ -103,15 +98,12 @@ export class AnnouncementsController {
@Path() announcementId: number, @Path() announcementId: number,
@Body() body: UpdateAnnouncementBody @Body() body: UpdateAnnouncementBody
): Promise<UpdateAnnouncementResponse> { ): Promise<UpdateAnnouncementResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
// Validate body // Validate body
const { error } = UpdateAnnouncementValidator.validate(body); const { error } = UpdateAnnouncementValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message); if (error) throw new ValidationError(error.details[0].message);
return await announcementsService.updateAnnouncement({ return await announcementsService.updateAnnouncement({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
announcement_id: announcementId, announcement_id: announcementId,
title: body.title, title: body.title,
@ -139,10 +131,8 @@ export class AnnouncementsController {
@Path() courseId: number, @Path() courseId: number,
@Path() announcementId: number @Path() announcementId: number
): Promise<DeleteAnnouncementResponse> { ): Promise<DeleteAnnouncementResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await announcementsService.deleteAnnouncement({ return await announcementsService.deleteAnnouncement({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
announcement_id: announcementId, announcement_id: announcementId,
}); });
@ -166,10 +156,8 @@ export class AnnouncementsController {
@Path() announcementId: number, @Path() announcementId: number,
@UploadedFile() file: Express.Multer.File @UploadedFile() file: Express.Multer.File
): Promise<UploadAnnouncementAttachmentResponse> { ): Promise<UploadAnnouncementAttachmentResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await announcementsService.uploadAttachment({ return await announcementsService.uploadAttachment({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
announcement_id: announcementId, announcement_id: announcementId,
file: file as any, file: file as any,
@ -195,10 +183,8 @@ export class AnnouncementsController {
@Path() announcementId: number, @Path() announcementId: number,
@Path() attachmentId: number @Path() attachmentId: number
): Promise<DeleteAnnouncementAttachmentResponse> { ): Promise<DeleteAnnouncementAttachmentResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await announcementsService.deleteAttachment({ return await announcementsService.deleteAttachment({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
announcement_id: announcementId, announcement_id: announcementId,
attachment_id: attachmentId, attachment_id: attachmentId,
@ -228,10 +214,8 @@ export class AnnouncementsStudentController {
@Query() page?: number, @Query() page?: number,
@Query() limit?: number @Query() limit?: number
): Promise<ListAnnouncementResponse> { ): Promise<ListAnnouncementResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await announcementsService.listAnnouncement({ return await announcementsService.listAnnouncement({
token, userId: request.user.id,
course_id: courseId, course_id: courseId,
page, page,
limit, limit,

View file

@ -1,8 +1,6 @@
import { prisma } from '../config/database'; import { prisma } from '../config/database';
import { config } from '../config';
import { logger } from '../config/logger'; import { logger } from '../config/logger';
import { UnauthorizedError, ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler'; import { ValidationError, NotFoundError } from '../middleware/errorHandler';
import jwt from 'jsonwebtoken';
import { getPresignedUrl } from '../config/minio'; import { getPresignedUrl } from '../config/minio';
import { import {
ListPendingCoursesResponse, ListPendingCoursesResponse,
@ -18,7 +16,7 @@ export class AdminCourseApprovalService {
/** /**
* Get all pending courses for admin review * Get all pending courses for admin review
*/ */
static async listPendingCourses(token: string): Promise<ListPendingCoursesResponse> { static async listPendingCourses(userId: number): Promise<ListPendingCoursesResponse> {
try { try {
const courses = await prisma.course.findMany({ const courses = await prisma.course.findMany({
where: { status: 'PENDING' }, where: { status: 'PENDING' },
@ -96,9 +94,8 @@ export class AdminCourseApprovalService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to list pending courses', { error }); logger.error('Failed to list pending courses', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: 0, entityId: 0,
@ -113,7 +110,7 @@ export class AdminCourseApprovalService {
/** /**
* Get course details for admin review * Get course details for admin review
*/ */
static async getCourseDetail(token: string, courseId: number): Promise<GetCourseDetailForAdminResponse> { static async getCourseDetail(userId: number, courseId: number): Promise<GetCourseDetailForAdminResponse> {
try { try {
const course = await prisma.course.findUnique({ const course = await prisma.course.findUnique({
where: { id: courseId }, where: { id: courseId },
@ -228,9 +225,8 @@ export class AdminCourseApprovalService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to get course detail', { error }); logger.error('Failed to get course detail', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: courseId, entityId: courseId,
@ -245,9 +241,8 @@ export class AdminCourseApprovalService {
/** /**
* Approve a course * Approve a course
*/ */
static async approveCourse(token: string, courseId: number, comment?: string): Promise<ApproveCourseResponse> { static async approveCourse(userId: number, courseId: number, comment?: string): Promise<ApproveCourseResponse> {
try { try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
const course = await prisma.course.findUnique({ where: { id: courseId } }); const course = await prisma.course.findUnique({ where: { id: courseId } });
if (!course) { if (!course) {
@ -264,7 +259,7 @@ export class AdminCourseApprovalService {
where: { id: courseId }, where: { id: courseId },
data: { data: {
status: 'APPROVED', status: 'APPROVED',
approved_by: decoded.id, approved_by: userId,
approved_at: new Date() approved_at: new Date()
} }
}), }),
@ -273,7 +268,7 @@ export class AdminCourseApprovalService {
data: { data: {
course_id: courseId, course_id: courseId,
submitted_by: course.created_by, submitted_by: course.created_by,
reviewed_by: decoded.id, reviewed_by: userId,
action: 'APPROVED', action: 'APPROVED',
previous_status: course.status, previous_status: course.status,
new_status: 'APPROVED', new_status: 'APPROVED',
@ -284,7 +279,7 @@ export class AdminCourseApprovalService {
// Audit log - APPROVE_COURSE // Audit log - APPROVE_COURSE
await auditService.logSync({ await auditService.logSync({
userId: decoded.id, userId,
action: AuditAction.APPROVE_COURSE, action: AuditAction.APPROVE_COURSE,
entityType: 'Course', entityType: 'Course',
entityId: courseId, entityId: courseId,
@ -299,9 +294,8 @@ export class AdminCourseApprovalService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to approve course', { error }); logger.error('Failed to approve course', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: courseId, entityId: courseId,
@ -317,9 +311,8 @@ export class AdminCourseApprovalService {
/** /**
* Reject a course * Reject a course
*/ */
static async rejectCourse(token: string, courseId: number, comment: string): Promise<RejectCourseResponse> { static async rejectCourse(userId: number, courseId: number, comment: string): Promise<RejectCourseResponse> {
try { try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
const course = await prisma.course.findUnique({ where: { id: courseId } }); const course = await prisma.course.findUnique({ where: { id: courseId } });
if (!course) { if (!course) {
@ -350,7 +343,7 @@ export class AdminCourseApprovalService {
data: { data: {
course_id: courseId, course_id: courseId,
submitted_by: course.created_by, submitted_by: course.created_by,
reviewed_by: decoded.id, reviewed_by: userId,
action: 'REJECTED', action: 'REJECTED',
previous_status: course.status, previous_status: course.status,
new_status: 'REJECTED', new_status: 'REJECTED',
@ -361,7 +354,7 @@ export class AdminCourseApprovalService {
// Audit log - REJECT_COURSE // Audit log - REJECT_COURSE
await auditService.logSync({ await auditService.logSync({
userId: decoded.id, userId,
action: AuditAction.REJECT_COURSE, action: AuditAction.REJECT_COURSE,
entityType: 'Course', entityType: 'Course',
entityId: courseId, entityId: courseId,
@ -376,9 +369,8 @@ export class AdminCourseApprovalService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to reject course', { error }); logger.error('Failed to reject course', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: courseId, entityId: courseId,

View file

@ -59,14 +59,11 @@ import { AuditAction } from '@prisma/client';
* Course ( Instructor Student) * Course ( Instructor Student)
* Returns: { hasAccess: boolean, role: 'INSTRUCTOR' | 'STUDENT' | null, userId: number } * Returns: { hasAccess: boolean, role: 'INSTRUCTOR' | 'STUDENT' | null, userId: number }
*/ */
async function validateCourseAccess(token: string, course_id: number): Promise<{ async function validateCourseAccess(userId: number, course_id: number): Promise<{
hasAccess: boolean; hasAccess: boolean;
role: 'INSTRUCTOR' | 'STUDENT' | null; role: 'INSTRUCTOR' | 'STUDENT' | null;
userId: number; userId: number;
}> { }> {
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
const userId = decodedToken.id;
const user = await prisma.user.findUnique({ where: { id: userId } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) { if (!user) {
throw new UnauthorizedError('Invalid token'); throw new UnauthorizedError('Invalid token');
@ -98,9 +95,8 @@ async function validateCourseAccess(token: string, course_id: number): Promise<{
export class ChaptersLessonService { export class ChaptersLessonService {
async listChapters(request: ChaptersRequest): Promise<ListChaptersResponse> { async listChapters(request: ChaptersRequest): Promise<ListChaptersResponse> {
try { try {
const { token, course_id } = request; const { userId, course_id } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number }; const user = await prisma.user.findUnique({ where: { id: userId } });
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
if (!user) { if (!user) {
throw new UnauthorizedError('Invalid token'); throw new UnauthorizedError('Invalid token');
} }
@ -117,14 +113,13 @@ export class ChaptersLessonService {
async createChapter(request: CreateChapterInput): Promise<CreateChapterResponse> { async createChapter(request: CreateChapterInput): Promise<CreateChapterResponse> {
try { try {
const { token, course_id, title, description, sort_order } = request; const { userId, course_id, title, description, sort_order } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) { if (!user) {
throw new UnauthorizedError('Invalid token'); throw new UnauthorizedError('Invalid token');
} }
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) { if (!courseInstructor) {
throw new ForbiddenError('You are not permitted to create chapter'); throw new ForbiddenError('You are not permitted to create chapter');
} }
@ -132,7 +127,7 @@ export class ChaptersLessonService {
// Audit log - CREATE Chapter // Audit log - CREATE Chapter
auditService.log({ auditService.log({
userId: decodedToken.id, userId: userId,
action: AuditAction.CREATE, action: AuditAction.CREATE,
entityType: 'Chapter', entityType: 'Chapter',
entityId: chapter.id, entityId: chapter.id,
@ -142,9 +137,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Chapter created successfully', data: chapter as ChapterData }; return { code: 200, message: 'Chapter created successfully', data: chapter as ChapterData };
} catch (error) { } catch (error) {
logger.error(`Error creating chapter: ${error}`); logger.error(`Error creating chapter: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Chapter', entityType: 'Chapter',
entityId: 0, entityId: 0,
@ -159,14 +153,13 @@ export class ChaptersLessonService {
async updateChapter(request: UpdateChapterInput): Promise<UpdateChapterResponse> { async updateChapter(request: UpdateChapterInput): Promise<UpdateChapterResponse> {
try { try {
const { token, course_id, chapter_id, title, description, sort_order } = request; const { userId, course_id, chapter_id, title, description, sort_order } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) { if (!user) {
throw new UnauthorizedError('Invalid token'); throw new UnauthorizedError('Invalid token');
} }
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) { if (!courseInstructor) {
throw new ForbiddenError('You are not permitted to update chapter'); throw new ForbiddenError('You are not permitted to update chapter');
} }
@ -174,9 +167,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Chapter updated successfully', data: chapter as ChapterData }; return { code: 200, message: 'Chapter updated successfully', data: chapter as ChapterData };
} catch (error) { } catch (error) {
logger.error(`Error updating chapter: ${error}`); logger.error(`Error updating chapter: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Chapter', entityType: 'Chapter',
entityId: request.chapter_id, entityId: request.chapter_id,
@ -191,14 +183,13 @@ export class ChaptersLessonService {
async deleteChapter(request: DeleteChapterRequest): Promise<DeleteChapterResponse> { async deleteChapter(request: DeleteChapterRequest): Promise<DeleteChapterResponse> {
try { try {
const { token, course_id, chapter_id } = request; const { userId, course_id, chapter_id } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) { if (!user) {
throw new UnauthorizedError('Invalid token'); throw new UnauthorizedError('Invalid token');
} }
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) { if (!courseInstructor) {
throw new ForbiddenError('You are not permitted to delete chapter'); throw new ForbiddenError('You are not permitted to delete chapter');
} }
@ -206,7 +197,7 @@ export class ChaptersLessonService {
// Audit log - DELETE Chapter // Audit log - DELETE Chapter
auditService.log({ auditService.log({
userId: decodedToken.id, userId: userId,
action: AuditAction.DELETE, action: AuditAction.DELETE,
entityType: 'Chapter', entityType: 'Chapter',
entityId: chapter_id, entityId: chapter_id,
@ -219,9 +210,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Chapter deleted successfully' }; return { code: 200, message: 'Chapter deleted successfully' };
} catch (error) { } catch (error) {
logger.error(`Error deleting chapter: ${error}`); logger.error(`Error deleting chapter: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Chapter', entityType: 'Chapter',
entityId: request.chapter_id, entityId: request.chapter_id,
@ -236,14 +226,13 @@ export class ChaptersLessonService {
async reorderChapter(request: ReorderChapterRequest): Promise<ReorderChapterResponse> { async reorderChapter(request: ReorderChapterRequest): Promise<ReorderChapterResponse> {
try { try {
const { token, course_id, chapter_id, sort_order: newSortOrder } = request; const { userId, course_id, chapter_id, sort_order: newSortOrder } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) { if (!user) {
throw new UnauthorizedError('Invalid token'); throw new UnauthorizedError('Invalid token');
} }
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) { if (!courseInstructor) {
throw new ForbiddenError('You are not permitted to reorder chapter'); throw new ForbiddenError('You are not permitted to reorder chapter');
} }
@ -313,9 +302,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Chapter reordered successfully', data: chapters as ChapterData[] }; return { code: 200, message: 'Chapter reordered successfully', data: chapters as ChapterData[] };
} catch (error) { } catch (error) {
logger.error(`Error reordering chapter: ${error}`); logger.error(`Error reordering chapter: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Chapter', entityType: 'Chapter',
entityId: request.chapter_id, entityId: request.chapter_id,
@ -335,14 +323,13 @@ export class ChaptersLessonService {
*/ */
async createLesson(request: CreateLessonInput): Promise<CreateLessonResponse> { async createLesson(request: CreateLessonInput): Promise<CreateLessonResponse> {
try { try {
const { token, course_id, chapter_id, title, content, type, sort_order } = request; const { userId, course_id, chapter_id, title, content, type, sort_order } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) { if (!user) {
throw new UnauthorizedError('Invalid token'); throw new UnauthorizedError('Invalid token');
} }
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) { if (!courseInstructor) {
throw new ForbiddenError('You are not permitted to create lesson'); throw new ForbiddenError('You are not permitted to create lesson');
} }
@ -354,7 +341,6 @@ export class ChaptersLessonService {
// If QUIZ type, create empty Quiz shell // If QUIZ type, create empty Quiz shell
if (type === 'QUIZ') { if (type === 'QUIZ') {
const userId = decodedToken.id;
await prisma.quiz.create({ await prisma.quiz.create({
data: { data: {
@ -376,7 +362,7 @@ export class ChaptersLessonService {
// Audit log - CREATE Lesson (QUIZ) // Audit log - CREATE Lesson (QUIZ)
auditService.log({ auditService.log({
userId: decodedToken.id, userId: userId,
action: AuditAction.CREATE, action: AuditAction.CREATE,
entityType: 'Lesson', entityType: 'Lesson',
entityId: lesson.id, entityId: lesson.id,
@ -388,7 +374,7 @@ export class ChaptersLessonService {
// Audit log - CREATE Lesson // Audit log - CREATE Lesson
auditService.log({ auditService.log({
userId: decodedToken.id, userId: userId,
action: AuditAction.CREATE, action: AuditAction.CREATE,
entityType: 'Lesson', entityType: 'Lesson',
entityId: lesson.id, entityId: lesson.id,
@ -398,9 +384,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Lesson created successfully', data: lesson as LessonData }; return { code: 200, message: 'Lesson created successfully', data: lesson as LessonData };
} catch (error) { } catch (error) {
logger.error(`Error creating lesson: ${error}`); logger.error(`Error creating lesson: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Lesson', entityType: 'Lesson',
entityId: 0, entityId: 0,
@ -419,10 +404,10 @@ export class ChaptersLessonService {
*/ */
async getLesson(request: GetLessonRequest): Promise<GetLessonResponse> { async getLesson(request: GetLessonRequest): Promise<GetLessonResponse> {
try { try {
const { token, course_id, lesson_id } = request; const { userId, course_id, lesson_id } = request;
// Check access for both instructor and enrolled student // Check access for both instructor and enrolled student
const access = await validateCourseAccess(token, course_id); const access = await validateCourseAccess(userId, course_id);
if (!access.hasAccess) { if (!access.hasAccess) {
throw new ForbiddenError('You do not have access to this course'); throw new ForbiddenError('You do not have access to this course');
} }
@ -549,9 +534,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Lesson fetched successfully', data: lessonData as LessonData }; return { code: 200, message: 'Lesson fetched successfully', data: lessonData as LessonData };
} catch (error) { } catch (error) {
logger.error(`Error fetching lesson: ${error}`); logger.error(`Error fetching lesson: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Lesson', entityType: 'Lesson',
entityId: request.lesson_id, entityId: request.lesson_id,
@ -566,14 +550,13 @@ export class ChaptersLessonService {
async updateLesson(request: UpdateLessonRequest): Promise<UpdateLessonResponse> { async updateLesson(request: UpdateLessonRequest): Promise<UpdateLessonResponse> {
try { try {
const { token, course_id, lesson_id, data } = request; const { userId, course_id, lesson_id, data } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) { if (!user) {
throw new UnauthorizedError('Invalid token'); throw new UnauthorizedError('Invalid token');
} }
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) { if (!courseInstructor) {
throw new ForbiddenError('You are not permitted to update lesson'); throw new ForbiddenError('You are not permitted to update lesson');
} }
@ -581,9 +564,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Lesson updated successfully', data: lesson as LessonData }; return { code: 200, message: 'Lesson updated successfully', data: lesson as LessonData };
} catch (error) { } catch (error) {
logger.error(`Error updating lesson: ${error}`); logger.error(`Error updating lesson: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Lesson', entityType: 'Lesson',
entityId: request.lesson_id, entityId: request.lesson_id,
@ -602,14 +584,13 @@ export class ChaptersLessonService {
*/ */
async reorderLessons(request: ReorderLessonsRequest): Promise<ReorderLessonsResponse> { async reorderLessons(request: ReorderLessonsRequest): Promise<ReorderLessonsResponse> {
try { try {
const { token, course_id, chapter_id, lesson_id, sort_order: newSortOrder } = request; const { userId, course_id, chapter_id, lesson_id, sort_order: newSortOrder } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to reorder lessons'); if (!courseInstructor) throw new ForbiddenError('You are not permitted to reorder lessons');
// Verify chapter exists and belongs to the course // Verify chapter exists and belongs to the course
@ -682,9 +663,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Lessons reordered successfully', data: lessons as LessonData[] }; return { code: 200, message: 'Lessons reordered successfully', data: lessons as LessonData[] };
} catch (error) { } catch (error) {
logger.error(`Error reordering lessons: ${error}`); logger.error(`Error reordering lessons: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Lesson', entityType: 'Lesson',
entityId: request.lesson_id, entityId: request.lesson_id,
@ -704,14 +684,13 @@ export class ChaptersLessonService {
*/ */
async deleteLesson(request: DeleteLessonRequest): Promise<DeleteLessonResponse> { async deleteLesson(request: DeleteLessonRequest): Promise<DeleteLessonResponse> {
try { try {
const { token, course_id, lesson_id } = request; const { userId, course_id, lesson_id } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to delete this lesson'); if (!courseInstructor) throw new ForbiddenError('You are not permitted to delete this lesson');
// Fetch lesson with all related data // Fetch lesson with all related data
@ -751,7 +730,7 @@ export class ChaptersLessonService {
// Audit log - DELETE Lesson // Audit log - DELETE Lesson
auditService.log({ auditService.log({
userId: decodedToken.id, userId: userId,
action: AuditAction.DELETE, action: AuditAction.DELETE,
entityType: 'Lesson', entityType: 'Lesson',
entityId: lesson_id, entityId: lesson_id,
@ -764,9 +743,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Lesson deleted successfully' }; return { code: 200, message: 'Lesson deleted successfully' };
} catch (error) { } catch (error) {
logger.error(`Error deleting lesson: ${error}`); logger.error(`Error deleting lesson: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Lesson', entityType: 'Lesson',
entityId: request.lesson_id, entityId: request.lesson_id,
@ -789,14 +767,13 @@ export class ChaptersLessonService {
*/ */
async uploadVideo(request: UploadVideoInput): Promise<VideoOperationResponse> { async uploadVideo(request: UploadVideoInput): Promise<VideoOperationResponse> {
try { try {
const { token, course_id, lesson_id, video } = request; const { userId, course_id, lesson_id, video } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists and is VIDEO type // Verify lesson exists and is VIDEO type
@ -833,7 +810,7 @@ export class ChaptersLessonService {
// Audit log - UPLOAD_FILE (Video) // Audit log - UPLOAD_FILE (Video)
auditService.log({ auditService.log({
userId: decodedToken.id, userId: userId,
action: AuditAction.UPLOAD_FILE, action: AuditAction.UPLOAD_FILE,
entityType: 'Lesson', entityType: 'Lesson',
entityId: lesson_id, entityId: lesson_id,
@ -853,9 +830,8 @@ export class ChaptersLessonService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error uploading video: ${error}`); logger.error(`Error uploading video: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Lesson', entityType: 'Lesson',
entityId: request.lesson_id, entityId: request.lesson_id,
@ -874,14 +850,13 @@ export class ChaptersLessonService {
*/ */
async updateVideo(request: UpdateVideoInput): Promise<VideoOperationResponse> { async updateVideo(request: UpdateVideoInput): Promise<VideoOperationResponse> {
try { try {
const { token, course_id, lesson_id, video } = request; const { userId, course_id, lesson_id, video } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists and is VIDEO type // Verify lesson exists and is VIDEO type
@ -946,9 +921,8 @@ export class ChaptersLessonService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error updating video: ${error}`); logger.error(`Error updating video: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Lesson', entityType: 'Lesson',
entityId: request.lesson_id, entityId: request.lesson_id,
@ -967,14 +941,13 @@ export class ChaptersLessonService {
*/ */
async setYouTubeVideo(request: SetYouTubeVideoInput): Promise<YouTubeVideoResponse> { async setYouTubeVideo(request: SetYouTubeVideoInput): Promise<YouTubeVideoResponse> {
try { try {
const { token, course_id, lesson_id, youtube_video_id, video_title } = request; const { userId, course_id, lesson_id, youtube_video_id, video_title } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists and is VIDEO type // Verify lesson exists and is VIDEO type
@ -1038,9 +1011,8 @@ export class ChaptersLessonService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error setting YouTube video: ${error}`); logger.error(`Error setting YouTube video: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Lesson', entityType: 'Lesson',
entityId: request.lesson_id, entityId: request.lesson_id,
@ -1059,14 +1031,13 @@ export class ChaptersLessonService {
*/ */
async uploadAttachment(request: UploadAttachmentInput): Promise<AttachmentOperationResponse> { async uploadAttachment(request: UploadAttachmentInput): Promise<AttachmentOperationResponse> {
try { try {
const { token, course_id, lesson_id, attachment } = request; const { userId, course_id, lesson_id, attachment } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists // Verify lesson exists
@ -1101,7 +1072,7 @@ export class ChaptersLessonService {
// Audit log - UPLOAD_FILE (Attachment) // Audit log - UPLOAD_FILE (Attachment)
auditService.log({ auditService.log({
userId: decodedToken.id, userId: userId,
action: AuditAction.UPLOAD_FILE, action: AuditAction.UPLOAD_FILE,
entityType: 'LessonAttachment', entityType: 'LessonAttachment',
entityId: newAttachment.id, entityId: newAttachment.id,
@ -1125,9 +1096,8 @@ export class ChaptersLessonService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error uploading attachment: ${error}`); logger.error(`Error uploading attachment: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'LessonAttachment', entityType: 'LessonAttachment',
entityId: request.lesson_id, entityId: request.lesson_id,
@ -1146,14 +1116,13 @@ export class ChaptersLessonService {
*/ */
async deleteAttachment(request: DeleteAttachmentInput): Promise<DeleteAttachmentResponse> { async deleteAttachment(request: DeleteAttachmentInput): Promise<DeleteAttachmentResponse> {
try { try {
const { token, course_id, lesson_id, attachment_id } = request; const { userId, course_id, lesson_id, attachment_id } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists // Verify lesson exists
@ -1184,7 +1153,7 @@ export class ChaptersLessonService {
// Audit log - DELETE_FILE (Attachment) // Audit log - DELETE_FILE (Attachment)
auditService.log({ auditService.log({
userId: decodedToken.id, userId: userId,
action: AuditAction.DELETE_FILE, action: AuditAction.DELETE_FILE,
entityType: 'LessonAttachment', entityType: 'LessonAttachment',
entityId: attachment_id, entityId: attachment_id,
@ -1194,9 +1163,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Attachment deleted successfully' }; return { code: 200, message: 'Attachment deleted successfully' };
} catch (error) { } catch (error) {
logger.error(`Error deleting attachment: ${error}`); logger.error(`Error deleting attachment: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'LessonAttachment', entityType: 'LessonAttachment',
entityId: request.attachment_id, entityId: request.attachment_id,
@ -1216,14 +1184,13 @@ export class ChaptersLessonService {
*/ */
async addQuestion(request: AddQuestionInput): Promise<AddQuestionResponse> { async addQuestion(request: AddQuestionInput): Promise<AddQuestionResponse> {
try { try {
const { token, course_id, lesson_id, question, explanation, question_type, sort_order, choices } = request; const { userId, course_id, lesson_id, question, explanation, question_type, sort_order, choices } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists and is QUIZ type // Verify lesson exists and is QUIZ type
@ -1281,9 +1248,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Question added successfully', data: completeQuestion as QuizQuestionData }; return { code: 200, message: 'Question added successfully', data: completeQuestion as QuizQuestionData };
} catch (error) { } catch (error) {
logger.error(`Error adding question: ${error}`); logger.error(`Error adding question: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Question', entityType: 'Question',
entityId: 0, entityId: 0,
@ -1303,14 +1269,13 @@ export class ChaptersLessonService {
*/ */
async updateQuestion(request: UpdateQuestionInput): Promise<UpdateQuestionResponse> { async updateQuestion(request: UpdateQuestionInput): Promise<UpdateQuestionResponse> {
try { try {
const { token, course_id, lesson_id, question_id, question, explanation, question_type, sort_order, choices } = request; const { userId, course_id, lesson_id, question_id, question, explanation, question_type, sort_order, choices } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists and is QUIZ type // Verify lesson exists and is QUIZ type
@ -1367,9 +1332,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Question updated successfully', data: completeQuestion as QuizQuestionData }; return { code: 200, message: 'Question updated successfully', data: completeQuestion as QuizQuestionData };
} catch (error) { } catch (error) {
logger.error(`Error updating question: ${error}`); logger.error(`Error updating question: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Question', entityType: 'Question',
entityId: request.question_id, entityId: request.question_id,
@ -1384,14 +1348,13 @@ export class ChaptersLessonService {
async reorderQuestion(request: ReorderQuestionInput): Promise<ReorderQuestionResponse> { async reorderQuestion(request: ReorderQuestionInput): Promise<ReorderQuestionResponse> {
try { try {
const { token, course_id, lesson_id, question_id, sort_order } = request; const { userId, course_id, lesson_id, question_id, sort_order } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists and is QUIZ type // Verify lesson exists and is QUIZ type
@ -1471,9 +1434,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Question reordered successfully', data: questions as QuizQuestionData[] }; return { code: 200, message: 'Question reordered successfully', data: questions as QuizQuestionData[] };
} catch (error) { } catch (error) {
logger.error(`Error reordering question: ${error}`); logger.error(`Error reordering question: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Question', entityType: 'Question',
entityId: request.question_id, entityId: request.question_id,
@ -1493,14 +1455,13 @@ export class ChaptersLessonService {
*/ */
async deleteQuestion(request: DeleteQuestionInput): Promise<DeleteQuestionResponse> { async deleteQuestion(request: DeleteQuestionInput): Promise<DeleteQuestionResponse> {
try { try {
const { token, course_id, lesson_id, question_id } = request; const { userId, course_id, lesson_id, question_id } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists and is QUIZ type // Verify lesson exists and is QUIZ type
@ -1530,9 +1491,8 @@ export class ChaptersLessonService {
return { code: 200, message: 'Question deleted successfully' }; return { code: 200, message: 'Question deleted successfully' };
} catch (error) { } catch (error) {
logger.error(`Error deleting question: ${error}`); logger.error(`Error deleting question: ${error}`);
const decodedToken = jwt.decode(request.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decodedToken?.id || 0, userId: request.userId || 0,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Question', entityType: 'Question',
entityId: request.question_id, entityId: request.question_id,
@ -1680,14 +1640,13 @@ export class ChaptersLessonService {
*/ */
async updateQuiz(request: UpdateQuizInput): Promise<UpdateQuizResponse> { async updateQuiz(request: UpdateQuizInput): Promise<UpdateQuizResponse> {
try { try {
const { token, course_id, lesson_id, title, description, passing_score, time_limit, shuffle_questions, shuffle_choices, show_answers_after_completion, is_skippable, allow_multiple_attempts } = request; const { userId, course_id, lesson_id, title, description, passing_score, time_limit, shuffle_questions, shuffle_choices, show_answers_after_completion, is_skippable, allow_multiple_attempts } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id); await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id); const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson'); if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists and is QUIZ type // Verify lesson exists and is QUIZ type

View file

@ -1,9 +1,7 @@
import { prisma } from '../config/database'; import { prisma } from '../config/database';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { config } from '../config';
import { logger } from '../config/logger'; import { logger } from '../config/logger';
import { UnauthorizedError, ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler'; import { ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler';
import jwt from 'jsonwebtoken';
import { uploadFile, deleteFile, getPresignedUrl } from '../config/minio'; import { uploadFile, deleteFile, getPresignedUrl } from '../config/minio';
import { import {
CreateCourseInput, CreateCourseInput,
@ -27,6 +25,7 @@ import {
SearchInstructorResponse, SearchInstructorResponse,
GetEnrolledStudentsInput, GetEnrolledStudentsInput,
GetEnrolledStudentsResponse, GetEnrolledStudentsResponse,
EnrolledStudentData,
GetQuizScoresInput, GetQuizScoresInput,
GetQuizScoresResponse, GetQuizScoresResponse,
GetQuizAttemptDetailInput, GetQuizAttemptDetailInput,
@ -38,6 +37,7 @@ import {
CloneCourseResponse, CloneCourseResponse,
setCourseDraft, setCourseDraft,
setCourseDraftResponse, setCourseDraftResponse,
GetAllMyStudentsResponse,
} from "../types/CoursesInstructor.types"; } from "../types/CoursesInstructor.types";
import { auditService } from './audit.service'; import { auditService } from './audit.service';
import { AuditAction } from '@prisma/client'; import { AuditAction } from '@prisma/client';
@ -121,10 +121,9 @@ export class CoursesInstructorService {
static async listMyCourses(input: ListMyCoursesInput): Promise<ListMyCourseResponse> { static async listMyCourses(input: ListMyCoursesInput): Promise<ListMyCourseResponse> {
try { try {
const decoded = jwt.verify(input.token, config.jwt.secret) as { id: number; type: string };
const courseInstructors = await prisma.courseInstructor.findMany({ const courseInstructors = await prisma.courseInstructor.findMany({
where: { where: {
user_id: decoded.id, user_id: input.userId,
course: input.status ? { status: input.status } : undefined course: input.status ? { status: input.status } : undefined
}, },
include: { include: {
@ -157,9 +156,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to retrieve courses', { error }); logger.error('Failed to retrieve courses', { error });
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: 0, entityId: 0,
@ -174,12 +172,10 @@ export class CoursesInstructorService {
static async getmyCourse(getmyCourse: getmyCourse): Promise<GetMyCourseResponse> { static async getmyCourse(getmyCourse: getmyCourse): Promise<GetMyCourseResponse> {
try { try {
const decoded = jwt.verify(getmyCourse.token, config.jwt.secret) as { id: number; type: string };
// Check if user is instructor of this course // Check if user is instructor of this course
const courseInstructor = await prisma.courseInstructor.findFirst({ const courseInstructor = await prisma.courseInstructor.findFirst({
where: { where: {
user_id: decoded.id, user_id: getmyCourse.userId,
course_id: getmyCourse.course_id course_id: getmyCourse.course_id
}, },
include: { include: {
@ -225,9 +221,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to retrieve course', { error }); logger.error('Failed to retrieve course', { error });
const decoded = jwt.decode(getmyCourse.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId: getmyCourse.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: getmyCourse.course_id, entityId: getmyCourse.course_id,
@ -240,9 +235,9 @@ export class CoursesInstructorService {
} }
} }
static async updateCourse(token: string, courseId: number, courseData: UpdateCourseInput): Promise<createCourseResponse> { static async updateCourse(userId: number, courseId: number, courseData: UpdateCourseInput): Promise<createCourseResponse> {
try { try {
await this.validateCourseInstructor(token, courseId); await this.validateCourseInstructor(userId, courseId);
const course = await prisma.course.update({ const course = await prisma.course.update({
where: { where: {
@ -258,9 +253,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to update course', { error }); logger.error('Failed to update course', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: courseId, entityId: courseId,
@ -273,9 +267,9 @@ export class CoursesInstructorService {
} }
} }
static async uploadThumbnail(token: string, courseId: number, file: Express.Multer.File): Promise<{ code: number; message: string; data: { course_id: number; thumbnail_url: string } }> { static async uploadThumbnail(userId: number, courseId: number, file: Express.Multer.File): Promise<{ code: number; message: string; data: { course_id: number; thumbnail_url: string } }> {
try { try {
await this.validateCourseInstructor(token, courseId); await this.validateCourseInstructor(userId, courseId);
// Get current course to check for existing thumbnail // Get current course to check for existing thumbnail
const currentCourse = await prisma.course.findUnique({ const currentCourse = await prisma.course.findUnique({
@ -322,9 +316,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to upload thumbnail', { error }); logger.error('Failed to upload thumbnail', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: courseId, entityId: courseId,
@ -337,9 +330,9 @@ export class CoursesInstructorService {
} }
} }
static async deleteCourse(token: string, courseId: number): Promise<createCourseResponse> { static async deleteCourse(userId: number, courseId: number): Promise<createCourseResponse> {
try { try {
const courseInstructorId = await this.validateCourseInstructor(token, courseId); const courseInstructorId = await this.validateCourseInstructor(userId, courseId);
if (!courseInstructorId.is_primary) { if (!courseInstructorId.is_primary) {
throw new ForbiddenError('You have no permission to delete this course'); throw new ForbiddenError('You have no permission to delete this course');
} }
@ -365,9 +358,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to delete course', { error }); logger.error('Failed to delete course', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: courseId, entityId: courseId,
@ -382,11 +374,10 @@ export class CoursesInstructorService {
static async sendCourseForReview(sendCourseForReview: sendCourseForReview): Promise<submitCourseResponse> { static async sendCourseForReview(sendCourseForReview: sendCourseForReview): Promise<submitCourseResponse> {
try { try {
const decoded = jwt.verify(sendCourseForReview.token, config.jwt.secret) as { id: number; type: string };
await prisma.courseApproval.create({ await prisma.courseApproval.create({
data: { data: {
course_id: sendCourseForReview.course_id, course_id: sendCourseForReview.course_id,
submitted_by: decoded.id, submitted_by: sendCourseForReview.userId,
} }
}); });
await prisma.course.update({ await prisma.course.update({
@ -398,7 +389,7 @@ export class CoursesInstructorService {
} }
}); });
await auditService.logSync({ await auditService.logSync({
userId: decoded.id, userId: sendCourseForReview.userId,
action: AuditAction.UPDATE, action: AuditAction.UPDATE,
entityType: 'Course', entityType: 'Course',
entityId: sendCourseForReview.course_id, entityId: sendCourseForReview.course_id,
@ -412,9 +403,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to send course for review', { error }); logger.error('Failed to send course for review', { error });
const decoded = jwt.decode(sendCourseForReview.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId: sendCourseForReview.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: sendCourseForReview.course_id, entityId: sendCourseForReview.course_id,
@ -429,7 +419,7 @@ export class CoursesInstructorService {
static async setCourseDraft(setCourseDraft: setCourseDraft): Promise<setCourseDraftResponse> { static async setCourseDraft(setCourseDraft: setCourseDraft): Promise<setCourseDraftResponse> {
try { try {
await this.validateCourseInstructor(setCourseDraft.token, setCourseDraft.course_id); await this.validateCourseInstructor(setCourseDraft.userId, setCourseDraft.course_id);
await prisma.course.update({ await prisma.course.update({
where: { where: {
id: setCourseDraft.course_id, id: setCourseDraft.course_id,
@ -445,9 +435,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to set course to draft', { error }); logger.error('Failed to set course to draft', { error });
const decoded = jwt.decode(setCourseDraft.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId: setCourseDraft.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: setCourseDraft.course_id, entityId: setCourseDraft.course_id,
@ -460,7 +449,7 @@ export class CoursesInstructorService {
} }
} }
static async getCourseApprovals(token: string, courseId: number): Promise<{ static async getCourseApprovals(userId: number, courseId: number): Promise<{
code: number; code: number;
message: string; message: string;
data: any[]; data: any[];
@ -468,7 +457,7 @@ export class CoursesInstructorService {
}> { }> {
try { try {
// Validate instructor access // Validate instructor access
await this.validateCourseInstructor(token, courseId); await this.validateCourseInstructor(userId, courseId);
const approvals = await prisma.courseApproval.findMany({ const approvals = await prisma.courseApproval.findMany({
where: { course_id: courseId }, where: { course_id: courseId },
@ -491,9 +480,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to retrieve course approvals', { error }); logger.error('Failed to retrieve course approvals', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: courseId, entityId: courseId,
@ -510,8 +498,6 @@ export class CoursesInstructorService {
static async searchInstructors(input: SearchInstructorInput): Promise<SearchInstructorResponse> { static async searchInstructors(input: SearchInstructorInput): Promise<SearchInstructorResponse> {
try { try {
const decoded = jwt.verify(input.token, config.jwt.secret) as { id: number };
// Get existing instructors in the course // Get existing instructors in the course
const existingInstructors = await prisma.courseInstructor.findMany({ const existingInstructors = await prisma.courseInstructor.findMany({
where: { course_id: input.course_id }, where: { course_id: input.course_id },
@ -528,7 +514,7 @@ export class CoursesInstructorService {
], ],
role: { code: 'INSTRUCTOR' }, role: { code: 'INSTRUCTOR' },
id: { id: {
notIn: [decoded.id, ...existingInstructorIds], notIn: [input.userId, ...existingInstructorIds],
}, },
}, },
include: { include: {
@ -563,9 +549,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to search instructors', { error }); logger.error('Failed to search instructors', { error });
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: input.course_id, entityId: input.course_id,
@ -581,7 +566,7 @@ export class CoursesInstructorService {
static async addInstructorToCourse(addinstructorCourse: addinstructorCourse): Promise<addinstructorCourseResponse> { static async addInstructorToCourse(addinstructorCourse: addinstructorCourse): Promise<addinstructorCourseResponse> {
try { try {
// Validate user is instructor of this course // Validate user is instructor of this course
await this.validateCourseInstructor(addinstructorCourse.token, addinstructorCourse.course_id); await this.validateCourseInstructor(addinstructorCourse.userId, addinstructorCourse.course_id);
// Find user by email or username // Find user by email or username
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
@ -619,9 +604,8 @@ export class CoursesInstructorService {
} }
}); });
const decoded = jwt.decode(addinstructorCourse.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: addinstructorCourse.userId,
action: AuditAction.CREATE, action: AuditAction.CREATE,
entityType: 'Course', entityType: 'Course',
entityId: addinstructorCourse.course_id, entityId: addinstructorCourse.course_id,
@ -637,9 +621,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to add instructor to course', { error }); logger.error('Failed to add instructor to course', { error });
const decoded = jwt.decode(addinstructorCourse.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId: addinstructorCourse.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: addinstructorCourse.course_id, entityId: addinstructorCourse.course_id,
@ -654,7 +637,6 @@ export class CoursesInstructorService {
static async removeInstructorFromCourse(removeinstructorCourse: removeinstructorCourse): Promise<removeinstructorCourseResponse> { static async removeInstructorFromCourse(removeinstructorCourse: removeinstructorCourse): Promise<removeinstructorCourseResponse> {
try { try {
const decoded = jwt.verify(removeinstructorCourse.token, config.jwt.secret) as { id: number; type: string };
await prisma.courseInstructor.delete({ await prisma.courseInstructor.delete({
where: { where: {
course_id_user_id: { course_id_user_id: {
@ -665,7 +647,7 @@ export class CoursesInstructorService {
}); });
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: removeinstructorCourse.userId,
action: AuditAction.DELETE, action: AuditAction.DELETE,
entityType: 'Course', entityType: 'Course',
entityId: removeinstructorCourse.course_id, entityId: removeinstructorCourse.course_id,
@ -682,9 +664,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to remove instructor from course', { error }); logger.error('Failed to remove instructor from course', { error });
const decoded = jwt.decode(removeinstructorCourse.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId: removeinstructorCourse.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: removeinstructorCourse.course_id, entityId: removeinstructorCourse.course_id,
@ -699,7 +680,6 @@ export class CoursesInstructorService {
static async listInstructorsOfCourse(listinstructorCourse: listinstructorCourse): Promise<listinstructorCourseResponse> { static async listInstructorsOfCourse(listinstructorCourse: listinstructorCourse): Promise<listinstructorCourseResponse> {
try { try {
const decoded = jwt.verify(listinstructorCourse.token, config.jwt.secret) as { id: number; type: string };
const courseInstructors = await prisma.courseInstructor.findMany({ const courseInstructors = await prisma.courseInstructor.findMany({
where: { where: {
course_id: listinstructorCourse.course_id, course_id: listinstructorCourse.course_id,
@ -743,9 +723,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to retrieve instructors of course', { error }); logger.error('Failed to retrieve instructors of course', { error });
const decoded = jwt.decode(listinstructorCourse.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId: listinstructorCourse.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: listinstructorCourse.course_id, entityId: listinstructorCourse.course_id,
@ -760,7 +739,6 @@ export class CoursesInstructorService {
static async setPrimaryInstructor(setprimaryCourseInstructor: setprimaryCourseInstructor): Promise<setprimaryCourseInstructorResponse> { static async setPrimaryInstructor(setprimaryCourseInstructor: setprimaryCourseInstructor): Promise<setprimaryCourseInstructorResponse> {
try { try {
const decoded = jwt.verify(setprimaryCourseInstructor.token, config.jwt.secret) as { id: number; type: string };
await prisma.courseInstructor.update({ await prisma.courseInstructor.update({
where: { where: {
course_id_user_id: { course_id_user_id: {
@ -774,7 +752,7 @@ export class CoursesInstructorService {
}); });
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: setprimaryCourseInstructor.userId,
action: AuditAction.UPDATE, action: AuditAction.UPDATE,
entityType: 'Course', entityType: 'Course',
entityId: setprimaryCourseInstructor.course_id, entityId: setprimaryCourseInstructor.course_id,
@ -791,9 +769,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to set primary instructor', { error }); logger.error('Failed to set primary instructor', { error });
const decoded = jwt.decode(setprimaryCourseInstructor.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId: setprimaryCourseInstructor.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: setprimaryCourseInstructor.course_id, entityId: setprimaryCourseInstructor.course_id,
@ -806,11 +783,10 @@ export class CoursesInstructorService {
} }
} }
static async validateCourseInstructor(token: string, courseId: number): Promise<{ user_id: number; is_primary: boolean }> { static async validateCourseInstructor(userId: number, courseId: number): Promise<{ user_id: number; is_primary: boolean }> {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
const courseInstructor = await prisma.courseInstructor.findFirst({ const courseInstructor = await prisma.courseInstructor.findFirst({
where: { where: {
user_id: decoded.id, user_id: userId,
course_id: courseId course_id: courseId
} }
}); });
@ -839,10 +815,10 @@ export class CoursesInstructorService {
*/ */
static async getEnrolledStudents(input: GetEnrolledStudentsInput): Promise<GetEnrolledStudentsResponse> { static async getEnrolledStudents(input: GetEnrolledStudentsInput): Promise<GetEnrolledStudentsResponse> {
try { try {
const { token, course_id, page = 1, limit = 20, search, status } = input; const { userId, course_id, page = 1, limit = 20, search, status } = input;
// Validate instructor // Validate instructor
await this.validateCourseInstructor(token, course_id); await this.validateCourseInstructor(userId, course_id);
// Build where clause // Build where clause
const whereClause: any = { course_id }; const whereClause: any = { course_id };
@ -917,9 +893,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error getting enrolled students: ${error}`); logger.error(`Error getting enrolled students: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: input.course_id, entityId: input.course_id,
@ -938,11 +913,10 @@ export class CoursesInstructorService {
*/ */
static async getQuizScores(input: GetQuizScoresInput): Promise<GetQuizScoresResponse> { static async getQuizScores(input: GetQuizScoresInput): Promise<GetQuizScoresResponse> {
try { try {
const { token, course_id, lesson_id, page = 1, limit = 20, search, is_passed } = input; const { userId, course_id, lesson_id, page = 1, limit = 20, search, is_passed } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor // Validate instructor
await this.validateCourseInstructor(token, course_id); await this.validateCourseInstructor(userId, course_id);
// Get lesson and verify it's a QUIZ type // Get lesson and verify it's a QUIZ type
const lesson = await prisma.lesson.findUnique({ const lesson = await prisma.lesson.findUnique({
@ -1095,9 +1069,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error getting quiz scores: ${error}`); logger.error(`Error getting quiz scores: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: input.course_id, entityId: input.course_id,
@ -1116,10 +1089,10 @@ export class CoursesInstructorService {
*/ */
static async getQuizAttemptDetail(input: GetQuizAttemptDetailInput): Promise<GetQuizAttemptDetailResponse> { static async getQuizAttemptDetail(input: GetQuizAttemptDetailInput): Promise<GetQuizAttemptDetailResponse> {
try { try {
const { token, course_id, lesson_id, student_id } = input; const { userId, course_id, lesson_id, student_id } = input;
// Validate instructor // Validate instructor
await this.validateCourseInstructor(token, course_id); await this.validateCourseInstructor(userId, course_id);
// Get lesson and verify it's a QUIZ type // Get lesson and verify it's a QUIZ type
const lesson = await prisma.lesson.findUnique({ const lesson = await prisma.lesson.findUnique({
@ -1219,9 +1192,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error getting quiz attempt detail: ${error}`); logger.error(`Error getting quiz attempt detail: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: input.course_id, entityId: input.course_id,
@ -1240,10 +1212,10 @@ export class CoursesInstructorService {
*/ */
static async getEnrolledStudentDetail(input: GetEnrolledStudentDetailInput): Promise<GetEnrolledStudentDetailResponse> { static async getEnrolledStudentDetail(input: GetEnrolledStudentDetailInput): Promise<GetEnrolledStudentDetailResponse> {
try { try {
const { token, course_id, student_id } = input; const { userId, course_id, student_id } = input;
// Validate instructor // Validate instructor
await this.validateCourseInstructor(token, course_id); await this.validateCourseInstructor(userId, course_id);
// Get student info // Get student info
const student = await prisma.user.findUnique({ const student = await prisma.user.findUnique({
@ -1367,9 +1339,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error getting enrolled student detail: ${error}`); logger.error(`Error getting enrolled student detail: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: input.course_id, entityId: input.course_id,
@ -1386,12 +1357,10 @@ export class CoursesInstructorService {
* *
* Get course approval history for instructor to see rejection reasons * Get course approval history for instructor to see rejection reasons
*/ */
static async getCourseApprovalHistory(token: string, courseId: number): Promise<GetCourseApprovalHistoryResponse> { static async getCourseApprovalHistory(userId: number, courseId: number): Promise<GetCourseApprovalHistoryResponse> {
try { try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor access // Validate instructor access
await this.validateCourseInstructor(token, courseId); await this.validateCourseInstructor(userId, courseId);
// Get course with approval history // Get course with approval history
const course = await prisma.course.findUnique({ const course = await prisma.course.findUnique({
@ -1434,9 +1403,8 @@ export class CoursesInstructorService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error getting course approval history: ${error}`); logger.error(`Error getting course approval history: ${error}`);
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || undefined, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: courseId, entityId: courseId,
@ -1454,11 +1422,10 @@ export class CoursesInstructorService {
*/ */
static async cloneCourse(input: CloneCourseInput): Promise<CloneCourseResponse> { static async cloneCourse(input: CloneCourseInput): Promise<CloneCourseResponse> {
try { try {
const { token, course_id, title } = input; const { userId, course_id, title } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor // Validate instructor
const courseInstructor = await this.validateCourseInstructor(token, course_id); const courseInstructor = await this.validateCourseInstructor(userId, course_id);
if (!courseInstructor) { if (!courseInstructor) {
throw new ForbiddenError('You are not an instructor of this course'); throw new ForbiddenError('You are not an instructor of this course');
} }
@ -1508,7 +1475,7 @@ export class CoursesInstructorService {
is_free: originalCourse.is_free, is_free: originalCourse.is_free,
have_certificate: originalCourse.have_certificate, have_certificate: originalCourse.have_certificate,
status: 'DRAFT', // Reset status status: 'DRAFT', // Reset status
created_by: decoded.id created_by: userId
} }
}); });
@ -1516,7 +1483,7 @@ export class CoursesInstructorService {
await tx.courseInstructor.create({ await tx.courseInstructor.create({
data: { data: {
course_id: createdCourse.id, course_id: createdCourse.id,
user_id: decoded.id, user_id: userId,
is_primary: true is_primary: true
} }
}); });
@ -1589,7 +1556,7 @@ export class CoursesInstructorService {
shuffle_questions: lesson.quiz.shuffle_questions, shuffle_questions: lesson.quiz.shuffle_questions,
shuffle_choices: lesson.quiz.shuffle_choices, shuffle_choices: lesson.quiz.shuffle_choices,
show_answers_after_completion: lesson.quiz.show_answers_after_completion, show_answers_after_completion: lesson.quiz.show_answers_after_completion,
created_by: decoded.id created_by: userId
} }
}); });
@ -1636,7 +1603,7 @@ export class CoursesInstructorService {
}); });
await auditService.logSync({ await auditService.logSync({
userId: decoded.id, userId: input.userId,
action: AuditAction.CREATE, action: AuditAction.CREATE,
entityType: 'Course', entityType: 'Course',
entityId: newCourse.id, entityId: newCourse.id,
@ -1658,9 +1625,8 @@ export class CoursesInstructorService {
} catch (error) { } catch (error) {
logger.error(`Error cloning course: ${error}`); logger.error(`Error cloning course: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Course', entityType: 'Course',
entityId: input.course_id, entityId: input.course_id,
@ -1672,4 +1638,45 @@ export class CoursesInstructorService {
throw error; throw error;
} }
} }
/**
* instructor
* Get all enrolled students across all courses the instructor owns/teaches
*/
static async getMyAllStudents(userId: number): Promise<GetAllMyStudentsResponse> {
try {
// หา course IDs ทั้งหมดที่ instructor สอน
const instructorCourses = await prisma.courseInstructor.findMany({
where: { user_id: userId },
select: { course_id: true }
});
const courseIds = instructorCourses.map(ci => ci.course_id);
if (courseIds.length === 0) {
return { code: 200, message: 'Students retrieved successfully', total_students: 0, total_completed: 0 };
}
// unique students ทั้งหมด
const uniqueStudents = await prisma.enrollment.groupBy({
by: ['user_id'],
where: { course_id: { in: courseIds } },
});
// จำนวน enrollment ที่ COMPLETED
const totalCompleted = await prisma.enrollment.count({
where: { course_id: { in: courseIds }, status: 'COMPLETED' }
});
return {
code: 200,
message: 'Students retrieved successfully',
total_students: uniqueStudents.length,
total_completed: totalCompleted,
};
} catch (error) {
logger.error(`Error getting all students: ${error}`);
throw error;
}
}
} }

View file

@ -133,7 +133,7 @@ export class CoursesStudentService {
async enrollCourse(input: EnrollCourseInput): Promise<EnrollCourseResponse> { async enrollCourse(input: EnrollCourseInput): Promise<EnrollCourseResponse> {
try { try {
const { course_id } = input; const { course_id } = input;
const decoded = jwt.verify(input.token, config.jwt.secret) as { id: number; type: string }; const userId = input.userId;
const course = await prisma.course.findUnique({ const course = await prisma.course.findUnique({
where: { id: course_id }, where: { id: course_id },
@ -146,7 +146,7 @@ export class CoursesStudentService {
const existingEnrollment = await prisma.enrollment.findUnique({ const existingEnrollment = await prisma.enrollment.findUnique({
where: { where: {
unique_enrollment: { unique_enrollment: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
}, },
@ -159,7 +159,7 @@ export class CoursesStudentService {
const enrollment = await prisma.enrollment.create({ const enrollment = await prisma.enrollment.create({
data: { data: {
course_id, course_id,
user_id: decoded.id, user_id: userId,
status: 'ENROLLED', status: 'ENROLLED',
enrolled_at: new Date(), enrolled_at: new Date(),
}, },
@ -167,11 +167,11 @@ export class CoursesStudentService {
// Audit log - ENROLL // Audit log - ENROLL
auditService.log({ auditService.log({
userId: decoded.id, userId: userId,
action: AuditAction.ENROLL, action: AuditAction.ENROLL,
entityType: 'Enrollment', entityType: 'Enrollment',
entityId: enrollment.id, entityId: enrollment.id,
newValue: { course_id, user_id: decoded.id, status: 'ENROLLED' } newValue: { course_id, user_id: userId, status: 'ENROLLED' }
}); });
return { return {
@ -187,9 +187,9 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error enrolling in course: ${error}`); logger.error(`Error enrolling in course: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null; // userId from middleware
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Enrollment', entityType: 'Enrollment',
entityId: 0, entityId: 0,
@ -206,13 +206,13 @@ export class CoursesStudentService {
async GetEnrolledCourses(input: ListEnrolledCoursesInput): Promise<ListEnrolledCoursesResponse> { async GetEnrolledCourses(input: ListEnrolledCoursesInput): Promise<ListEnrolledCoursesResponse> {
try { try {
const { token } = input; // destructure input
const page = input.page ?? 1; const page = input.page ?? 1;
const limit = input.limit ?? 20; const limit = input.limit ?? 20;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; const userId = input.userId;
const enrollments = await prisma.enrollment.findMany({ const enrollments = await prisma.enrollment.findMany({
where: { where: {
user_id: decoded.id, user_id: userId,
}, },
include: { include: {
course: { course: {
@ -230,7 +230,7 @@ export class CoursesStudentService {
}); });
const total = await prisma.enrollment.count({ const total = await prisma.enrollment.count({
where: { where: {
user_id: decoded.id, user_id: userId,
}, },
}); });
@ -274,9 +274,9 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
const decoded = jwt.decode(input.token) as { id: number } | null; // userId from middleware
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Enrollment', entityType: 'Enrollment',
entityId: 0, entityId: 0,
@ -290,8 +290,8 @@ export class CoursesStudentService {
} }
async getCourseLearning(input: GetCourseLearningInput): Promise<GetCourseLearningResponse> { async getCourseLearning(input: GetCourseLearningInput): Promise<GetCourseLearningResponse> {
try { try {
const { token, course_id } = input; const { course_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; const userId = input.userId;
// Get course with chapters and lessons (basic info only) // Get course with chapters and lessons (basic info only)
const course = await prisma.course.findUnique({ const course = await prisma.course.findUnique({
@ -330,7 +330,7 @@ export class CoursesStudentService {
const enrollment = await prisma.enrollment.findUnique({ const enrollment = await prisma.enrollment.findUnique({
where: { where: {
unique_enrollment: { unique_enrollment: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
}, },
@ -345,7 +345,7 @@ export class CoursesStudentService {
prisma.enrollment.update({ prisma.enrollment.update({
where: { where: {
unique_enrollment: { unique_enrollment: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
}, },
@ -357,7 +357,7 @@ export class CoursesStudentService {
const lessonIds = course.chapters.flatMap(ch => ch.lessons.map(l => l.id)); const lessonIds = course.chapters.flatMap(ch => ch.lessons.map(l => l.id));
const lessonProgress = await prisma.lessonProgress.findMany({ const lessonProgress = await prisma.lessonProgress.findMany({
where: { where: {
user_id: decoded.id, user_id: userId,
lesson_id: { in: lessonIds }, lesson_id: { in: lessonIds },
}, },
}); });
@ -453,9 +453,9 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
const decoded = jwt.decode(input.token) as { id: number } | null; // userId from middleware
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Enrollment', entityType: 'Enrollment',
entityId: 0, entityId: 0,
@ -470,8 +470,8 @@ export class CoursesStudentService {
async getlessonContent(input: GetLessonContentInput): Promise<GetLessonContentResponse> { async getlessonContent(input: GetLessonContentInput): Promise<GetLessonContentResponse> {
try { try {
const { token, course_id, lesson_id } = input; const { course_id, lesson_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; const userId = input.userId;
// Import MinIO functions // Import MinIO functions
@ -479,7 +479,7 @@ export class CoursesStudentService {
const enrollment = await prisma.enrollment.findUnique({ const enrollment = await prisma.enrollment.findUnique({
where: { where: {
unique_enrollment: { unique_enrollment: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
}, },
@ -528,7 +528,7 @@ export class CoursesStudentService {
const lessonProgress = await prisma.lessonProgress.findUnique({ const lessonProgress = await prisma.lessonProgress.findUnique({
where: { where: {
user_id_lesson_id: { user_id_lesson_id: {
user_id: decoded.id, user_id: userId,
lesson_id, lesson_id,
}, },
}, },
@ -639,7 +639,7 @@ export class CoursesStudentService {
// Get latest quiz attempt for this user // Get latest quiz attempt for this user
latestQuizAttempt = await prisma.quizAttempt.findFirst({ latestQuizAttempt = await prisma.quizAttempt.findFirst({
where: { where: {
user_id: decoded.id, user_id: userId,
quiz_id: lesson.quiz.id, quiz_id: lesson.quiz.id,
}, },
orderBy: { orderBy: {
@ -726,9 +726,9 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
const decoded = jwt.decode(input.token) as { id: number } | null; // userId from middleware
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Enrollment', entityType: 'Enrollment',
entityId: 0, entityId: 0,
@ -744,14 +744,14 @@ export class CoursesStudentService {
async checkAccessLesson(input: CheckLessonAccessInput): Promise<CheckLessonAccessResponse> { async checkAccessLesson(input: CheckLessonAccessInput): Promise<CheckLessonAccessResponse> {
try { try {
const { token, course_id, lesson_id } = input; const { course_id, lesson_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; const userId = input.userId;
// Check enrollment // Check enrollment
const enrollment = await prisma.enrollment.findUnique({ const enrollment = await prisma.enrollment.findUnique({
where: { where: {
unique_enrollment: { unique_enrollment: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
}, },
@ -845,7 +845,7 @@ export class CoursesStudentService {
// Get user's progress for prerequisite lessons // Get user's progress for prerequisite lessons
const prerequisiteProgress = await prisma.lessonProgress.findMany({ const prerequisiteProgress = await prisma.lessonProgress.findMany({
where: { where: {
user_id: decoded.id, user_id: userId,
lesson_id: { in: prerequisiteIds }, lesson_id: { in: prerequisiteIds },
}, },
}); });
@ -879,7 +879,7 @@ export class CoursesStudentService {
// Check if user passed the quiz // Check if user passed the quiz
const quizAttempt = await prisma.quizAttempt.findFirst({ const quizAttempt = await prisma.quizAttempt.findFirst({
where: { where: {
user_id: decoded.id, user_id: userId,
quiz_id: prereqLesson.quiz.id, quiz_id: prereqLesson.quiz.id,
is_passed: true, is_passed: true,
}, },
@ -925,9 +925,9 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
const decoded = jwt.decode(input.token) as { id: number } | null; // userId from middleware
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Enrollment', entityType: 'Enrollment',
entityId: 0, entityId: 0,
@ -942,8 +942,8 @@ export class CoursesStudentService {
async getVideoProgress(input: GetVideoProgressInput): Promise<GetVideoProgressResponse> { async getVideoProgress(input: GetVideoProgressInput): Promise<GetVideoProgressResponse> {
try { try {
const { token, lesson_id } = input; const { lesson_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; const userId = input.userId;
// Get lesson to find course_id // Get lesson to find course_id
const lesson = await prisma.lesson.findUnique({ const lesson = await prisma.lesson.findUnique({
@ -966,7 +966,7 @@ export class CoursesStudentService {
const enrollment = await prisma.enrollment.findUnique({ const enrollment = await prisma.enrollment.findUnique({
where: { where: {
unique_enrollment: { unique_enrollment: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
}, },
@ -980,7 +980,7 @@ export class CoursesStudentService {
const progress = await prisma.lessonProgress.findUnique({ const progress = await prisma.lessonProgress.findUnique({
where: { where: {
user_id_lesson_id: { user_id_lesson_id: {
user_id: decoded.id, user_id: userId,
lesson_id, lesson_id,
}, },
}, },
@ -1010,9 +1010,9 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
const decoded = jwt.decode(input.token) as { id: number } | null; // userId from middleware
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Enrollment', entityType: 'Enrollment',
entityId: 0, entityId: 0,
@ -1027,8 +1027,8 @@ export class CoursesStudentService {
async saveVideoProgress(input: SaveVideoProgressInput): Promise<SaveVideoProgressResponse> { async saveVideoProgress(input: SaveVideoProgressInput): Promise<SaveVideoProgressResponse> {
try { try {
const { token, lesson_id, video_progress_seconds, video_duration_seconds } = input; const { lesson_id, video_progress_seconds, video_duration_seconds } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; const userId = input.userId;
// Get lesson to find course_id // Get lesson to find course_id
const lesson = await prisma.lesson.findUnique({ const lesson = await prisma.lesson.findUnique({
@ -1051,7 +1051,7 @@ export class CoursesStudentService {
const enrollment = await prisma.enrollment.findUnique({ const enrollment = await prisma.enrollment.findUnique({
where: { where: {
unique_enrollment: { unique_enrollment: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
}, },
@ -1074,12 +1074,12 @@ export class CoursesStudentService {
const progress = await prisma.lessonProgress.upsert({ const progress = await prisma.lessonProgress.upsert({
where: { where: {
user_id_lesson_id: { user_id_lesson_id: {
user_id: decoded.id, user_id: userId,
lesson_id, lesson_id,
}, },
}, },
create: { create: {
user_id: decoded.id, user_id: userId,
lesson_id, lesson_id,
video_progress_seconds, video_progress_seconds,
video_duration_seconds: video_duration_seconds ?? null, video_duration_seconds: video_duration_seconds ?? null,
@ -1098,7 +1098,7 @@ export class CoursesStudentService {
// If video completed, mark lesson as complete and update enrollment progress // If video completed, mark lesson as complete and update enrollment progress
let enrollmentProgress: { progress_percentage: number; is_course_completed: boolean } | undefined; let enrollmentProgress: { progress_percentage: number; is_course_completed: boolean } | undefined;
if (isCompleted) { if (isCompleted) {
const result = await this.markLessonComplete(decoded.id, lesson_id, course_id); const result = await this.markLessonComplete(userId, lesson_id, course_id);
enrollmentProgress = result.enrollmentProgress; enrollmentProgress = result.enrollmentProgress;
} }
@ -1118,9 +1118,9 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
const decoded = jwt.decode(input.token) as { id: number } | null; // userId from middleware
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Enrollment', entityType: 'Enrollment',
entityId: 0, entityId: 0,
@ -1135,8 +1135,8 @@ export class CoursesStudentService {
async completeLesson(input: CompleteLessonInput): Promise<CompleteLessonResponse> { async completeLesson(input: CompleteLessonInput): Promise<CompleteLessonResponse> {
try { try {
const { token, lesson_id } = input; const { lesson_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; const userId = input.userId;
// Get lesson with chapter and course info // Get lesson with chapter and course info
const lesson = await prisma.lesson.findUnique({ const lesson = await prisma.lesson.findUnique({
@ -1185,7 +1185,7 @@ export class CoursesStudentService {
const enrollment = await prisma.enrollment.findUnique({ const enrollment = await prisma.enrollment.findUnique({
where: { where: {
unique_enrollment: { unique_enrollment: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
}, },
@ -1196,7 +1196,7 @@ export class CoursesStudentService {
} }
// Mark lesson as complete and update enrollment progress // Mark lesson as complete and update enrollment progress
const { lessonProgress, enrollmentProgress } = await this.markLessonComplete(decoded.id, lesson_id, course_id); const { lessonProgress, enrollmentProgress } = await this.markLessonComplete(userId, lesson_id, course_id);
const { progress_percentage: course_progress_percentage, is_course_completed } = enrollmentProgress; const { progress_percentage: course_progress_percentage, is_course_completed } = enrollmentProgress;
// Find next lesson // Find next lesson
@ -1225,7 +1225,7 @@ export class CoursesStudentService {
// Check if certificate already exists // Check if certificate already exists
const existingCertificate = await prisma.certificate.findFirst({ const existingCertificate = await prisma.certificate.findFirst({
where: { where: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
}); });
@ -1233,10 +1233,10 @@ export class CoursesStudentService {
if (!existingCertificate) { if (!existingCertificate) {
await prisma.certificate.create({ await prisma.certificate.create({
data: { data: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
enrollment_id: enrollment.id, enrollment_id: enrollment.id,
file_path: `certificates/${course_id}/${decoded.id}/${Date.now()}.pdf`, file_path: `certificates/${course_id}/${userId}/${Date.now()}.pdf`,
issued_at: new Date(), issued_at: new Date(),
}, },
}); });
@ -1261,9 +1261,9 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error completing lesson: ${error}`); logger.error(`Error completing lesson: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null; // userId from middleware
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'LessonProgress', entityType: 'LessonProgress',
entityId: input.lesson_id, entityId: input.lesson_id,
@ -1283,14 +1283,14 @@ export class CoursesStudentService {
*/ */
async submitQuiz(input: SubmitQuizInput): Promise<SubmitQuizResponse> { async submitQuiz(input: SubmitQuizInput): Promise<SubmitQuizResponse> {
try { try {
const { token, course_id, lesson_id, answers } = input; const { course_id, lesson_id, answers } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; const userId = input.userId;
// Check enrollment // Check enrollment
const enrollment = await prisma.enrollment.findUnique({ const enrollment = await prisma.enrollment.findUnique({
where: { where: {
unique_enrollment: { unique_enrollment: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
}, },
@ -1331,7 +1331,7 @@ export class CoursesStudentService {
// Get previous attempt count // Get previous attempt count
const previousAttempts = await prisma.quizAttempt.count({ const previousAttempts = await prisma.quizAttempt.count({
where: { where: {
user_id: decoded.id, user_id: userId,
quiz_id: quiz.id, quiz_id: quiz.id,
}, },
}); });
@ -1384,7 +1384,7 @@ export class CoursesStudentService {
const now = new Date(); const now = new Date();
const quizAttempt = await prisma.quizAttempt.create({ const quizAttempt = await prisma.quizAttempt.create({
data: { data: {
user_id: decoded.id, user_id: userId,
quiz_id: quiz.id, quiz_id: quiz.id,
score: earnedScore, score: earnedScore,
total_questions: quiz.questions.length, total_questions: quiz.questions.length,
@ -1400,7 +1400,7 @@ export class CoursesStudentService {
// If passed, mark lesson as complete and update enrollment progress // If passed, mark lesson as complete and update enrollment progress
let enrollmentProgress: { progress_percentage: number; is_course_completed: boolean } | undefined; let enrollmentProgress: { progress_percentage: number; is_course_completed: boolean } | undefined;
if (isPassed) { if (isPassed) {
const result = await this.markLessonComplete(decoded.id, lesson_id, course_id); const result = await this.markLessonComplete(userId, lesson_id, course_id);
enrollmentProgress = result.enrollmentProgress; enrollmentProgress = result.enrollmentProgress;
} }
@ -1429,9 +1429,9 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error submitting quiz: ${error}`); logger.error(`Error submitting quiz: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null; // userId from middleware
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'QuizAttempt', entityType: 'QuizAttempt',
entityId: 0, entityId: 0,
@ -1452,14 +1452,14 @@ export class CoursesStudentService {
*/ */
async getQuizAttempts(input: GetQuizAttemptsInput): Promise<GetQuizAttemptsResponse> { async getQuizAttempts(input: GetQuizAttemptsInput): Promise<GetQuizAttemptsResponse> {
try { try {
const { token, course_id, lesson_id } = input; const { course_id, lesson_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; const userId = input.userId;
// Check enrollment // Check enrollment
const enrollment = await prisma.enrollment.findUnique({ const enrollment = await prisma.enrollment.findUnique({
where: { where: {
unique_enrollment: { unique_enrollment: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
}, },
@ -1494,7 +1494,7 @@ export class CoursesStudentService {
// Get all quiz attempts for this user // Get all quiz attempts for this user
const attempts = await prisma.quizAttempt.findMany({ const attempts = await prisma.quizAttempt.findMany({
where: { where: {
user_id: decoded.id, user_id: userId,
quiz_id: lesson.quiz.id, quiz_id: lesson.quiz.id,
}, },
orderBy: { attempt_number: 'desc' }, orderBy: { attempt_number: 'desc' },
@ -1539,10 +1539,9 @@ export class CoursesStudentService {
}; };
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
const decoded = jwt.decode(input.token) as { id: number } | null; // userId from middleware
if (decoded?.id) {
await auditService.logSync({ await auditService.logSync({
userId: decoded.id, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'QuizAttempt', entityType: 'QuizAttempt',
entityId: 0, entityId: 0,
@ -1553,7 +1552,6 @@ export class CoursesStudentService {
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error)
} }
}); });
}
throw error; throw error;
} }
} }

View file

@ -1,8 +1,6 @@
import { prisma } from '../config/database'; import { prisma } from '../config/database';
import { config } from '../config';
import { logger } from '../config/logger'; import { logger } from '../config/logger';
import { NotFoundError, ValidationError } from '../middleware/errorHandler'; import { NotFoundError, ValidationError } from '../middleware/errorHandler';
import jwt from 'jsonwebtoken';
import { getPresignedUrl } from '../config/minio'; import { getPresignedUrl } from '../config/minio';
import { import {
ListApprovedCoursesResponse, ListApprovedCoursesResponse,
@ -20,7 +18,7 @@ export class RecommendedCoursesService {
* List all approved courses (for admin to manage recommendations) * List all approved courses (for admin to manage recommendations)
*/ */
static async listApprovedCourses( static async listApprovedCourses(
token: string, userId: number,
filters?: { search?: string; categoryId?: number } filters?: { search?: string; categoryId?: number }
): Promise<ListApprovedCoursesResponse> { ): Promise<ListApprovedCoursesResponse> {
try { try {
@ -108,10 +106,8 @@ export class RecommendedCoursesService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to list approved courses', { error }); logger.error('Failed to list approved courses', { error });
const decoded = jwt.decode(token) as { id: number } | null;
if (decoded?.id) {
await auditService.logSync({ await auditService.logSync({
userId: decoded.id, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'RecommendedCourses', entityType: 'RecommendedCourses',
entityId: 0, entityId: 0,
@ -120,7 +116,6 @@ export class RecommendedCoursesService {
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error)
} }
}); });
}
throw error; throw error;
} }
} }
@ -128,7 +123,7 @@ export class RecommendedCoursesService {
/** /**
* Get course by ID (for admin to view details) * Get course by ID (for admin to view details)
*/ */
static async getCourseById(token: string, courseId: number): Promise<GetCourseByIdResponse> { static async getCourseById(userId: number, courseId: number): Promise<GetCourseByIdResponse> {
try { try {
const course = await prisma.course.findUnique({ const course = await prisma.course.findUnique({
where: { id: courseId }, where: { id: courseId },
@ -213,10 +208,8 @@ export class RecommendedCoursesService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to get course by ID', { error }); logger.error('Failed to get course by ID', { error });
const decoded = jwt.decode(token) as { id: number } | null;
if (decoded?.id) {
await auditService.logSync({ await auditService.logSync({
userId: decoded.id, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'RecommendedCourses', entityType: 'RecommendedCourses',
entityId: 0, entityId: 0,
@ -225,7 +218,6 @@ export class RecommendedCoursesService {
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error)
} }
}); });
}
throw error; throw error;
} }
} }
@ -234,12 +226,11 @@ export class RecommendedCoursesService {
* Toggle course recommendation status * Toggle course recommendation status
*/ */
static async toggleRecommended( static async toggleRecommended(
token: string, userId: number,
courseId: number, courseId: number,
isRecommended: boolean isRecommended: boolean
): Promise<ToggleRecommendedResponse> { ): Promise<ToggleRecommendedResponse> {
try { try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
const course = await prisma.course.findUnique({ where: { id: courseId } }); const course = await prisma.course.findUnique({ where: { id: courseId } });
if (!course) { if (!course) {
@ -257,7 +248,7 @@ export class RecommendedCoursesService {
// Audit log // Audit log
await auditService.logSync({ await auditService.logSync({
userId: decoded.id, userId,
action: AuditAction.UPDATE, action: AuditAction.UPDATE,
entityType: 'Course', entityType: 'Course',
entityId: courseId, entityId: courseId,
@ -276,9 +267,8 @@ export class RecommendedCoursesService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to toggle recommended status', { error }); logger.error('Failed to toggle recommended status', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'RecommendedCourses', entityType: 'RecommendedCourses',
entityId: courseId, entityId: courseId,

View file

@ -1,8 +1,6 @@
import { prisma } from '../config/database'; import { prisma } from '../config/database';
import { config } from '../config';
import { logger } from '../config/logger'; import { logger } from '../config/logger';
import { UnauthorizedError, ForbiddenError, NotFoundError } from '../middleware/errorHandler'; import { ForbiddenError, NotFoundError } from '../middleware/errorHandler';
import jwt from 'jsonwebtoken';
import { import {
ListAnnouncementResponse, ListAnnouncementResponse,
CreateAnnouncementInput, CreateAnnouncementInput,
@ -31,27 +29,26 @@ export class AnnouncementsService {
*/ */
async listAnnouncement(input: ListAnnouncementInput): Promise<ListAnnouncementResponse> { async listAnnouncement(input: ListAnnouncementInput): Promise<ListAnnouncementResponse> {
try { try {
const { token, course_id, page = 1, limit = 10 } = input; const { userId, course_id, page = 1, limit = 10 } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
// Check user access - instructor, admin, or enrolled student // Check user access - instructor, admin, or enrolled student
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: decoded.id }, where: { id: userId },
include: { role: true }, include: { role: true },
}); });
if (!user) throw new UnauthorizedError('Invalid token'); if (!user) throw new ForbiddenError('User not found');
// Admin can access all courses // Admin can access all courses
const isAdmin = user.role.code === 'ADMIN'; const isAdmin = user.role.code === 'ADMIN';
// Check if instructor of this course // Check if instructor of this course
const isInstructor = await prisma.courseInstructor.findFirst({ const isInstructor = await prisma.courseInstructor.findFirst({
where: { course_id, user_id: decoded.id }, where: { course_id, user_id: userId },
}); });
// Check if enrolled student // Check if enrolled student
const isEnrolled = await prisma.enrollment.findFirst({ const isEnrolled = await prisma.enrollment.findFirst({
where: { course_id, user_id: decoded.id }, where: { course_id, user_id: userId },
}); });
if (!isAdmin && !isInstructor && !isEnrolled) throw new ForbiddenError('You do not have access to this course announcements'); if (!isAdmin && !isInstructor && !isEnrolled) throw new ForbiddenError('You do not have access to this course announcements');
@ -130,9 +127,8 @@ export class AnnouncementsService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error listing announcements: ${error}`); logger.error(`Error listing announcements: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Announcement', entityType: 'Announcement',
entityId: 0, entityId: 0,
@ -150,11 +146,10 @@ export class AnnouncementsService {
*/ */
async createAnnouncement(input: CreateAnnouncementInput): Promise<CreateAnnouncementResponse> { async createAnnouncement(input: CreateAnnouncementInput): Promise<CreateAnnouncementResponse> {
try { try {
const { token, course_id, title, content, status, is_pinned, published_at, files } = input; const { userId, course_id, title, content, status, is_pinned, published_at, files } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor access // Validate instructor access
await CoursesInstructorService.validateCourseInstructor(token, course_id); await CoursesInstructorService.validateCourseInstructor(userId, course_id);
// Determine published_at: use provided value or default to now if status is PUBLISHED // Determine published_at: use provided value or default to now if status is PUBLISHED
let finalPublishedAt: Date | null = null; let finalPublishedAt: Date | null = null;
@ -171,7 +166,7 @@ export class AnnouncementsService {
status: status as any, status: status as any,
is_pinned, is_pinned,
published_at: finalPublishedAt, published_at: finalPublishedAt,
created_by: decoded.id, created_by: userId,
}, },
}); });
@ -236,9 +231,8 @@ export class AnnouncementsService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error creating announcement: ${error}`); logger.error(`Error creating announcement: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Announcement', entityType: 'Announcement',
entityId: 0, entityId: 0,
@ -256,11 +250,10 @@ export class AnnouncementsService {
*/ */
async updateAnnouncement(input: UpdateAnnouncementInput): Promise<UpdateAnnouncementResponse> { async updateAnnouncement(input: UpdateAnnouncementInput): Promise<UpdateAnnouncementResponse> {
try { try {
const { token, course_id, announcement_id, title, content, status, is_pinned, published_at } = input; const { userId, course_id, announcement_id, title, content, status, is_pinned, published_at } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor access // Validate instructor access
await CoursesInstructorService.validateCourseInstructor(token, course_id); await CoursesInstructorService.validateCourseInstructor(userId, course_id);
// Check announcement exists and belongs to course // Check announcement exists and belongs to course
const existing = await prisma.announcement.findFirst({ const existing = await prisma.announcement.findFirst({
@ -289,7 +282,7 @@ export class AnnouncementsService {
status: status as any, status: status as any,
is_pinned, is_pinned,
published_at: finalPublishedAt, published_at: finalPublishedAt,
updated_by: decoded.id, updated_by: userId,
}, },
include: { include: {
attachments: true, attachments: true,
@ -320,9 +313,8 @@ export class AnnouncementsService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error updating announcement: ${error}`); logger.error(`Error updating announcement: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Announcement', entityType: 'Announcement',
entityId: 0, entityId: 0,
@ -340,11 +332,10 @@ export class AnnouncementsService {
*/ */
async deleteAnnouncement(input: DeleteAnnouncementInput): Promise<DeleteAnnouncementResponse> { async deleteAnnouncement(input: DeleteAnnouncementInput): Promise<DeleteAnnouncementResponse> {
try { try {
const { token, course_id, announcement_id } = input; const { userId, course_id, announcement_id } = input;
jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor access // Validate instructor access
await CoursesInstructorService.validateCourseInstructor(token, course_id); await CoursesInstructorService.validateCourseInstructor(userId, course_id);
// Check announcement exists and belongs to course // Check announcement exists and belongs to course
const existing = await prisma.announcement.findFirst({ const existing = await prisma.announcement.findFirst({
@ -376,9 +367,8 @@ export class AnnouncementsService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error deleting announcement: ${error}`); logger.error(`Error deleting announcement: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Announcement', entityType: 'Announcement',
entityId: 0, entityId: 0,
@ -396,11 +386,10 @@ export class AnnouncementsService {
*/ */
async uploadAttachment(input: UploadAnnouncementAttachmentInput): Promise<UploadAnnouncementAttachmentResponse> { async uploadAttachment(input: UploadAnnouncementAttachmentInput): Promise<UploadAnnouncementAttachmentResponse> {
try { try {
const { token, course_id, announcement_id, file } = input; const { userId, course_id, announcement_id, file } = input;
jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor access // Validate instructor access
await CoursesInstructorService.validateCourseInstructor(token, course_id); await CoursesInstructorService.validateCourseInstructor(userId, course_id);
// Check announcement exists and belongs to course // Check announcement exists and belongs to course
const existing = await prisma.announcement.findFirst({ const existing = await prisma.announcement.findFirst({
@ -451,9 +440,8 @@ export class AnnouncementsService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error uploading attachment: ${error}`); logger.error(`Error uploading attachment: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Announcement', entityType: 'Announcement',
entityId: 0, entityId: 0,
@ -471,11 +459,10 @@ export class AnnouncementsService {
*/ */
async deleteAttachment(input: DeleteAnnouncementAttachmentInput): Promise<DeleteAnnouncementAttachmentResponse> { async deleteAttachment(input: DeleteAnnouncementAttachmentInput): Promise<DeleteAnnouncementAttachmentResponse> {
try { try {
const { token, course_id, announcement_id, attachment_id } = input; const { userId, course_id, announcement_id, attachment_id } = input;
jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor access // Validate instructor access
await CoursesInstructorService.validateCourseInstructor(token, course_id); await CoursesInstructorService.validateCourseInstructor(userId, course_id);
// Check attachment exists and belongs to announcement in this course // Check attachment exists and belongs to announcement in this course
const attachment = await prisma.announcementAttachment.findFirst({ const attachment = await prisma.announcementAttachment.findFirst({
@ -508,9 +495,8 @@ export class AnnouncementsService {
}; };
} catch (error) { } catch (error) {
logger.error(`Error deleting attachment: ${error}`); logger.error(`Error deleting attachment: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Announcement', entityType: 'Announcement',
entityId: 0, entityId: 0,

View file

@ -74,7 +74,6 @@ export class AuthService {
data: { data: {
token, token,
refreshToken, refreshToken,
user: await this.formatUserResponse(user)
} }
}; };
} }

View file

@ -1,10 +1,7 @@
import { prisma } from '../config/database'; import { prisma } from '../config/database';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { config } from '../config';
import { logger } from '../config/logger'; import { logger } from '../config/logger';
import jwt from 'jsonwebtoken';
import { createCategory, createCategoryResponse, deleteCategoryResponse, updateCategory, updateCategoryResponse, ListCategoriesResponse, Category } from '../types/categories.type'; import { createCategory, createCategoryResponse, deleteCategoryResponse, updateCategory, updateCategoryResponse, ListCategoriesResponse, Category } from '../types/categories.type';
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
import { auditService } from './audit.service'; import { auditService } from './audit.service';
import { AuditAction } from '@prisma/client'; import { AuditAction } from '@prisma/client';
@ -26,14 +23,13 @@ export class CategoryService {
} }
} }
async createCategory(token: string, category: createCategory): Promise<createCategoryResponse> { async createCategory(userId: number, category: createCategory): Promise<createCategoryResponse> {
try { try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string };
const newCategory = await prisma.category.create({ const newCategory = await prisma.category.create({
data: category data: category
}); });
auditService.log({ auditService.log({
userId: decoded.id, userId,
action: AuditAction.CREATE, action: AuditAction.CREATE,
entityType: 'Category', entityType: 'Category',
entityId: newCategory.id, entityId: newCategory.id,
@ -47,13 +43,13 @@ export class CategoryService {
name: newCategory.name as { th: string; en: string }, name: newCategory.name as { th: string; en: string },
slug: newCategory.slug, slug: newCategory.slug,
description: newCategory.description as { th: string; en: string }, description: newCategory.description as { th: string; en: string },
created_by: decoded.id, created_by: userId,
} }
}; };
} catch (error) { } catch (error) {
logger.error('Failed to create category', { error }); logger.error('Failed to create category', { error });
await auditService.logSync({ await auditService.logSync({
userId: 0, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Category', entityType: 'Category',
entityId: 0, entityId: 0,
@ -66,15 +62,14 @@ export class CategoryService {
} }
} }
async updateCategory(token: string, id: number, category: updateCategory): Promise<updateCategoryResponse> { async updateCategory(userId: number, id: number, category: updateCategory): Promise<updateCategoryResponse> {
try { try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string };
const updatedCategory = await prisma.category.update({ const updatedCategory = await prisma.category.update({
where: { id }, where: { id },
data: category data: category
}); });
auditService.log({ auditService.log({
userId: decoded.id, userId,
action: AuditAction.UPDATE, action: AuditAction.UPDATE,
entityType: 'Category', entityType: 'Category',
entityId: id, entityId: id,
@ -88,13 +83,13 @@ export class CategoryService {
name: updatedCategory.name as { th: string; en: string }, name: updatedCategory.name as { th: string; en: string },
slug: updatedCategory.slug, slug: updatedCategory.slug,
description: updatedCategory.description as { th: string; en: string }, description: updatedCategory.description as { th: string; en: string },
updated_by: decoded.id, updated_by: userId,
} }
}; };
} catch (error) { } catch (error) {
logger.error('Failed to update category', { error }); logger.error('Failed to update category', { error });
await auditService.logSync({ await auditService.logSync({
userId: 0, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Category', entityType: 'Category',
entityId: 0, entityId: 0,
@ -107,14 +102,13 @@ export class CategoryService {
} }
} }
async deleteCategory(token: string, id: number): Promise<deleteCategoryResponse> { async deleteCategory(userId: number, id: number): Promise<deleteCategoryResponse> {
try { try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string };
const deletedCategory = await prisma.category.delete({ const deletedCategory = await prisma.category.delete({
where: { id } where: { id }
}); });
auditService.log({ auditService.log({
userId: decoded.id, userId,
action: AuditAction.DELETE, action: AuditAction.DELETE,
entityType: 'Category', entityType: 'Category',
entityId: id, entityId: id,
@ -127,7 +121,7 @@ export class CategoryService {
} catch (error) { } catch (error) {
logger.error('Failed to delete category', { error }); logger.error('Failed to delete category', { error });
await auditService.logSync({ await auditService.logSync({
userId: 0, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Category', entityType: 'Category',
entityId: 0, entityId: 0,

View file

@ -1,8 +1,6 @@
import { prisma } from '../config/database'; import { prisma } from '../config/database';
import { config } from '../config';
import { logger } from '../config/logger'; import { logger } from '../config/logger';
import { NotFoundError, ForbiddenError, ValidationError } from '../middleware/errorHandler'; import { NotFoundError, ForbiddenError, ValidationError } from '../middleware/errorHandler';
import jwt from 'jsonwebtoken';
import { PDFDocument, rgb } from 'pdf-lib'; import { PDFDocument, rgb } from 'pdf-lib';
import fontkit from '@pdf-lib/fontkit'; import fontkit from '@pdf-lib/fontkit';
import * as fs from 'fs'; import * as fs from 'fs';
@ -29,14 +27,13 @@ export class CertificateService {
*/ */
async generateCertificate(input: GenerateCertificateInput): Promise<GenerateCertificateResponse> { async generateCertificate(input: GenerateCertificateInput): Promise<GenerateCertificateResponse> {
try { try {
const { token, course_id } = input; const { userId, course_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Check enrollment and completion // Check enrollment and completion
const enrollment = await prisma.enrollment.findUnique({ const enrollment = await prisma.enrollment.findUnique({
where: { where: {
unique_enrollment: { unique_enrollment: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
}, },
@ -65,7 +62,7 @@ export class CertificateService {
// Check if certificate already exists // Check if certificate already exists
const existingCertificate = await prisma.certificate.findFirst({ const existingCertificate = await prisma.certificate.findFirst({
where: { where: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
}); });
@ -103,13 +100,13 @@ export class CertificateService {
// Upload to MinIO // Upload to MinIO
const timestamp = Date.now(); const timestamp = Date.now();
const filePath = `certificates/${course_id}/${decoded.id}/${timestamp}.pdf`; const filePath = `certificates/${course_id}/${userId}/${timestamp}.pdf`;
await uploadFile(filePath, Buffer.from(pdfBytes), 'application/pdf'); await uploadFile(filePath, Buffer.from(pdfBytes), 'application/pdf');
// Save to database // Save to database
const certificate = await prisma.certificate.create({ const certificate = await prisma.certificate.create({
data: { data: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
enrollment_id: enrollment.id, enrollment_id: enrollment.id,
file_path: filePath, file_path: filePath,
@ -118,7 +115,7 @@ export class CertificateService {
}); });
auditService.log({ auditService.log({
userId: decoded.id, userId,
action: AuditAction.CREATE, action: AuditAction.CREATE,
entityType: 'Certificate', entityType: 'Certificate',
entityId: certificate.id, entityId: certificate.id,
@ -139,9 +136,8 @@ export class CertificateService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to generate certificate', { error }); logger.error('Failed to generate certificate', { error });
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Certificate', entityType: 'Certificate',
entityId: 0, entityId: 0,
@ -160,12 +156,11 @@ export class CertificateService {
*/ */
async getCertificate(input: GetCertificateInput): Promise<GetCertificateResponse> { async getCertificate(input: GetCertificateInput): Promise<GetCertificateResponse> {
try { try {
const { token, course_id } = input; const { userId, course_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
const certificate = await prisma.certificate.findFirst({ const certificate = await prisma.certificate.findFirst({
where: { where: {
user_id: decoded.id, user_id: userId,
course_id, course_id,
}, },
include: { include: {
@ -202,9 +197,8 @@ export class CertificateService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to get certificate', { error }); logger.error('Failed to get certificate', { error });
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Certificate', entityType: 'Certificate',
entityId: 0, entityId: 0,
@ -223,12 +217,11 @@ export class CertificateService {
*/ */
async listMyCertificates(input: ListMyCertificatesInput): Promise<ListMyCertificatesResponse> { async listMyCertificates(input: ListMyCertificatesInput): Promise<ListMyCertificatesResponse> {
try { try {
const { token } = input; const { userId } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
const certificates = await prisma.certificate.findMany({ const certificates = await prisma.certificate.findMany({
where: { where: {
user_id: decoded.id, user_id: userId,
}, },
include: { include: {
enrollment: { enrollment: {
@ -267,9 +260,8 @@ export class CertificateService {
}; };
} catch (error) { } catch (error) {
logger.error('Failed to list certificates', { error }); logger.error('Failed to list certificates', { error });
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id, userId: input.userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'Certificate', entityType: 'Certificate',
entityId: 0, entityId: 0,

View file

@ -24,15 +24,10 @@ import { auditService } from './audit.service';
import { AuditAction } from '@prisma/client'; import { AuditAction } from '@prisma/client';
export class UserService { export class UserService {
async getUserProfile(token: string): Promise<UserResponse> { async getUserProfile(userId: number): Promise<UserResponse> {
try { try {
// Decode JWT token to get user ID
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string };
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { where: { id: userId },
id: decoded.id
},
include: { include: {
profile: true, profile: true,
role: true role: true
@ -68,14 +63,6 @@ export class UserService {
} : undefined } : undefined
}; };
} catch (error) { } catch (error) {
if (error instanceof jwt.JsonWebTokenError) {
logger.error('Invalid JWT token:', error);
throw new UnauthorizedError('Invalid token');
}
if (error instanceof jwt.TokenExpiredError) {
logger.error('JWT token expired:', error);
throw new UnauthorizedError('Token expired');
}
logger.error('Error fetching user profile:', error); logger.error('Error fetching user profile:', error);
throw error; throw error;
} }
@ -84,12 +71,9 @@ export class UserService {
/** /**
* Change user password * Change user password
*/ */
async changePassword(token: string, oldPassword: string, newPassword: string): Promise<ChangePasswordResponse> { async changePassword(userId: number, oldPassword: string, newPassword: string): Promise<ChangePasswordResponse> {
try { try {
// Decode JWT token to get user ID const user = await prisma.user.findUnique({ where: { id: userId } });
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string };
const user = await prisma.user.findUnique({ where: { id: decoded.id } });
if (!user) throw new UnauthorizedError('User not found'); if (!user) throw new UnauthorizedError('User not found');
// Check if account is deactivated // Check if account is deactivated
@ -127,21 +111,12 @@ export class UserService {
message: 'Password changed successfully' message: 'Password changed successfully'
}; };
} catch (error) { } catch (error) {
if (error instanceof jwt.JsonWebTokenError) {
logger.error('Invalid JWT token:', error);
throw new UnauthorizedError('Invalid token');
}
if (error instanceof jwt.TokenExpiredError) {
logger.error('JWT token expired:', error);
throw new UnauthorizedError('Token expired');
}
logger.error('Failed to change password', { error }); logger.error('Failed to change password', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'User', entityType: 'User',
entityId: decoded?.id || 0, entityId: userId,
metadata: { metadata: {
operation: 'change_password', operation: 'change_password',
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error)
@ -154,12 +129,9 @@ export class UserService {
/** /**
* Update user profile * Update user profile
*/ */
async updateProfile(token: string, profile: ProfileUpdate): Promise<ProfileUpdateResponse> { async updateProfile(userId: number, profile: ProfileUpdate): Promise<ProfileUpdateResponse> {
try { try {
// Decode JWT token to get user ID const user = await prisma.user.findUnique({ where: { id: userId } });
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string };
const user = await prisma.user.findUnique({ where: { id: decoded.id } });
if (!user) throw new UnauthorizedError('User not found'); if (!user) throw new UnauthorizedError('User not found');
// Check if account is deactivated // Check if account is deactivated
@ -189,21 +161,12 @@ export class UserService {
} }
}; };
} catch (error) { } catch (error) {
if (error instanceof jwt.JsonWebTokenError) {
logger.error('Invalid JWT token:', error);
throw new UnauthorizedError('Invalid token');
}
if (error instanceof jwt.TokenExpiredError) {
logger.error('JWT token expired:', error);
throw new UnauthorizedError('Token expired');
}
logger.error('Failed to update profile', { error }); logger.error('Failed to update profile', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId,
action: AuditAction.UPDATE, action: AuditAction.UPDATE,
entityType: 'UserProfile', entityType: 'UserProfile',
entityId: decoded?.id || 0, entityId: userId,
metadata: { metadata: {
operation: 'update_profile', operation: 'update_profile',
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error)
@ -213,9 +176,8 @@ export class UserService {
} }
} }
async getRoles(token: string): Promise<rolesResponse> { async getRoles(): Promise<rolesResponse> {
try { try {
jwt.verify(token, config.jwt.secret);
const roles = await prisma.role.findMany({ const roles = await prisma.role.findMany({
select: { select: {
id: true, id: true,
@ -224,14 +186,6 @@ export class UserService {
}); });
return { roles }; return { roles };
} catch (error) { } 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 }); logger.error('Failed to get roles', { error });
throw error; throw error;
} }
@ -240,13 +194,11 @@ export class UserService {
/** /**
* Upload avatar picture to MinIO * Upload avatar picture to MinIO
*/ */
async uploadAvatarPicture(token: string, file: Express.Multer.File): Promise<updateAvatarResponse> { async uploadAvatarPicture(userId: number, file: Express.Multer.File): Promise<updateAvatarResponse> {
try { try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Check if user exists // Check if user exists
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: decoded.id }, where: { id: userId },
include: { profile: true } include: { profile: true }
}); });
@ -265,7 +217,7 @@ export class UserService {
const fileName = file.originalname || 'avatar'; const fileName = file.originalname || 'avatar';
const extension = fileName.split('.').pop() || 'jpg'; const extension = fileName.split('.').pop() || 'jpg';
const safeFilename = `${timestamp}-${uniqueId}.${extension}`; const safeFilename = `${timestamp}-${uniqueId}.${extension}`;
const filePath = `avatars/${decoded.id}/${safeFilename}`; const filePath = `avatars/${userId}/${safeFilename}`;
// Delete old avatar if exists // Delete old avatar if exists
if (user.profile?.avatar_url) { if (user.profile?.avatar_url) {
@ -285,13 +237,13 @@ export class UserService {
// Update or create profile - store only file path // Update or create profile - store only file path
if (user.profile) { if (user.profile) {
await prisma.userProfile.update({ await prisma.userProfile.update({
where: { user_id: decoded.id }, where: { user_id: userId },
data: { avatar_url: filePath } data: { avatar_url: filePath }
}); });
} else { } else {
await prisma.userProfile.create({ await prisma.userProfile.create({
data: { data: {
user_id: decoded.id, user_id: userId,
avatar_url: filePath, avatar_url: filePath,
first_name: '', first_name: '',
last_name: '' last_name: ''
@ -301,10 +253,10 @@ export class UserService {
// Audit log - UPLOAD_AVATAR // Audit log - UPLOAD_AVATAR
await auditService.logSync({ await auditService.logSync({
userId: decoded.id, userId,
action: AuditAction.UPLOAD_FILE, action: AuditAction.UPLOAD_FILE,
entityType: 'User', entityType: 'User',
entityId: decoded.id, entityId: userId,
metadata: { metadata: {
operation: 'upload_avatar', operation: 'upload_avatar',
filePath filePath
@ -318,26 +270,17 @@ export class UserService {
code: 200, code: 200,
message: 'Avatar uploaded successfully', message: 'Avatar uploaded successfully',
data: { data: {
id: decoded.id, id: userId,
avatar_url: presignedUrl avatar_url: presignedUrl
} }
}; };
} catch (error) { } catch (error) {
if (error instanceof jwt.JsonWebTokenError) {
logger.error('Invalid JWT token:', error);
throw new UnauthorizedError('Invalid token');
}
if (error instanceof jwt.TokenExpiredError) {
logger.error('JWT token expired:', error);
throw new UnauthorizedError('Token expired');
}
logger.error('Failed to upload avatar', { error }); logger.error('Failed to upload avatar', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId,
action: AuditAction.UPLOAD_FILE, action: AuditAction.UPLOAD_FILE,
entityType: 'UserProfile', entityType: 'UserProfile',
entityId: decoded?.id || 0, entityId: userId,
metadata: { metadata: {
operation: 'upload_avatar', operation: 'upload_avatar',
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error)
@ -390,12 +333,10 @@ export class UserService {
/** /**
* Send verification email to user * Send verification email to user
*/ */
async sendVerifyEmail(token: string): Promise<SendVerifyEmailResponse> { async sendVerifyEmail(userId: number): Promise<SendVerifyEmailResponse> {
try { try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; email: string; roleCode: string };
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: decoded.id }, where: { id: userId },
include: { role: true } include: { role: true }
}); });
@ -453,15 +394,12 @@ export class UserService {
message: 'Verification email sent successfully' message: 'Verification email sent successfully'
}; };
} catch (error) { } catch (error) {
if (error instanceof jwt.JsonWebTokenError) throw new UnauthorizedError('Invalid token');
if (error instanceof jwt.TokenExpiredError) throw new UnauthorizedError('Token expired');
logger.error('Failed to send verification email', { error }); logger.error('Failed to send verification email', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({ await auditService.logSync({
userId: decoded?.id || 0, userId,
action: AuditAction.ERROR, action: AuditAction.ERROR,
entityType: 'UserProfile', entityType: 'UserProfile',
entityId: decoded?.id || 0, entityId: userId,
metadata: { metadata: {
operation: 'send_verification_email', operation: 'send_verification_email',
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error)

View file

@ -98,18 +98,18 @@ export interface ChapterData {
// ============================================ // ============================================
export interface ChaptersRequest { export interface ChaptersRequest {
token: string; userId: number;
course_id: number; course_id: number;
} }
export interface GetChapterRequest { export interface GetChapterRequest {
token: string; userId: number;
course_id: number; course_id: number;
chapter_id: number; chapter_id: number;
} }
export interface CreateChapterInput { export interface CreateChapterInput {
token: string; userId: number;
course_id: number; course_id: number;
title: MultiLanguageText; title: MultiLanguageText;
description?: MultiLanguageText; description?: MultiLanguageText;
@ -118,13 +118,13 @@ export interface CreateChapterInput {
} }
export interface CreateChapterRequest { export interface CreateChapterRequest {
token: string; userId: number;
course_id: number; course_id: number;
data: CreateChapterInput; data: CreateChapterInput;
} }
export interface UpdateChapterInput { export interface UpdateChapterInput {
token: string; userId: number;
course_id: number; course_id: number;
chapter_id: number; chapter_id: number;
title?: MultiLanguageText; title?: MultiLanguageText;
@ -134,20 +134,20 @@ export interface UpdateChapterInput {
} }
export interface UpdateChapterRequest { export interface UpdateChapterRequest {
token: string; userId: number;
course_id: number; course_id: number;
chapter_id: number; chapter_id: number;
data: UpdateChapterInput; data: UpdateChapterInput;
} }
export interface DeleteChapterRequest { export interface DeleteChapterRequest {
token: string; userId: number;
course_id: number; course_id: number;
chapter_id: number; chapter_id: number;
} }
export interface ReorderChapterRequest { export interface ReorderChapterRequest {
token: string; userId: number;
course_id: number; course_id: number;
chapter_id: number; chapter_id: number;
sort_order: number; sort_order: number;
@ -199,7 +199,7 @@ export interface ReorderChapterResponse {
// ============================================ // ============================================
export interface GetLessonRequest { export interface GetLessonRequest {
token: string; userId: number;
course_id: number; course_id: number;
chapter_id: number; chapter_id: number;
lesson_id: number; lesson_id: number;
@ -216,7 +216,7 @@ export interface UploadedFileInfo {
} }
export interface CreateLessonInput { export interface CreateLessonInput {
token: string; userId: number;
course_id: number; course_id: number;
chapter_id: number; chapter_id: number;
title: MultiLanguageText; title: MultiLanguageText;
@ -293,7 +293,7 @@ export interface QuizChoiceData {
} }
export interface CreateLessonRequest { export interface CreateLessonRequest {
token: string; userId: number;
course_id: number; course_id: number;
chapter_id: number; chapter_id: number;
data: CreateLessonInput; data: CreateLessonInput;
@ -311,7 +311,7 @@ export interface UpdateLessonInput {
} }
export interface UpdateLessonRequest { export interface UpdateLessonRequest {
token: string; userId: number;
course_id: number; course_id: number;
chapter_id: number; chapter_id: number;
lesson_id: number; lesson_id: number;
@ -319,14 +319,14 @@ export interface UpdateLessonRequest {
} }
export interface DeleteLessonRequest { export interface DeleteLessonRequest {
token: string; userId: number;
course_id: number; course_id: number;
chapter_id: number; chapter_id: number;
lesson_id: number; lesson_id: number;
} }
export interface ReorderLessonsRequest { export interface ReorderLessonsRequest {
token: string; userId: number;
course_id: number; course_id: number;
chapter_id: number; chapter_id: number;
lesson_id: number; lesson_id: number;
@ -365,7 +365,7 @@ export interface UpdateLessonResponse {
* Input for uploading video to a lesson * Input for uploading video to a lesson
*/ */
export interface UploadVideoInput { export interface UploadVideoInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
video: UploadedFileInfo; video: UploadedFileInfo;
@ -375,7 +375,7 @@ export interface UploadVideoInput {
* Input for updating (replacing) video in a lesson * Input for updating (replacing) video in a lesson
*/ */
export interface UpdateVideoInput { export interface UpdateVideoInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
video: UploadedFileInfo; video: UploadedFileInfo;
@ -385,7 +385,7 @@ export interface UpdateVideoInput {
* Input for setting YouTube video to a lesson * Input for setting YouTube video to a lesson
*/ */
export interface SetYouTubeVideoInput { export interface SetYouTubeVideoInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
youtube_video_id: string; youtube_video_id: string;
@ -411,7 +411,7 @@ export interface YouTubeVideoResponse {
* Input for uploading a single attachment to a lesson * Input for uploading a single attachment to a lesson
*/ */
export interface UploadAttachmentInput { export interface UploadAttachmentInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
attachment: UploadedFileInfo; attachment: UploadedFileInfo;
@ -421,7 +421,7 @@ export interface UploadAttachmentInput {
* Input for deleting an attachment from a lesson * Input for deleting an attachment from a lesson
*/ */
export interface DeleteAttachmentInput { export interface DeleteAttachmentInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
attachment_id: number; attachment_id: number;
@ -490,7 +490,7 @@ export interface LessonWithDetailsResponse {
* Input for adding quiz to an existing QUIZ lesson * Input for adding quiz to an existing QUIZ lesson
*/ */
export interface AddQuizToLessonInput { export interface AddQuizToLessonInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
quiz_data: { quiz_data: {
@ -509,7 +509,7 @@ export interface AddQuizToLessonInput {
* Input for adding a single question to a quiz lesson * Input for adding a single question to a quiz lesson
*/ */
export interface AddQuestionInput { export interface AddQuestionInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
question: MultiLanguageText; question: MultiLanguageText;
@ -532,7 +532,7 @@ export interface AddQuestionResponse {
* Input for updating a question * Input for updating a question
*/ */
export interface UpdateQuestionInput { export interface UpdateQuestionInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
question_id: number; question_id: number;
@ -556,14 +556,14 @@ export interface UpdateQuestionResponse {
* Input for deleting a question * Input for deleting a question
*/ */
export interface DeleteQuestionInput { export interface DeleteQuestionInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
question_id: number; question_id: number;
} }
export interface ReorderQuestionInput { export interface ReorderQuestionInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
question_id: number; question_id: number;
@ -588,7 +588,7 @@ export interface DeleteQuestionResponse {
* Input for updating quiz settings * Input for updating quiz settings
*/ */
export interface UpdateQuizInput { export interface UpdateQuizInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
title?: MultiLanguageText; title?: MultiLanguageText;

View file

@ -24,7 +24,7 @@ export interface createCourseResponse {
} }
export interface ListMyCoursesInput { export interface ListMyCoursesInput {
token: string; userId: number;
status?: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'ARCHIVED'; status?: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'ARCHIVED';
} }
@ -42,7 +42,7 @@ export interface GetMyCourseResponse {
} }
export interface getmyCourse { export interface getmyCourse {
token: string; userId: number;
course_id: number; course_id: number;
} }
@ -94,13 +94,13 @@ export interface listCourseinstructorResponse {
} }
export interface addinstructorCourse { export interface addinstructorCourse {
token: string; userId: number;
email_or_username: string; email_or_username: string;
course_id: number; course_id: number;
} }
export interface SearchInstructorInput { export interface SearchInstructorInput {
token: string; userId: number;
query: string; query: string;
course_id: number; course_id: number;
} }
@ -145,12 +145,12 @@ export interface listinstructorCourseResponse {
} }
export interface listinstructorCourse { export interface listinstructorCourse {
token: string; userId: number;
course_id: number; course_id: number;
} }
export interface removeinstructorCourse { export interface removeinstructorCourse {
token: string; userId: number;
user_id: number; user_id: number;
course_id: number; course_id: number;
} }
@ -161,7 +161,7 @@ export interface removeinstructorCourseResponse {
} }
export interface setprimaryCourseInstructor { export interface setprimaryCourseInstructor {
token: string; userId: number;
user_id: number; user_id: number;
course_id: number; course_id: number;
} }
@ -172,12 +172,12 @@ export interface setprimaryCourseInstructorResponse {
} }
export interface sendCourseForReview { export interface sendCourseForReview {
token: string; userId: number;
course_id: number; course_id: number;
} }
export interface setCourseDraft { export interface setCourseDraft {
token: string; userId: number;
course_id: number; course_id: number;
} }
@ -220,7 +220,7 @@ export interface GetCourseApprovalsResponse {
// ============================================ // ============================================
export interface GetEnrolledStudentsInput { export interface GetEnrolledStudentsInput {
token: string; userId: number;
course_id: number; course_id: number;
page?: number; page?: number;
limit?: number; limit?: number;
@ -254,7 +254,7 @@ export interface GetEnrolledStudentsResponse {
// ============================================ // ============================================
export interface GetQuizScoresInput { export interface GetQuizScoresInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
page?: number; page?: number;
@ -305,7 +305,7 @@ export interface GetQuizScoresResponse {
// ============================================ // ============================================
export interface GetQuizAttemptDetailInput { export interface GetQuizAttemptDetailInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
student_id: number; student_id: number;
@ -353,7 +353,7 @@ export interface GetQuizAttemptDetailResponse {
// ============================================ // ============================================
export interface GetEnrolledStudentDetailInput { export interface GetEnrolledStudentDetailInput {
token: string; userId: number;
course_id: number; course_id: number;
student_id: number; student_id: number;
} }
@ -435,7 +435,7 @@ export interface GetCourseApprovalHistoryResponse {
} }
export interface CloneCourseInput { export interface CloneCourseInput {
token: string; userId: number;
course_id: number; course_id: number;
title: MultiLanguageText; title: MultiLanguageText;
} }
@ -448,3 +448,14 @@ export interface CloneCourseResponse {
title: MultiLanguageText; title: MultiLanguageText;
}; };
} }
// ============================================
// Get All Students across all instructor courses
// ============================================
export interface GetAllMyStudentsResponse {
code: number;
message: string;
total_students: number;
total_completed: number;
}

View file

@ -9,7 +9,7 @@ export type MultiLangText = MultiLanguageText;
// ============================================ // ============================================
export interface EnrollCourseInput { export interface EnrollCourseInput {
token: string; userId: number;
course_id: number; course_id: number;
} }
@ -26,7 +26,7 @@ export interface EnrollCourseResponse {
} }
export interface ListEnrolledCoursesInput { export interface ListEnrolledCoursesInput {
token: string; userId: number;
page?: number; page?: number;
limit?: number; limit?: number;
status?: EnrollmentStatus; status?: EnrollmentStatus;
@ -64,7 +64,7 @@ export interface ListEnrolledCoursesResponse {
// ============================================ // ============================================
export interface GetCourseLearningInput { export interface GetCourseLearningInput {
token: string; userId: number;
course_id: number; course_id: number;
} }
@ -126,7 +126,7 @@ export interface GetCourseLearningResponse {
// ============================================ // ============================================
export interface GetLessonContentInput { export interface GetLessonContentInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
} }
@ -204,7 +204,7 @@ export interface GetLessonContentResponse {
// ============================================ // ============================================
export interface CheckLessonAccessInput { export interface CheckLessonAccessInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
} }
@ -236,7 +236,7 @@ export interface CheckLessonAccessResponse {
// ============================================ // ============================================
export interface SaveVideoProgressInput { export interface SaveVideoProgressInput {
token: string; userId: number;
lesson_id: number; lesson_id: number;
video_progress_seconds: number; video_progress_seconds: number;
video_duration_seconds?: number; video_duration_seconds?: number;
@ -258,7 +258,7 @@ export interface SaveVideoProgressResponse {
} }
export interface GetVideoProgressInput { export interface GetVideoProgressInput {
token: string; userId: number;
lesson_id: number; lesson_id: number;
} }
@ -281,7 +281,7 @@ export interface GetVideoProgressResponse {
// ============================================ // ============================================
export interface MarkLessonCompleteInput { export interface MarkLessonCompleteInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
} }
@ -314,7 +314,7 @@ export interface EnrollCourseBody {
} }
export interface CompleteLessonInput { export interface CompleteLessonInput {
token: string; userId: number;
lesson_id: number; lesson_id: number;
} }
@ -342,7 +342,7 @@ export interface QuizAnswerInput {
} }
export interface SubmitQuizInput { export interface SubmitQuizInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
answers: QuizAnswerInput[]; answers: QuizAnswerInput[];
@ -384,7 +384,7 @@ export interface SubmitQuizResponse {
// ============================================ // ============================================
export interface GetQuizAttemptsInput { export interface GetQuizAttemptsInput {
token: string; userId: number;
course_id: number; course_id: number;
lesson_id: number; lesson_id: number;
} }

View file

@ -22,7 +22,7 @@ export interface AnnouncementAttachment {
updated_at: Date; updated_at: Date;
} }
export interface ListAnnouncementResponse{ export interface ListAnnouncementResponse {
code: number; code: number;
message: string; message: string;
data: Announcement[]; data: Announcement[];
@ -31,15 +31,15 @@ export interface ListAnnouncementResponse{
limit: number; limit: number;
} }
export interface ListAnnouncementInput{ export interface ListAnnouncementInput {
token: string; userId: number;
course_id: number; course_id: number;
page?: number; page?: number;
limit?: number; limit?: number;
} }
export interface CreateAnnouncementInput{ export interface CreateAnnouncementInput {
token: string; userId: number;
course_id: number; course_id: number;
title: MultiLanguageText; title: MultiLanguageText;
content: MultiLanguageText; content: MultiLanguageText;
@ -49,39 +49,39 @@ export interface CreateAnnouncementInput{
files?: Express.Multer.File[]; files?: Express.Multer.File[];
} }
export interface UploadAnnouncementAttachmentInput{ export interface UploadAnnouncementAttachmentInput {
token: string; userId: number;
course_id: number; course_id: number;
announcement_id: number; announcement_id: number;
file: File; file: File;
} }
export interface UploadAnnouncementAttachmentResponse{ export interface UploadAnnouncementAttachmentResponse {
code: number; code: number;
message: string; message: string;
data: AnnouncementAttachment; data: AnnouncementAttachment;
} }
export interface DeleteAnnouncementAttachmentInput{ export interface DeleteAnnouncementAttachmentInput {
token: string; userId: number;
course_id: number; course_id: number;
announcement_id: number; announcement_id: number;
attachment_id: number; attachment_id: number;
} }
export interface DeleteAnnouncementAttachmentResponse{ export interface DeleteAnnouncementAttachmentResponse {
code: number; code: number;
message: string; message: string;
} }
export interface CreateAnnouncementResponse{ export interface CreateAnnouncementResponse {
code: number; code: number;
message: string; message: string;
data: Announcement; data: Announcement;
} }
export interface UpdateAnnouncementInput{ export interface UpdateAnnouncementInput {
token: string; userId: number;
course_id: number; course_id: number;
announcement_id: number; announcement_id: number;
title: MultiLanguageText; title: MultiLanguageText;
@ -92,19 +92,19 @@ export interface UpdateAnnouncementInput{
attachments?: AnnouncementAttachment[]; attachments?: AnnouncementAttachment[];
} }
export interface UpdateAnnouncementResponse{ export interface UpdateAnnouncementResponse {
code: number; code: number;
message: string; message: string;
data: Announcement; data: Announcement;
} }
export interface DeleteAnnouncementInput{ export interface DeleteAnnouncementInput {
token: string; userId: number;
course_id: number; course_id: number;
announcement_id: number; announcement_id: number;
} }
export interface DeleteAnnouncementResponse{ export interface DeleteAnnouncementResponse {
code: number; code: number;
message: string; message: string;
} }

View file

@ -28,7 +28,6 @@ export interface LoginResponse {
data: { data: {
token: string; token: string;
refreshToken: string; refreshToken: string;
user: UserResponse;
}; };
} }

View file

@ -3,7 +3,7 @@
// ============================================ // ============================================
export interface GenerateCertificateInput { export interface GenerateCertificateInput {
token: string; userId: number;
course_id: number; course_id: number;
} }
@ -19,7 +19,7 @@ export interface GenerateCertificateResponse {
} }
export interface GetCertificateInput { export interface GetCertificateInput {
token: string; userId: number;
course_id: number; course_id: number;
} }
@ -37,7 +37,7 @@ export interface GetCertificateResponse {
} }
export interface ListMyCertificatesInput { export interface ListMyCertificatesInput {
token: string; userId: number;
} }
export interface ListMyCertificatesResponse { export interface ListMyCertificatesResponse {

View file

@ -79,7 +79,7 @@ export const UpdateLessonValidator = Joi.object({
'number.min': 'Duration must be at least 0' 'number.min': 'Duration must be at least 0'
}), }),
sort_order: Joi.number().integer().min(0).optional(), sort_order: Joi.number().integer().min(0).optional(),
prerequisite_lesson_ids: Joi.array().items(Joi.number().integer().positive()).optional(), prerequisite_lesson_ids: Joi.array().items(Joi.number().integer().positive()).allow(null).optional(),
is_published: Joi.boolean().optional() is_published: Joi.boolean().optional()
}); });

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

@ -7,8 +7,8 @@ WORKDIR /app
# คัดลอกไฟล์จัดการ dependencies # คัดลอกไฟล์จัดการ dependencies
COPY package*.json ./ COPY package*.json ./
# ติดตั้ง dependencies (ใช้ npm ci เพื่อความแม่นยำของเวอร์ชัน) # ติดตั้ง dependencies
RUN npm ci RUN npm install
# คัดลอกไฟล์ทั้งหมดในโปรเจกต์ # คัดลอกไฟล์ทั้งหมดในโปรเจกต์
COPY . . COPY . .

View file

@ -27,8 +27,8 @@ onMounted(() => {
</script> </script>
<template> <template>
<!-- แสดง Loader ระหวางเปลยนหน หรอโหลดขอม --> <!-- แสดงแถบโหลดดานบนจอ (Progress Bar) แทนการโหลดเตมหนาจอ -->
<GlobalLoader /> <NuxtLoadingIndicator color="#2563EB" :height="4" />
<!-- NuxtLayout: แสดง Layout กำหนดในแตละเพจ (default: layouts/default.vue) --> <!-- NuxtLayout: แสดง Layout กำหนดในแตละเพจ (default: layouts/default.vue) -->
<NuxtLayout> <NuxtLayout>

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file AnnouncementModal.vue * @file AnnouncementModal.vue
* @description Modal component to display course announcements * @description คอมโพเนนต Modal สำหรบแสดงประกาศของคอรสเรยน (Modal component to display course announcements)
*/ */
const props = defineProps<{ const props = defineProps<{
@ -15,7 +15,7 @@ const emit = defineEmits<{
const { locale, t } = useI18n() const { locale, t } = useI18n()
// Helper for localization // (Helper for localization)
const getLocalizedText = (text: any) => { const getLocalizedText = (text: any) => {
if (!text) return '' if (!text) return ''
if (typeof text === 'string') return text if (typeof text === 'string') return text
@ -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="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}" :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"> <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" /> <q-icon name="push_pin" color="orange" size="18px" class="transform rotate-45" />
</div> </div>

View file

@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file CurriculumSidebar.vue * @file CurriculumSidebar.vue
* @description Sidebar Component for displaying course curriculum (Chapters & Lessons) * @description คอมโพเนนตแถบเมนานขางสำหรบแสดงหลกสตรของคอรสเรยน (บทเรยน & ตอนตางๆ)
* Handles lesson navigation, locked status display, and unread announcement badge. * ดการการนำทางไปยงบทเรยน, แสดงสถานะการลอค, และแจงเตอนประกาศทงไมไดาน
*/ */
const props = defineProps<{ const props = defineProps<{
modelValue: boolean; // Sidebar open state (v-model) modelValue: boolean; // / Sidebar (Sidebar open state - v-model)
courseData: any; courseData: any;
currentLessonId?: number; currentLessonId?: number;
isLoading: boolean; isLoading: boolean;
@ -21,10 +21,10 @@ const emit = defineEmits<{
const { locale } = useI18n() const { locale } = useI18n()
// State for expansion items // (State for expansion items)
const chapterOpenState = ref<Record<string, boolean>>({}) const chapterOpenState = ref<Record<string, boolean>>({})
// Helper for localization // (Helper for localization)
const getLocalizedText = (text: any) => { const getLocalizedText = (text: any) => {
if (!text) return '' if (!text) return ''
if (typeof text === 'string') return text if (typeof text === 'string') return text
@ -34,13 +34,13 @@ const getLocalizedText = (text: any) => {
return text[currentLocale] || text.th || text.en || '' return text[currentLocale] || text.th || text.en || ''
} }
// Helper: Check if lesson is completed // (Helper: Check if lesson is completed)
const isLessonCompleted = (lesson: any) => { const isLessonCompleted = (lesson: any) => {
return lesson.is_completed === true || lesson.progress?.is_completed === true return lesson.is_completed === true || lesson.progress?.is_completed === true
} }
// Reactive Chapter Completion Status // (Reactive Chapter Completion Status)
// Computes a map of chapterId -> boolean (true if all lessons are completed) // Map chapterId -> boolean (true )
const chapterCompletionStatus = computed(() => { const chapterCompletionStatus = computed(() => {
const status: Record<string, boolean> = {} const status: Record<string, boolean> = {}
if (!props.courseData || !props.courseData.chapters) return status if (!props.courseData || !props.courseData.chapters) return status
@ -55,7 +55,7 @@ const chapterCompletionStatus = computed(() => {
return status return status
}) })
// Local Progress Calculation // Local (Local Progress Calculation)
const progressPercentage = computed(() => { const progressPercentage = computed(() => {
if (!props.courseData || !props.courseData.chapters) return 0 if (!props.courseData || !props.courseData.chapters) return 0
let total = 0 let total = 0
@ -69,7 +69,7 @@ const progressPercentage = computed(() => {
return total > 0 ? Math.round((completed / total) * 100) : 0 return total > 0 ? Math.round((completed / total) * 100) : 0
}) })
// Auto-expand chapter containing current lesson // (Auto-expand chapter containing current lesson)
watch(() => props.currentLessonId, (newId) => { watch(() => props.currentLessonId, (newId) => {
if (newId && props.courseData?.chapters) { if (newId && props.courseData?.chapters) {
props.courseData.chapters.forEach((chapter: any) => { props.courseData.chapters.forEach((chapter: any) => {
@ -81,7 +81,7 @@ watch(() => props.currentLessonId, (newId) => {
} }
}, { immediate: true }) }, { immediate: true })
// Initialize all chapters as open by default on load // (Initialize all chapters as open by default on load)
watch(() => props.courseData, (newData) => { watch(() => props.courseData, (newData) => {
if (newData?.chapters) { if (newData?.chapters) {
newData.chapters.forEach((chapter: any) => { newData.chapters.forEach((chapter: any) => {
@ -104,10 +104,10 @@ watch(() => props.courseData, (newData) => {
:breakpoint="1024" :breakpoint="1024"
class="bg-slate-50 dark:!bg-slate-900 shadow-xl" class="bg-slate-50 dark:!bg-slate-900 shadow-xl"
> >
<!-- Main Container: Enforce Column Layout and Full Width --> <!-- คอนเทนเนอรหลกบงคบใชความกวางเตมท (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"> <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) --> <!-- 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"> <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> <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>
@ -123,11 +123,11 @@ watch(() => props.courseData, (newData) => {
</div> </div>
</div> </div>
<!-- 2. Curriculum List (Scrollable Area) --> <!-- 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"> <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"> <q-list class="block w-full">
<div v-for="(chapter, idx) in courseData.chapters" :key="chapter.id" class="block w-full mb-3"> <div v-for="(chapter, idx) in courseData.chapters" :key="chapter.id" class="block w-full mb-3">
<!-- Chapter Accordion --> <!-- กลองขอมลของบท (Chapter Accordion) -->
<q-expansion-item <q-expansion-item
v-model="chapterOpenState[chapter.id]" 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" class="bg-white dark:!bg-slate-800 rounded-xl overflow-hidden shadow-sm border border-slate-200 dark:border-slate-700 w-full"
@ -137,7 +137,7 @@ watch(() => props.courseData, (newData) => {
<template v-slot:header> <template v-slot:header>
<div class="flex items-center w-full py-3 text-slate-900 dark:!text-white"> <div class="flex items-center w-full py-3 text-slate-900 dark:!text-white">
<div class="mr-3 flex-shrink-0"> <div class="mr-3 flex-shrink-0">
<!-- Chapter Indicator (Check or Number) --> <!-- วบงชบทเรยน เครองหมายถกหรอตวเลข (Chapter Indicator - Check or Number) -->
<div class="w-7 h-7 rounded-full border-2 flex items-center justify-center transition-colors font-bold" <div class="w-7 h-7 rounded-full border-2 flex items-center justify-center transition-colors font-bold"
:class="chapterCompletionStatus[chapter.id] :class="chapterCompletionStatus[chapter.id]
? 'border-green-500 text-green-500 bg-green-50 dark:!bg-green-500/10' ? 'border-green-500 text-green-500 bg-green-50 dark:!bg-green-500/10'
@ -146,7 +146,7 @@ watch(() => props.courseData, (newData) => {
<span v-else class="text-[10px]">{{ Number(idx) + 1 }}</span> <span v-else class="text-[10px]">{{ Number(idx) + 1 }}</span>
</div> </div>
</div> </div>
<!-- Explicitly handle text overflow --> <!-- ดการตวอกษรทนเกนอยางชดเจน (Explicitly handle text overflow) -->
<div class="flex-1 min-w-0 pr-2 overflow-hidden"> <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="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"> <div class="text-[10px] text-slate-500 dark:!text-slate-400 font-normal truncate block w-full">
@ -156,7 +156,7 @@ watch(() => props.courseData, (newData) => {
</div> </div>
</template> </template>
<!-- Lessons List --> <!-- รายการบทเรยนยอย (Lessons List) -->
<div class="bg-slate-50 dark:!bg-slate-800/50 border-t border-slate-100 dark:border-slate-700 w-full"> <div class="bg-slate-50 dark:!bg-slate-800/50 border-t border-slate-100 dark:border-slate-700 w-full">
<div <div
v-for="(lesson, lIdx) in chapter.lessons" v-for="(lesson, lIdx) in chapter.lessons"
@ -167,27 +167,27 @@ watch(() => props.courseData, (newData) => {
: 'border-transparent'" : 'border-transparent'"
@click="!lesson.is_locked && emit('select-lesson', lesson.id)" @click="!lesson.is_locked && emit('select-lesson', lesson.id)"
> >
<!-- Lesson Status Icon --> <!-- ไอคอนสถานะของบทเรยน (Lesson Status Icon) -->
<div class="mr-3 flex-shrink-0"> <div class="mr-3 flex-shrink-0">
<!-- Completed (Takes Precedence) --> <!-- เรยนจบแล (สำคญท) (Completed - Takes Precedence) -->
<q-icon v-if="isLessonCompleted(lesson)" <q-icon v-if="isLessonCompleted(lesson)"
name="check_circle" name="check_circle"
class="text-green-500" class="text-green-500"
size="20px" size="20px"
/> />
<!-- Active/Playing (If not completed) --> <!-- กำลงเรยนอย (Active/Playing - If not completed) -->
<q-icon v-else-if="currentLessonId === lesson.id" <q-icon v-else-if="currentLessonId === lesson.id"
name="play_circle_filled" name="play_circle_filled"
class="text-blue-600 dark:!text-blue-400 animate-pulse" class="text-blue-600 dark:!text-blue-400 animate-pulse"
size="20px" size="20px"
/> />
<!-- Locked --> <!-- กลอคอย (Locked) -->
<q-icon v-else-if="lesson.is_locked" <q-icon v-else-if="lesson.is_locked"
name="lock" name="lock"
class="text-slate-400 dark:!text-slate-500 opacity-70" class="text-slate-400 dark:!text-slate-500 opacity-70"
size="18px" size="18px"
/> />
<!-- Not Started --> <!-- งไมไดเร (Not Started) -->
<div v-else class="w-[18px] h-[18px] rounded-full border-2 border-slate-300 dark:border-slate-600"></div> <div v-else class="w-[18px] h-[18px] rounded-full border-2 border-slate-300 dark:border-slate-600"></div>
</div> </div>
@ -214,7 +214,7 @@ watch(() => props.courseData, (newData) => {
</template> </template>
<style scoped> <style scoped>
/* Custom scrollbar for better aesthetics */ /* สครอลบาร์ปรับแต่งเพื่อความสวยงาม (Custom scrollbar for better aesthetics) */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 4px; width: 4px;
} }

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file VideoPlayer.vue * @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<{ const props = defineProps<{
@ -22,7 +22,7 @@ const videoProgress = ref(0);
const currentTime = ref(0); const currentTime = ref(0);
const duration = ref(0); const duration = ref(0);
// Media Prefs // (Media Prefs)
const { volume, muted: isMuted, setVolume, setMuted, applyTo } = useMediaPrefs(); const { volume, muted: isMuted, setVolume, setMuted, applyTo } = useMediaPrefs();
const volumeIcon = computed(() => { const volumeIcon = computed(() => {
@ -40,7 +40,7 @@ const formatTime = (time: number) => {
const currentTimeDisplay = computed(() => formatTime(currentTime.value)); const currentTimeDisplay = computed(() => formatTime(currentTime.value));
const durationDisplay = computed(() => formatTime(duration.value || 0)); const durationDisplay = computed(() => formatTime(duration.value || 0));
// YouTube Helper Logic // YouTube (YouTube Helper Logic)
const isYoutube = computed(() => { const isYoutube = computed(() => {
const s = props.src.toLowerCase(); const s = props.src.toLowerCase();
return s.includes('youtube.com') || s.includes('youtu.be'); return s.includes('youtube.com') || s.includes('youtu.be');
@ -50,7 +50,7 @@ const youtubeEmbedUrl = computed(() => {
if (!isYoutube.value) return ''; if (!isYoutube.value) return '';
let videoId = ''; let videoId = '';
// Extract Video ID // (Extract Video ID)
if (props.src.includes('youtu.be')) { if (props.src.includes('youtu.be')) {
videoId = props.src.split('youtu.be/')[1]?.split('?')[0]; videoId = props.src.split('youtu.be/')[1]?.split('?')[0];
} else { } else {
@ -58,18 +58,18 @@ const youtubeEmbedUrl = computed(() => {
videoId = urlParams.get('v') || ''; 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`; return `https://www.youtube.com/embed/${videoId}?enablejsapi=1&rel=0`;
}); });
// YouTube API Tracking // YouTube API (YouTube API Tracking)
let ytPlayer: any = null; let ytPlayer: any = null;
let ytInterval: any = null; let ytInterval: any = null;
const initYoutubeAPI = () => { const initYoutubeAPI = () => {
if (!isYoutube.value || typeof window === 'undefined') return; 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) { if (!(window as any).YT) {
const tag = document.createElement('script'); const tag = document.createElement('script');
tag.src = "https://www.youtube.com/iframe_api"; tag.src = "https://www.youtube.com/iframe_api";
@ -83,7 +83,7 @@ const initYoutubeAPI = () => {
'onReady': (event: any) => { 'onReady': (event: any) => {
duration.value = event.target.getDuration(); duration.value = event.target.getDuration();
// Resume Logic for YouTube // YouTube (Resume Logic for YouTube)
if (props.initialSeekTime && props.initialSeekTime > 0) { if (props.initialSeekTime && props.initialSeekTime > 0) {
event.target.seekTo(props.initialSeekTime, true); event.target.seekTo(props.initialSeekTime, true);
} }
@ -118,7 +118,7 @@ const startYTTracking = () => {
currentTime.value = ytPlayer.getCurrentTime(); currentTime.value = ytPlayer.getCurrentTime();
emit('timeupdate', currentTime.value, duration.value); emit('timeupdate', currentTime.value, duration.value);
} }
}, 1000); // Check every second }, 1000); // (Check every second)
}; };
const stopYTTracking = () => { const stopYTTracking = () => {
@ -145,7 +145,7 @@ onUnmounted(() => {
destroyYoutubePlayer(); destroyYoutubePlayer();
}); });
// Watch for src change to re-init // src (Watch for src change to re-init)
watch(() => props.src, (newSrc, oldSrc) => { watch(() => props.src, (newSrc, oldSrc) => {
if (newSrc !== oldSrc) { if (newSrc !== oldSrc) {
destroyYoutubePlayer(); destroyYoutubePlayer();
@ -174,8 +174,8 @@ const togglePlay = () => {
playPromise.then(() => { playPromise.then(() => {
isPlaying.value = true; isPlaying.value = true;
}).catch(error => { }).catch(error => {
// Auto-play was prevented or play was interrupted // (Auto-play was prevented or play was interrupted)
// We can safely ignore this error // (We can safely ignore this error)
console.log("Video play request handled:", error.name); console.log("Video play request handled:", error.name);
}); });
} }
@ -223,14 +223,14 @@ const handleVolumeChange = (val: any) => {
setVolume(newVol); setVolume(newVol);
}; };
// Expose video ref for parent to control if needed // video ref (Expose video ref for parent to control if needed)
defineExpose({ defineExpose({
videoRef, videoRef,
pause: () => videoRef.value?.pause(), pause: () => videoRef.value?.pause(),
currentTime: () => videoRef.value?.currentTime || 0 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], () => { watch([volume, isMuted], () => {
if (videoRef.value) applyTo(videoRef.value); if (videoRef.value) applyTo(videoRef.value);
}); });
@ -238,7 +238,7 @@ watch([volume, isMuted], () => {
<template> <template>
<div class="bg-black rounded-xl overflow-hidden shadow-2xl mb-6 aspect-video relative group ring-1 ring-white/10"> <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 <iframe
v-if="isYoutube" v-if="isYoutube"
id="youtube-iframe" id="youtube-iframe"
@ -249,7 +249,7 @@ watch([volume, isMuted], () => {
allowfullscreen allowfullscreen
></iframe> ></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"> <div v-else class="w-full h-full relative group/video cursor-pointer">
<video <video
ref="videoRef" ref="videoRef"
@ -262,9 +262,9 @@ watch([volume, isMuted], () => {
@ended="handleEnded" @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"> <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="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 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> </div>
@ -275,7 +275,7 @@ watch([volume, isMuted], () => {
<div class="flex-grow"></div> <div class="flex-grow"></div>
<!-- Volume Control --> <!-- วควบคมระดบเสยง (Volume Control) -->
<div class="flex items-center gap-2 group/volume relative"> <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" /> <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"> <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"> <script setup lang="ts">
/** /**
* @file FormInput.vue * @file FormInput.vue
* @description Reusable input component with label, error handling, and support for disabled/required states. * @description คอมโพเนนตองกรอกขอม (Input) แบบนำกลบมาใชใหมได พรอมรองรบขอความปายกำก, ดการขอผดพลาด และสถานะปดใชงาน/งคบกรอก
* Now supports password visibility toggle. * รองรบการสลบซอน/แสดงรหสผาน
*/ */
const props = defineProps<{ const props = defineProps<{
@ -16,19 +16,19 @@ const props = defineProps<{
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
/** Update v-model value */ /** อัปเดตค่า v-model (Update v-model value) */
'update:modelValue': [value: string] 'update:modelValue': [value: string]
}>() }>()
// Password visibility state // / (Password visibility state)
const showPassword = ref(false) const showPassword = ref(false)
// Toggle function // (Toggle function)
const togglePassword = () => { const togglePassword = () => {
showPassword.value = !showPassword.value showPassword.value = !showPassword.value
} }
// Compute input type based on visibility state // بناءً pada state (Compute input type based on visibility state)
const inputType = computed(() => { const inputType = computed(() => {
if (props.type === 'password') { if (props.type === 'password') {
return showPassword.value ? 'text' : 'password' return showPassword.value ? 'text' : 'password'
@ -59,7 +59,7 @@ const updateValue = (event: Event) => {
@input="updateValue" @input="updateValue"
> >
<!-- Password Toggle Button --> <!-- มสลบซอน/แสดงรหสผาน (Password Toggle Button) -->
<button <button
v-if="type === 'password'" v-if="type === 'password'"
type="button" type="button"
@ -67,13 +67,13 @@ const updateValue = (event: Event) => {
@click="togglePassword" @click="togglePassword"
tabindex="-1" 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"> <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"/> <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"/> <circle cx="12" cy="12" r="3"/>
</svg> </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"> <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="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"/> <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"> <script setup lang="ts">
/** /**
* @file GlobalLoader.vue * @file GlobalLoader.vue
* @description Global full-screen loading overlay that triggers during page navigation. * @description คอมโพเนนตหนาจอโหลดแบบเตมจอ (Global full-screen loading) แสดงผลตอนเปลยนหน
* Uses a premium pulsing logo animation. * พรมแอนเมชนโลโกขยบไดแบบพรเมยม
*/ */
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
const isLoading = ref(false) const isLoading = ref(false)
// Hook into Nuxt page transitions // Nuxt hook (Hook into Nuxt page transitions)
nuxtApp.hook('page:start', () => { nuxtApp.hook('page:start', () => {
isLoading.value = true isLoading.value = true
}) })
nuxtApp.hook('page:finish', () => { nuxtApp.hook('page:finish', () => {
// Add a small delay for better UX (prevents flickering on fast loads) // (Add a small delay for better UX)
setTimeout(() => { setTimeout(() => {
isLoading.value = false isLoading.value = false
}, 500) }, 500)
@ -25,14 +25,14 @@ nuxtApp.hook('page:finish', () => {
<Transition name="fade"> <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 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"> <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-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"> <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> <span class="text-2xl font-black text-white">E</span>
</div> </div>
</div> </div>
<!-- Loading Text --> <!-- อความระหวางโหลด (Loading Text) -->
<div class="flex flex-col items-center gap-2"> <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> <h3 class="text-lg font-bold text-slate-800 dark:text-white tracking-wide">e-Learning</h3>
<div class="flex gap-1"> <div class="flex gap-1">

View file

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

View file

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

View file

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

View file

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file CourseCard.vue * @file CourseCard.vue
* @description Standardized Course Card Component. * @description คอมโพเนนตการดแสดงคอรสเรยนมาตรฐาน (Standardized Course Card Component)
* Usage: <CourseCard :id="1" title="..." ... /> * ใชงาน: <CourseCard :id="1" title="..." ... />
*/ */
interface CourseCardProps { interface CourseCardProps {
@ -20,7 +20,7 @@ interface CourseCardProps {
image?: string image?: string
loading?: boolean loading?: boolean
// Action Flags // (Action Flags)
showViewDetails?: boolean showViewDetails?: boolean
showContinue?: boolean showContinue?: boolean
showCertificate?: boolean showCertificate?: boolean
@ -59,7 +59,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))
<template> <template>
<div class="group relative flex flex-col bg-white dark:!bg-slate-900 rounded-3xl overflow-hidden border border-slate-200 dark:border-white/5 shadow-sm hover:shadow-xl dark:shadow-none hover:-translate-y-1 transition-all duration-300 h-full"> <div class="group relative flex flex-col bg-white dark:!bg-slate-900 rounded-3xl overflow-hidden border border-slate-200 dark:border-white/5 shadow-sm hover:shadow-xl dark:shadow-none hover:-translate-y-1 transition-all duration-300 h-full">
<!-- Thumbnail Section --> <!-- วนรปหนาปก (Thumbnail Section) -->
<div class="relative w-full aspect-video overflow-hidden"> <div class="relative w-full aspect-video overflow-hidden">
<img <img
v-if="image" v-if="image"
@ -71,12 +71,12 @@ const displayCategory = computed(() => getLocalizedText(props.category))
<q-icon name="image" size="48px" class="text-slate-300 dark:text-slate-600" /> <q-icon name="image" size="48px" class="text-slate-300 dark:text-slate-600" />
</div> </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> <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"> <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"> <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" /> <q-icon name="check" size="24px" />
@ -84,9 +84,9 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</div> </div>
</div> </div>
<!-- Content Section --> <!-- วนเนอหาขอม (Content Section) -->
<div class="p-6 flex flex-col flex-grow"> <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"> <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"> <span v-if="lessons" class="flex items-center gap-1">
<q-icon name="menu_book" size="14px" /> {{ lessons }} {{ $t('course.lessonsUnit') }} <q-icon name="menu_book" size="14px" /> {{ lessons }} {{ $t('course.lessonsUnit') }}
@ -96,18 +96,18 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</span> </span>
</div> </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"> <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 }} {{ displayTitle }}
</h3> </h3>
<!-- Description --> <!-- รายละเอยดเพมเต (Description) -->
<p v-if="displayDescription" class="text-sm text-slate-500 dark:text-slate-400 line-clamp-2 mb-6"> <p v-if="displayDescription" class="text-sm text-slate-500 dark:text-slate-400 line-clamp-2 mb-6">
{{ displayDescription }} {{ displayDescription }}
</p> </p>
<div class="mt-auto pt-4"> <div class="mt-auto pt-4">
<!-- Progress Bar --> <!-- หลอดความคบหน (Progress Bar) -->
<div v-if="progress !== undefined && !completed && !hideProgress" class="mb-4"> <div v-if="progress !== undefined && !completed && !hideProgress" class="mb-4">
<div class="flex justify-between text-[10px] font-bold uppercase mb-1"> <div class="flex justify-between text-[10px] font-bold uppercase mb-1">
<span class="text-slate-500 dark:text-slate-400">{{ $t('course.progress') }}</span> <span class="text-slate-500 dark:text-slate-400">{{ $t('course.progress') }}</span>
@ -118,9 +118,9 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</div> </div>
</div> </div>
<!-- Action Buttons --> <!-- มปฏการตางๆ (Action Buttons) -->
<div v-if="!hideActions" class="flex flex-col gap-3"> <div v-if="!hideActions" class="flex flex-col gap-3">
<!-- View Details (Secondary Action) --> <!-- มดรายละเอยด (มรอง) (View Details - Secondary Action) -->
<q-btn <q-btn
v-if="showViewDetails && !completed && !progress" v-if="showViewDetails && !completed && !progress"
flat flat
@ -130,7 +130,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))
:to="`/course/${id}`" :to="`/course/${id}`"
/> />
<!-- Continue Learning (Primary Action) --> <!-- มเรยนต/เรมเรยน (มหล) (Continue Learning - Primary Action) -->
<q-btn <q-btn
v-if="showContinue || (progress && !completed) || (progress === 0 && !completed)" v-if="showContinue || (progress && !completed) || (progress === 0 && !completed)"
unelevated unelevated
@ -142,7 +142,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</div> </div>
<div v-if="completed" class="space-y-2"> <div v-if="completed" class="space-y-2">
<!-- Study Again --> <!-- มเรยนอกคร (Study Again) -->
<q-btn <q-btn
v-if="showStudyAgain" v-if="showStudyAgain"
unelevated unelevated
@ -152,7 +152,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))
:to="`/classroom/learning?course_id=${id}`" :to="`/classroom/learning?course_id=${id}`"
/> />
<!-- Download Certificate --> <!-- มดาวนโหลดใบรบรอง (Download Certificate) -->
<q-btn <q-btn
v-if="showCertificate" v-if="showCertificate"
unelevated unelevated
@ -168,5 +168,5 @@ const displayCategory = computed(() => getLocalizedText(props.category))
</template> </template>
<style scoped> <style scoped>
/* Scoped overrides if needed */ /* ใส่โค้ด CSS เพิ่มได้ถ้าต้องการครอบคลุมเฉพาะไฟล์นี้ (Scoped overrides if needed) */
</style> </style>

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file CategorySidebar.vue * @file CategorySidebar.vue
* @description Sidebar for filtering courses by category * @description แถบเมนานขางสำหรบกรองคอรสตามหมวดหม (Sidebar for filtering courses by category)
*/ */
const props = defineProps<{ const props = defineProps<{
@ -81,13 +81,13 @@ const toggleCategory = (id: number) => {
{{ getLocalizedText(cat.name) }} {{ getLocalizedText(cat.name) }}
</span> </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 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> </div>
</div> </div>
<!-- Show More/Less Action --> <!-- มแสดงเพมเต/แสดงนอยลง (Show More/Less Action) -->
<div <div
v-if="categories.length > 5" v-if="categories.length > 5"
@click="showAllCategories = !showAllCategories" @click="showAllCategories = !showAllCategories"

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file CourseDetailView.vue * @file CourseDetailView.vue
* @description Quick view of course details including video preview, curriculum, and enroll logic * @description แสดงรายละเอยดคอรสแบบรวดเร รวมถงตวอยางวโอ, หลกสตร, และระบบการลงทะเบยน
*/ */
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
@ -51,9 +51,9 @@ const handleEnroll = () => {
if(!props.course) return; if(!props.course) return;
enrollmentLoading.value = true; enrollmentLoading.value = true;
emit('enroll', props.course.id); emit('enroll', props.course.id);
// Loading state reset depends on parent, but locally we can reset after emit or keep until prop changes // Loading event prop
// In this pattern, we just emit. // event (just emit)
setTimeout(() => enrollmentLoading.value = false, 2000); // Safety timeout setTimeout(() => enrollmentLoading.value = false, 2000); // (Safety timeout)
}; };
const instructorData = computed(() => { const instructorData = computed(() => {
if (props.course?.instructors && props.course.instructors.length > 0) { if (props.course?.instructors && props.course.instructors.length > 0) {
@ -67,10 +67,10 @@ const instructorData = computed(() => {
<template> <template>
<div class="animate-fade-in-up"> <div class="animate-fade-in-up">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Left: Content Detail --> <!-- านซาย: รายละเอยดเนอหา (Left: Content Detail) -->
<div class="lg:col-span-2 space-y-8"> <div class="lg:col-span-2 space-y-8">
<!-- 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]"> <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"> <template v-if="course.media?.video_url">
<video <video
@ -81,19 +81,19 @@ const instructorData = computed(() => {
<source :src="course.media.video_url" type="video/mp4"> <source :src="course.media.video_url" type="video/mp4">
{{ $t('course.videoNotSupported') }} {{ $t('course.videoNotSupported') }}
</video> </video>
<!-- Custom Play Overlay when not playing - simple version is often best --> <!-- มเลนวโอแบบปรบแตงเองตอนยงไมเล (Custom Play Overlay when not playing) -->
</template> </template>
<!-- Beautiful Image Showcase if no video --> <!-- แสดงรปภาพสวยๆ กรณไมโอ (Beautiful Image Showcase if no video) -->
<template v-else> <template v-else>
<div class="w-full h-full flex items-center justify-center relative overflow-hidden bg-slate-950 group"> <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 <img
v-if="course.thumbnail_url || course.cover_image" v-if="course.thumbnail_url || course.cover_image"
:src="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" class="absolute inset-0 w-full h-full object-cover opacity-40 blur-2xl scale-125"
/> />
<!-- Main Sharp Image --> <!-- ปหลกแบบคมช (Main Sharp Image) -->
<img <img
v-if="course.thumbnail_url || course.cover_image" v-if="course.thumbnail_url || course.cover_image"
:src="course.thumbnail_url || course.cover_image" :src="course.thumbnail_url || course.cover_image"
@ -107,7 +107,7 @@ const instructorData = computed(() => {
</template> </template>
</div> </div>
<!-- Course Title & Description --> <!-- อคอรสและรายละเอยด (Course Title & Description) -->
<div> <div>
<h1 class="text-3xl md:text-4xl font-extrabold text-slate-900 dark:text-white mb-4 leading-tight"> <h1 class="text-3xl md:text-4xl font-extrabold text-slate-900 dark:text-white mb-4 leading-tight">
{{ getLocalizedText(course.title) }} {{ getLocalizedText(course.title) }}
@ -118,10 +118,10 @@ const instructorData = computed(() => {
</div> </div>
</div> </div>
<!-- Course Detail - Single Page Layout --> <!-- รายละเอยดคอร - แแบบหนาเดยว (Course Detail - Single Page Layout) -->
<div class="space-y-10"> <div class="space-y-10">
<!-- Instructor Info --> <!-- อมลผสอน (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"> <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"> <q-avatar size="64px">
<img :src="instructorData?.profile?.avatar_url || 'https://cdn.quasar.dev/img/boy-avatar.png'" /> <img :src="instructorData?.profile?.avatar_url || 'https://cdn.quasar.dev/img/boy-avatar.png'" />
@ -135,7 +135,7 @@ const instructorData = computed(() => {
</div> </div>
</div> </div>
<!-- Curriculum / Lesson Details --> <!-- รายละเอยดหลกสตร / บทเรยน (Curriculum / Lesson Details) -->
<div> <div>
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-bold text-slate-900 dark:text-white"> <h3 class="text-xl font-bold text-slate-900 dark:text-white">
@ -148,7 +148,7 @@ const instructorData = computed(() => {
<div class="space-y-4"> <div class="space-y-4">
<div v-for="(chapter, idx) in course.chapters" :key="chapter.id" class="group"> <div v-for="(chapter, idx) in course.chapters" :key="chapter.id" class="group">
<!-- Chapter Header --> <!-- วนหวของบท (Chapter Header) -->
<div class="px-6 py-4 bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-white/5 font-black text-slate-800 dark:text-white flex justify-between items-center mb-2 shadow-sm"> <div class="px-6 py-4 bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-white/5 font-black text-slate-800 dark:text-white flex justify-between items-center mb-2 shadow-sm">
<span class="flex items-center gap-3"> <span class="flex items-center gap-3">
<span class="w-7 h-7 flex items-center justify-center bg-slate-100 dark:bg-white/10 rounded-lg text-xs font-bold font-mono">{{ Number(idx) + 1 }}</span> <span class="w-7 h-7 flex items-center justify-center bg-slate-100 dark:bg-white/10 rounded-lg text-xs font-bold font-mono">{{ Number(idx) + 1 }}</span>
@ -157,7 +157,7 @@ const instructorData = computed(() => {
<span class="text-[10px] uppercase font-black tracking-widest text-slate-400 opacity-60">{{ chapter.lessons?.length || 0 }} {{ $t('course.lessonsUnit') }}</span> <span class="text-[10px] uppercase font-black tracking-widest text-slate-400 opacity-60">{{ chapter.lessons?.length || 0 }} {{ $t('course.lessonsUnit') }}</span>
</div> </div>
<!-- Lessons List --> <!-- รายการบทเรยน (Lessons List) -->
<div class="ml-4 pl-4 border-l-2 border-slate-100 dark:border-slate-800 space-y-1 mt-3"> <div class="ml-4 pl-4 border-l-2 border-slate-100 dark:border-slate-800 space-y-1 mt-3">
<div v-for="lesson in chapter.lessons" :key="lesson.id" class="px-5 py-3 flex items-center gap-3 text-sm text-slate-600 dark:text-slate-400 hover:bg-white dark:hover:bg-white/5 rounded-xl transition-all hover:translate-x-1"> <div v-for="lesson in chapter.lessons" :key="lesson.id" class="px-5 py-3 flex items-center gap-3 text-sm text-slate-600 dark:text-slate-400 hover:bg-white dark:hover:bg-white/5 rounded-xl transition-all hover:translate-x-1">
<div class="w-8 h-8 rounded-full flex items-center justify-center shrink-0" :class="lesson.type === 'VIDEO' ? 'bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400' : 'bg-orange-50 text-orange-600 dark:bg-orange-500/10 dark:text-orange-400'"> <div class="w-8 h-8 rounded-full flex items-center justify-center 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'">
@ -173,7 +173,7 @@ const instructorData = computed(() => {
</div> </div>
</div> </div>
<!-- Empty State --> <!-- กรณไมอม (Empty State) -->
<div v-if="!course.chapters || course.chapters.length === 0" class="flex flex-col items-center justify-center py-12 text-slate-400 dark:text-slate-500 bg-white/50 dark:bg-slate-900/50 rounded-2xl border-2 border-dashed border-slate-200 dark:border-slate-800"> <div v-if="!course.chapters || course.chapters.length === 0" class="flex flex-col items-center justify-center py-12 text-slate-400 dark:text-slate-500 bg-white/50 dark:bg-slate-900/50 rounded-2xl border-2 border-dashed border-slate-200 dark:border-slate-800">
<q-icon name="menu_book" size="40px" class="mb-2 opacity-50" /> <q-icon name="menu_book" size="40px" class="mb-2 opacity-50" />
<p class="text-sm font-medium">{{ $t('course.noContent') }}</p> <p class="text-sm font-medium">{{ $t('course.noContent') }}</p>
@ -185,11 +185,11 @@ const instructorData = computed(() => {
</div> </div>
<!-- Right: Enrollment Card --> <!-- านขวา: การดลงทะเบยน (Right: Enrollment Card) -->
<div class="lg:col-span-1"> <div class="lg:col-span-1">
<div class="sticky top-24"> <div class="sticky top-24">
<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"> <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="absolute -top-12 -right-12 w-48 h-48 bg-blue-500/10 rounded-full blur-3xl group-hover:bg-blue-500/20 transition-colors"></div>
<div class="relative"> <div class="relative">

View file

@ -1,16 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file AppHeader.vue * @file AppHeader.vue
* @description The main header for the EduLearn application dashboard. * @description แถบเมนานบนหล (Header) สำหรบหนาแดชบอร (Dashboard) ของระบบ EduLearn
*/ */
const props = defineProps<{ const props = defineProps<{
/** Controls visibility of the sidebar toggle button */ /** ควบคุมการแสดงผลของปุ่มเปิด/ปิดแถบเมนูด้านข้าง (Sidebar) */
showSidebarToggle?: boolean; showSidebarToggle?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
/** Emitted when the hamburger menu is clicked */ /** ส่งสัญญาณ (Emit) เมื่อผู้ใช้คลิกที่ปุ่มแฮมเบอร์เกอร์เมนู */
toggleSidebar: []; toggleSidebar: [];
}>(); }>();
@ -30,7 +30,7 @@ const toggleTheme = () => {
<template> <template>
<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"> <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">
<!-- Left: Hamburger Toggle --> <!-- านซาย: มยอขยายแถบเมนานขาง (Hamburger Toggle) -->
<q-btn <q-btn
flat flat
round round
@ -43,10 +43,10 @@ const toggleTheme = () => {
<q-space /> <q-space />
<!-- Right Section --> <!-- วนการตงคาทางดานขวา (Right Section) -->
<div class="flex items-center gap-2 sm:gap-4 md:gap-6 no-wrap"> <div class="flex items-center gap-2 sm:gap-4 md:gap-6 no-wrap">
<!-- Theme Toggle --> <!-- มสลบธ (Theme Toggle) -->
<q-btn <q-btn
flat flat
round round
@ -60,7 +60,7 @@ const toggleTheme = () => {
<q-tooltip>{{ isDark ? 'โหมดกลางคืน' : 'โหมดกลางวัน' }}</q-tooltip> <q-tooltip>{{ isDark ? 'โหมดกลางคืน' : 'โหมดกลางวัน' }}</q-tooltip>
</q-btn> </q-btn>
<!-- Language Switcher (Pill Style) --> <!-- วสลบภาษาแบบแคปซ (Language Switcher) -->
<div <div
@click="toggleLanguage" @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" 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"
@ -70,7 +70,7 @@ const toggleTheme = () => {
<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 :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> </div>
<!-- Divider --> <!-- เสนค (Divider) -->
<div class="hidden sm:block w-[1px] h-8 bg-slate-100 dark:bg-slate-800"></div> <div class="hidden sm:block w-[1px] h-8 bg-slate-100 dark:bg-slate-800"></div>
<!-- วนขอมลผใชงาน (User Profile) --> <!-- วนขอมลผใชงาน (User Profile) -->
@ -102,12 +102,12 @@ const toggleTheme = () => {
</template> </template>
<style scoped> <style scoped>
/* Ensure toolbar height is consistent */ /* บังคับให้ความสูงของ Header เท่ากันเสมอ (Ensure toolbar height is consistent) */
:deep(.q-toolbar) { :deep(.q-toolbar) {
min-height: 80px; min-height: 80px;
} }
/* Hide user name only on small mobile screens */ /* ซ่อนชื่อผู้ใช้ไว้เฉพาะบนหน้าจอมือถือขนาดเล็กเท่านั้น (Hide user name only on small mobile screens) */
@media (max-width: 600px) { @media (max-width: 600px) {
.user-info-text { .user-info-text {
display: none !important; display: none !important;

View file

@ -51,7 +51,7 @@ const handleLogout = () => {
<template> <template>
<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"> <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 --> <!-- โลโกแบรนด (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="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"> <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" /> <q-icon name="school" color="white" size="24px" />
@ -59,7 +59,7 @@ const handleLogout = () => {
<span class="text-[22px] font-black tracking-tight text-slate-800 dark:text-white">EduLearn</span> <span class="text-[22px] font-black tracking-tight text-slate-800 dark:text-white">EduLearn</span>
</div> </div>
<!-- Main Navigation --> <!-- การนำทางหล (Main Navigation) -->
<div class="space-y-1 mb-8"> <div class="space-y-1 mb-8">
<NuxtLink <NuxtLink
v-for="item in menuItems" v-for="item in menuItems"
@ -71,12 +71,12 @@ const handleLogout = () => {
<q-icon :name="item.icon" size="24px" class="transition-colors" /> <q-icon :name="item.icon" size="24px" class="transition-colors" />
<span class="font-bold text-[15px]">{{ item.label }}</span> <span class="font-bold text-[15px]">{{ item.label }}</span>
<!-- Active Indicator --> <!-- วบงชหนาปจจ (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> <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> </NuxtLink>
</div> </div>
<!-- Account Section --> <!-- หมวดหมญช (Account Section) -->
<div class="px-4 mb-4"> <div class="px-4 mb-4">
<span class="text-[12px] font-bold text-slate-400 uppercase tracking-widest">{{ $t('sidebar.accountGroup') }}</span> <span class="text-[12px] font-bold text-slate-400 uppercase tracking-widest">{{ $t('sidebar.accountGroup') }}</span>
</div> </div>
@ -92,7 +92,7 @@ const handleLogout = () => {
<span class="font-bold text-[15px]">{{ item.label }}</span> <span class="font-bold text-[15px]">{{ item.label }}</span>
</NuxtLink> </NuxtLink>
<!-- Logout Button --> <!-- มออกจากระบบ (Logout Button) -->
<button <button
@click="handleLogout" @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" 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"
@ -104,7 +104,7 @@ const handleLogout = () => {
<q-space /> <q-space />
<!-- Promo Card --> <!-- การดโปรโมช (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="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"> <div class="relative z-10">
<h4 class="font-black text-slate-800 dark:text-white text-sm mb-1">{{ $t('sidebar.promoTitle') }}</h4> <h4 class="font-black text-slate-800 dark:text-white text-sm mb-1">{{ $t('sidebar.promoTitle') }}</h4>
@ -118,7 +118,7 @@ const handleLogout = () => {
</q-btn> </q-btn>
</div> </div>
<!-- Subtle background decoration --> <!-- การตกแตงพนหลงแบบจางๆ (Subtle background decoration) -->
<div class="absolute -right-2 -bottom-2 w-16 h-16 bg-blue-500/5 rounded-full blur-xl"></div> <div class="absolute -right-2 -bottom-2 w-16 h-16 bg-blue-500/5 rounded-full blur-xl"></div>
</div> </div>
</div> </div>

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file LandingFooter.vue * @file LandingFooter.vue
* @description Footer component for the landing page - Adjusted to Image 2 (E-Learning Platform Branding) * @description วนทายของหนาแรก (Footer component for the landing page)
*/ */
</script> </script>
@ -9,7 +9,7 @@
<footer class="bg-white pt-16 pb-8 border-t border-slate-200"> <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="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"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8 mb-12 text-left">
<!-- Brand --> <!-- โลโกและชอแบรนด (Brand) -->
<div class="space-y-6"> <div class="space-y-6">
<NuxtLink to="/" class="flex items-center gap-3 group"> <NuxtLink to="/" class="flex items-center gap-3 group">
<div class="bg-blue-600 text-white font-black rounded-full px-6 w-10 h-10 flex items-center justify-center group-hover:scale-110 transition-transform"> <div class="bg-blue-600 text-white font-black rounded-full px-6 w-10 h-10 flex items-center justify-center group-hover:scale-110 transition-transform">
@ -29,7 +29,7 @@
</p> </p>
</div> </div>
<!-- Links --> <!-- งกางๆ (Links) -->
<div class="lg:pl-8"> <div class="lg:pl-8">
<h4 class="font-bold text-slate-900 mb-6 text-base tracking-tight">คอรสเรยน</h4> <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"> <ul class="space-y-3 text-sm text-slate-500 flex flex-col gap-2">
@ -39,7 +39,7 @@
</ul> </ul>
</div> </div>
<!-- Support --> <!-- การสนบสนนผใช (Support) -->
<div> <div>
<h4 class="font-bold text-slate-900 mb-6 text-base">วยเหล</h4> <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"> <ul class="space-y-3 text-sm text-slate-500 flex flex-col gap-2">
@ -50,11 +50,11 @@
</ul> </ul>
</div> </div>
<!-- Contact (Bronco Hourse Data) --> <!-- อมลการตดต (Contact) -->
<div class="space-y-6"> <div class="space-y-6">
<h4 class="font-bold text-slate-900 text-base">ดตอเรา</h4> <h4 class="font-bold text-slate-900 text-base">ดตอเรา</h4>
<div class="flex flex-col gap-5"> <div class="flex flex-col gap-5">
<!-- Location --> <!-- สถานท (Location) -->
<div class="flex flex-row items-start gap-4 flex-nowrap"> <div class="flex flex-row items-start gap-4 flex-nowrap">
<q-icon name="o_location_on" size="20px" color="slate-800" /> <q-icon name="o_location_on" size="20px" color="slate-800" />
<div class="flex flex-col gap-1 min-w-0"> <div class="flex flex-col gap-1 min-w-0">
@ -65,18 +65,18 @@
</div> </div>
</div> </div>
<!-- Phone --> <!-- เบอรโทรศพท (Phone) -->
<div class="flex flex-row items-center gap-4 flex-nowrap"> <div class="flex flex-row items-center gap-4 flex-nowrap">
<q-icon name="o_phone" size="18px" color="slate-800" /> <q-icon name="o_phone" size="18px" color="slate-800" />
<a href="tel:052-076-025" class="font-semibold text-slate-800 text-sm hover:text-blue-600 font-semibold text-sm transition-colors truncate"> <a href="tel:052-076-025" class="font-semibold text-slate-800 text-sm hover:text-blue-600 transition-colors truncate">
052-076-025 052-076-025
</a> </a>
</div> </div>
<!-- Email --> <!-- เมล (Email) -->
<div class="flex flex-row items-center gap-4 flex-nowrap"> <div class="flex flex-row items-center gap-4 flex-nowrap">
<q-icon name="o_email" size="18px" color="slate-800" /> <q-icon name="o_email" size="18px" color="slate-800" />
<a href="mailto:info@chamomind.com" class="font-semibold text-slate-800 text-sm hover:text-blue-600 font-semibold text-sm transition-colors truncate"> <a href="mailto:info@chamomind.com" class="font-semibold text-slate-800 text-sm hover:text-blue-600 transition-colors truncate">
info@chamomind.com info@chamomind.com
</a> </a>
</div> </div>
@ -84,7 +84,7 @@
</div> </div>
</div> </div>
<!-- Bottom Bar (Centered Copyright) --> <!-- แถบดานลางสำหรบสงวนลขสทธ (Bottom Bar - Centered Copyright) -->
<div class="pt-8 border-t border-slate-200 text-center"> <div class="pt-8 border-t border-slate-200 text-center">
<p class="text-sm text-slate-400 font-medium tracking-wide"> <p class="text-sm text-slate-400 font-medium tracking-wide">
Copyright © CHAMOMIND CO., LTD. 2023 Copyright © CHAMOMIND CO., LTD. 2023

View file

@ -1,23 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file LandingHeader.vue * @file LandingHeader.vue
* @description The main header for the public landing pages. * @description คอมโพเนนตแถบเมนานบน (Header Navigation) สำหรบหน Landing Page และหนาเปดอนๆ
* Features a transparent background that becomes solid/glass upon scrolling. * รองรบการเปลยนภาษา เปลยนโหมดสวาง/ และเขาถงเมนใช (Profile/Logout)
* Responsive: Collapses into a drawer on mobile (md breakpoint).
*/ */
const text = ref('');
// Track scrolling state to adjust header styling // (scroll) Header
const isScrolled = ref(false) const isScrolled = ref(false)
const { isAuthenticated } = useAuth() const { isAuthenticated } = useAuth()
// Mobile Drawer State // (Mobile Drawer State)
// / (Mobile Drawer)
const mobileMenuOpen = ref(false) const mobileMenuOpen = ref(false)
const handleScroll = () => { const handleScroll = () => {
isScrolled.value = window.scrollY > 20 isScrolled.value = window.scrollY > 20
} }
// Lock body scroll when mobile menu is open // (Lock body scroll)
watch(mobileMenuOpen, (isOpen) => { watch(mobileMenuOpen, (isOpen) => {
if (isOpen) { if (isOpen) {
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
@ -32,21 +31,21 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('scroll', handleScroll) window.removeEventListener('scroll', handleScroll)
document.body.style.overflow = '' // Cleanup document.body.style.overflow = '' // (Cleanup)
}) })
</script> </script>
<template> <template>
<!-- <!--
Header Container คอนเทนเนอรของ Header (Header Container)
- Transitions between transparent and glass effect based on scroll. - เปลยนจากสใส (transparent) เปนเอฟเฟกตกระจก (glass effect) เมอเลอนเมาสลง
--> -->
<header <header
class="fixed top-0 left-0 right-0 z-[100] transition-all" 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 ']" :class="[isScrolled ? 'h-20 glass-nav shadow-lg' : 'h-20 bg-transparent duration-300 border-b border-b-grey-7 ']"
> >
<div class="container mx-auto px-6 md:px-12 h-full flex items-center justify-between"> <div class="container mx-auto px-6 md:px-12 h-full flex items-center justify-between">
<!-- Left Section: Logo --> <!-- านซาย: โลโกแบรนด (Left Section: Logo) -->
<NuxtLink to="/" class="flex items-center gap-3 group"> <NuxtLink to="/" class="flex items-center gap-3 group">
<div class="bg-blue-600 text-white font-black rounded-full px-6 w-10 h-10 flex items-center justify-center group-hover:scale-110 transition-transform"> <div class="bg-blue-600 text-white font-black rounded-full px-6 w-10 h-10 flex items-center justify-center group-hover:scale-110 transition-transform">
<q-icon name="o_school" size="24px" /> <q-icon name="o_school" size="24px" />
@ -67,7 +66,7 @@ onUnmounted(() => {
</div> </div>
</NuxtLink> </NuxtLink>
<!-- Desktop Navigation (Visible by default, hidden on mobile via CSS 'desktop-nav') --> <!-- การนำทางสำหรบเดสกอป (แสดงผลเปนคาเรมต, อนบนมอถอผาน CSS 'desktop-nav') -->
<!-- <nav class="flex desktop-nav items-center gap-8 text-[16px] font-medium"> <!-- <nav class="flex desktop-nav items-center gap-8 text-[16px] font-medium">
<NuxtLink <NuxtLink
to="/browse" to="/browse"
@ -89,10 +88,10 @@ onUnmounted(() => {
<!-- Desktop Action Buttons (Visible by default, hidden on mobile via CSS 'desktop-nav') --> <!-- มปฏการสำหรบเดสกอป (แสดงผลเปนคาเรมต, อนบนมอถอผาน CSS 'desktop-nav') -->
<div class="flex desktop-nav items-center gap-4"> <div class="flex desktop-nav items-center gap-4">
<template v-if="!isAuthenticated"> <template v-if="!isAuthenticated">
<!-- Login Button --> <!-- มเขาสระบบ (Login Button) -->
<NuxtLink <NuxtLink
to="/auth/login" to="/auth/login"
class="px-5 py-4 rounded-full text-slate-700 font-semibold text-sm transition-all hover:-translate-y-0.5" class="px-5 py-4 rounded-full text-slate-700 font-semibold text-sm transition-all hover:-translate-y-0.5"
@ -101,7 +100,7 @@ onUnmounted(() => {
{{ $t('auth.login') }} {{ $t('auth.login') }}
</NuxtLink> </NuxtLink>
<!-- Register Button --> <!-- มสมครสมาช (Register Button) -->
<NuxtLink <NuxtLink
to="/auth/register" 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" 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"
@ -120,7 +119,7 @@ onUnmounted(() => {
</template> </template>
</div> </div>
<!-- Mobile Menu Button (Visible on Mobile) --> <!-- มเปดเมนบนมอถ (แสดงผลเฉพาะบน Mobile) -->
<button <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="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]' : '']" :class="[isScrolled ? 'text-white hover:bg-white/10' : 'text-slate-900 hover:bg-slate-100', mobileMenuOpen ? 'text-slate-900 z-[120]' : '']"
@ -132,7 +131,7 @@ onUnmounted(() => {
</div> </div>
</header> </header>
<!-- Mobile Navigation Drawer (Teleported to body to avoid z-index/clipping issues with Header) --> <!-- นชกเมนานขางสำหรบมอถ (Mobile Navigation Drawer - แยกสวนไปย body เพอไมใหญหา z-index หรอถกบ) -->
<Teleport to="body"> <Teleport to="body">
<div v-if="mobileMenuOpen"> <div v-if="mobileMenuOpen">
<div <div
@ -146,7 +145,7 @@ onUnmounted(() => {
:class="[mobileMenuOpen ? 'translate-x-0' : 'translate-x-full']" :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"> <div class="p-6 pt-8 flex flex-col gap-6 h-full overflow-y-auto relative">
<!-- Close Button --> <!-- มปดเมน (Close Button) -->
<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" 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" @click="mobileMenuOpen = false"
@ -154,7 +153,7 @@ onUnmounted(() => {
<q-icon name="close" size="20px" /> <q-icon name="close" size="20px" />
</button> </button>
<!-- Mobile Links --> <!-- งกสำหรบมอถ (Mobile Links) -->
<nav class="flex flex-col gap-2 mt-8"> <nav class="flex flex-col gap-2 mt-8">
<NuxtLink <NuxtLink
to="/" to="/"
@ -210,14 +209,14 @@ onUnmounted(() => {
</template> </template>
<style scoped> <style scoped>
/* Glassmorphism Effect for Scrolled Header */ /* เอฟเฟกต์ Glassmorphism สำหรับ Header ตอนเลื่อนเมาส์ลง */
.glass-nav { .glass-nav {
background: rgba(15, 23, 42, 0.95); /* Darker background for legibility */ background: rgba(15, 23, 42, 0.95); /* พื้นหลังเข้มขึ้นเพื่อให้อ่านตัวหนังสือชัดเจน */
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
border-bottom: 1px solid rgba(255, 255, 255, 0.05); border-bottom: 1px solid rgba(255, 255, 255, 0.05);
} }
/* Premium Primary Button Styling */ /* สไตล์ปุ่มหลัก (Primary Button) แบบพรีเมียม */
.btn-primary-premium { .btn-primary-premium {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white; color: white;
@ -236,7 +235,7 @@ onUnmounted(() => {
box-shadow: 0 8px 20px -4px rgba(37, 99, 235, 0.5); box-shadow: 0 8px 20px -4px rgba(37, 99, 235, 0.5);
} }
/* Secondary Premium Button Styling */ /* สไตล์ปุ่มดรอง (Secondary Button) แบบพรีเมียม */
.btn-secondary-premium { .btn-secondary-premium {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
border-radius: 0.75rem; border-radius: 0.75rem;
@ -260,11 +259,11 @@ onUnmounted(() => {
} }
/* /*
Force Visibility Logic to bypass potential Tailwind Build issues โลจกบงคบการแสดงผล เพอแกญหาการคอมไฟลของ Tailwind
Ensures Desktop and Mobile parts are strictly separated นยนวาสวน Desktop และ Mobile เลยเอาตแยกจากกนอยางชดเจน
*/ */
.desktop-nav { .desktop-nav {
display: flex; /* Default to visible */ display: flex; /* แสดงผลเป็นค่าเริ่มต้น */
} }
@media (max-width: 767.98px) { @media (max-width: 767.98px) {

View file

@ -31,7 +31,7 @@ const handleNavigate = (path: string) => {
</template> </template>
<style scoped> <style scoped>
/* Optional shadow for better separation */ /* เงาด้านบนแบบบางๆ เพื่อแบ่งส่วนล่างให้ชัดเจนขึ้น (Optional shadow for better separation) */
.shadow-up-1 { .shadow-up-1 {
box-shadow: 0 -1px 3px rgba(0,0,0,0.05); box-shadow: 0 -1px 3px rgba(0,0,0,0.05);
} }

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file PasswordChangeForm.vue * @file PasswordChangeForm.vue
* @description From for changing user password * @description ฟอรมสำหรบเปลยนรหสผานของผใช (From for changing user password)
*/ */
const props = defineProps<{ const props = defineProps<{
@ -130,7 +130,12 @@ const showConfirmPassword = ref(false);
<style scoped> <style scoped>
.card-premium { .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-radius: 1.5rem;
border-width: 1px; border-width: 1px;
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.05); box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.05);

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file ProfileEditForm.vue * @file ProfileEditForm.vue
* @description From for editing user personal information * @description ฟอรมสำหรบแกไขขอมลสวนตวของผใช (Form for editing user personal information)
*/ */
const props = defineProps<{ const props = defineProps<{
@ -93,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"> <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" /> <q-icon name="camera_alt" class="text-white text-xl" />
</div> </div>
<!-- Hidden Input --> <!-- องเลอกไฟลกซอนไว (Hidden Input) -->
<input ref="fileInput" type="file" class="hidden" accept="image/*" @change="handleFileUpload" > <input ref="fileInput" type="file" class="hidden" accept="image/*" @change="handleFileUpload" >
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="font-bold text-slate-900 dark:text-white mb-1">{{ $t('profile.yourAvatar') }}</div> <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"> <div class="flex items-center gap-3">
<template v-if="modelValue.photoURL"> <template v-if="modelValue.photoURL">
<q-btn <q-btn
@ -124,7 +124,7 @@ const onPhoneKeydown = (e: KeyboardEvent) => {
</template> </template>
</div> </div>
<!-- Add Limit Text --> <!-- อความจำกดขนาดไฟล (Add Limit Text) -->
<div class="mt-1 text-xs text-slate-500 dark:text-slate-400"> <div class="mt-1 text-xs text-slate-500 dark:text-slate-400">
{{ $t('profile.uploadLimit') }} {{ $t('profile.uploadLimit') }}
</div> </div>
@ -248,7 +248,12 @@ const onPhoneKeydown = (e: KeyboardEvent) => {
<style scoped> <style scoped>
.card-premium { .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-radius: 1.5rem;
border-width: 1px; border-width: 1px;
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.05); box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.05);

View file

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

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file UserMenu.vue * @file UserMenu.vue
* @description User profile dropdown menu component using Quasar. * @description คอมโพเนนตเมน Dropdown ของโปรไฟลใช ใช Quasar
*/ */
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
@ -12,7 +12,7 @@ const { currentUser, logout } = useAuth()
const { t } = useI18n() const { t } = useI18n()
const $q = useQuasar() const $q = useQuasar()
// Use centralized theme management // (Use centralized theme management)
const { isDark, set } = useThemeMode() const { isDark, set } = useThemeMode()
const isHydrated = ref(false) const isHydrated = ref(false)
@ -21,7 +21,7 @@ onMounted(() => {
isHydrated.value = true isHydrated.value = true
}) })
// User Initials // (User Initials)
const userInitials = computed(() => { const userInitials = computed(() => {
if (!currentUser.value) return '' if (!currentUser.value) return ''
const f = currentUser.value.firstName?.charAt(0).toUpperCase() || 'U' const f = currentUser.value.firstName?.charAt(0).toUpperCase() || 'U'

View file

@ -40,7 +40,7 @@ export const useAuth = () => {
// ฟังก์ชันเข้าสู่ระบบ (Login) // ฟังก์ชันเข้าสู่ระบบ (Login)
const login = async (credentials: { email: string; password: string }) => { const login = async (credentials: { email: string; password: string }) => {
try { try {
// API returns { code: 200, message: "...", data: { token, user, ... } } // API returns { code: 200, message: "...", data: { token, refreshToken } }
const response = await $fetch<any>(`${API_BASE_URL}/auth/login`, { const response = await $fetch<any>(`${API_BASE_URL}/auth/login`, {
method: 'POST', method: 'POST',
body: credentials body: credentials
@ -49,16 +49,35 @@ export const useAuth = () => {
if (response && response.data) { if (response && response.data) {
const data = response.data const data = response.data
// Validation: Ensure user and role exist, then check for Role 'STUDENT' // บันทึก Token ก่อน เพื่อใช้เรียก /user/me
if (!data.user || !data.user.role || data.user.role.code !== 'STUDENT') { token.value = data.token
refreshToken.value = data.refreshToken
// ดึงข้อมูลผู้ใช้จาก /user/me (เพราะ API login ไม่ส่ง user กลับมาแล้ว)
try {
const userData = await $fetch<any>(`${API_BASE_URL}/user/me`, {
headers: {
Authorization: `Bearer ${data.token}`
}
})
// Validation: ตรวจสอบ Role ต้องเป็น STUDENT เท่านั้น
if (!userData || !userData.role || userData.role.code !== 'STUDENT') {
// ถ้า Role ไม่ใช่ STUDENT ให้ล้าง Token ออก
token.value = null
refreshToken.value = null
return { success: false, error: 'Email ไม่ถูกต้อง' } return { success: false, error: 'Email ไม่ถูกต้อง' }
} }
token.value = data.token // เก็บข้อมูล User ลง Cookie
refreshToken.value = data.refreshToken // บันทึก Refresh Token user.value = userData
} catch (profileErr) {
// API ส่งข้อมูล profile มาใน user object // ดึงข้อมูลผู้ใช้ไม่สำเร็จ ให้ล้าง Token ออก
user.value = data.user console.error('Failed to fetch user profile after login:', profileErr)
token.value = null
refreshToken.value = null
return { success: false, error: 'ไม่สามารถดึงข้อมูลผู้ใช้ได้' }
}
return { success: true } return { success: true }
} }

View file

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

View file

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

View file

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

View file

@ -1,17 +1,19 @@
/** /**
* @file useNavItems.ts * @file useNavItems.ts
* @description Single Source of Truth for navigation items used across the app (Sidebar, Mobile Nav, User Menu). * @description (Navigation Items)
* ( , , )
*/ */
export interface NavItem { export interface NavItem {
to: string to: string // ลิงก์ปลายทาง
labelKey: string labelKey: string // คีย์ภาษาสำหรับ i18n
icon: string icon: string // ไอคอนจาก Material Icons
showOn: ('sidebar' | 'mobile' | 'userMenu')[] showOn: ('sidebar' | 'mobile' | 'userMenu')[] // กำหนดให้โชว์ที่ส่วนไหนบ้าง
roles?: string[] roles?: string[] // กำหนดสิทธิ์ผู้ใช้ที่จะเห็น (ถ้ามี)
} }
export const useNavItems = () => { export const useNavItems = () => {
// เมนูทั้งหมดในระบบ กำหนดไว้ที่เดียว
const allNavItems: NavItem[] = [ const allNavItems: NavItem[] = [
{ {
to: '/dashboard', to: '/dashboard',
@ -63,6 +65,7 @@ export const useNavItems = () => {
} }
] ]
// คัดกรองเมนูที่จะเอาไปแสดงแต่ละตำแหน่ง
const sidebarItems = computed(() => allNavItems.filter(item => item.showOn.includes('sidebar'))) const sidebarItems = computed(() => allNavItems.filter(item => item.showOn.includes('sidebar')))
const mobileItems = computed(() => allNavItems.filter(item => item.showOn.includes('mobile'))) const mobileItems = computed(() => allNavItems.filter(item => item.showOn.includes('mobile')))
const userMenuItems = computed(() => allNavItems.filter(item => item.showOn.includes('userMenu'))) const userMenuItems = computed(() => allNavItems.filter(item => item.showOn.includes('userMenu')))

View file

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

View file

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

View file

@ -1,6 +1,6 @@
/** /**
* @file landing.ts * @file landing.ts
* @description Static data for the landing page. * @description (Static data) Landing page
*/ */
export const CATEGORY_CARDS = [ export const CATEGORY_CARDS = [

View file

@ -117,7 +117,11 @@
"foundTotal": "Found Total", "foundTotal": "Found Total",
"items": "items", "items": "items",
"subtitle": "Choose to learn new skills from our curated quality courses", "subtitle": "Choose to learn new skills from our curated quality courses",
"searchBtn": "Search" "searchBtn": "Search",
"allCategory": "All",
"byInstructor": "by",
"students": "students",
"viewDetails": "View Details"
}, },
"myCourses": { "myCourses": {
"title": "My Courses", "title": "My Courses",

View file

@ -117,7 +117,11 @@
"foundTotal": "พบทั้งหมด", "foundTotal": "พบทั้งหมด",
"items": "รายการ", "items": "รายการ",
"subtitle": "เลือกเรียนรู้ทักษะใหม่ๆ จากหลักสูตรคุณภาพที่คัดสรรมาเพื่อคุณ", "subtitle": "เลือกเรียนรู้ทักษะใหม่ๆ จากหลักสูตรคุณภาพที่คัดสรรมาเพื่อคุณ",
"searchBtn": "ค้นหา" "searchBtn": "ค้นหา",
"allCategory": "ทั้งหมด",
"byInstructor": "โดย",
"students": "นักเรียน",
"viewDetails": "ดูรายละเอียด"
}, },
"myCourses": { "myCourses": {
"title": "คอร์สของฉัน", "title": "คอร์สของฉัน",

View file

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

View file

@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file dashboard-index.vue * @file dashboard-index.vue
* @description Layout for the Dashboard Index page, without the sidebar. * @description เลยเอาตสำหรบหนาแรกของ Dashboard (ไมแผงเมนานขางเพอเนนพนทเนอหา)
* Uses Quasar QLayout for responsive structure. * ใชโครงสรางจาก Quasar QLayout เพอใหรองร Responsive
*/ */
// Initialize global theme management // Global
useThemeMode() useThemeMode()
const { currentUser, logout } = useAuth() const { currentUser, logout } = useAuth()
@ -18,7 +18,7 @@ const toggleRightDrawer = () => {
<template> <template>
<q-layout view="hHh lpR fFf" class="bg-slate-50 dark:!bg-[#020617] text-slate-900 dark:!text-slate-50"> <q-layout view="hHh lpR fFf" class="bg-slate-50 dark:!bg-[#020617] text-slate-900 dark:!text-slate-50">
<!-- Header --> <!-- แถบดานบนของโครงสราง (Header) -->
<q-header <q-header
class="bg-white/80 dark:!bg-[#0f172a]/80 backdrop-blur-md text-slate-900 dark:!text-white" class="bg-white/80 dark:!bg-[#0f172a]/80 backdrop-blur-md text-slate-900 dark:!text-white"
> >
@ -29,7 +29,7 @@ const toggleRightDrawer = () => {
/> />
</q-header> </q-header>
<!-- Master Mobile Drawer (The Everything Hub) --> <!-- แถบลนชกมอถอหล (เมนรวมทกอยางเมอปดหนาจอ/กดไอคอนบนสดสวนเล) -->
<q-drawer <q-drawer
v-model="rightDrawerOpen" v-model="rightDrawerOpen"
side="right" side="right"
@ -39,7 +39,7 @@ const toggleRightDrawer = () => {
:width="300" :width="300"
> >
<div class="flex flex-col h-full bg-white dark:bg-[#0f172a]"> <div class="flex flex-col h-full bg-white dark:bg-[#0f172a]">
<!-- 1. Account Section (Premium Look) --> <!-- 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="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 justify-between mb-8">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@ -62,10 +62,10 @@ const toggleRightDrawer = () => {
</div> </div>
</div> </div>
<!-- 2. Integrated Content Hub --> <!-- 2. นยรวมเมนและเนอหา (Integrated Content Hub) -->
<div class="flex-grow overflow-y-auto pt-4"> <div class="flex-grow overflow-y-auto pt-4">
<q-list padding class="text-slate-600 dark:text-slate-300"> <q-list padding class="text-slate-600 dark:text-slate-300">
<!-- Navigation --> <!-- การนำทาง (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-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 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">
@ -85,10 +85,10 @@ const toggleRightDrawer = () => {
<q-separator class="my-4 mx-6 opacity-50" /> <q-separator class="my-4 mx-6 opacity-50" />
<!-- Tools & Settings --> <!-- เครองมอทวไปและการตงคาระบบ -->
<q-item-label header class="text-[11px] font-black tracking-[0.2em] text-slate-400 uppercase px-6 pb-2">เครองมอและการตงค</q-item-label> <q-item-label header class="text-[11px] font-black tracking-[0.2em] text-slate-400 uppercase px-6 pb-2">เครองมอและการตงค</q-item-label>
<!-- Language Selection --> <!-- มสลบภาษา -->
<q-item class="px-6 py-2"> <q-item class="px-6 py-2">
<q-item-section avatar><q-icon name="language" size="22px" /></q-item-section> <q-item-section avatar><q-icon name="language" size="22px" /></q-item-section>
<q-item-section> <q-item-section>
@ -99,7 +99,7 @@ const toggleRightDrawer = () => {
</q-item-section> </q-item-section>
</q-item> </q-item>
<!-- Dark Mode Toggle --> <!-- เปดปดโหมดม (Dark Mode Toggle) -->
<q-item class="px-6 py-2"> <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 avatar><q-icon :name="isDark ? 'dark_mode' : 'light_mode'" size="22px" /></q-item-section>
<q-item-section> <q-item-section>
@ -121,7 +121,7 @@ const toggleRightDrawer = () => {
</q-list> </q-list>
</div> </div>
<!-- 3. Bottom Actions --> <!-- 3. วนลางส เช อกเอาต หรอระบเวอรนระบบ -->
<div class="p-6 mt-auto border-t border-slate-100 dark:border-slate-800"> <div class="p-6 mt-auto border-t border-slate-100 dark:border-slate-800">
<q-btn <q-btn
unelevated unelevated
@ -138,18 +138,16 @@ const toggleRightDrawer = () => {
</div> </div>
</q-drawer> </q-drawer>
<!-- Sidebar Removed for this layout --> <!-- หมายเหต: สำหรบหนาน จะไมไดการโชว Sidebar เปนลนชกซาย -->
<!-- Main Content --> <!-- นทแสดงเนอหาหล -->
<q-page-container> <q-page-container>
<q-page class="relative"> <q-page class="relative">
<slot /> <slot />
</q-page> </q-page>
</q-page-container> </q-page-container>
<!-- Mobile Bottom Nav - Optional, keeping it consistent with default but maybe not needed if full width? <!-- เมนมกดลางหนาจอบนมอถ (สำหรบชวยใหเขาถงหนาหลกไดไวข) -->
If we remove sidebar, we might still want mobile nav if it's main navigation.
Let's keep it for now as it doesn't hurt. -->
<q-footer <q-footer
v-if="$q.screen.lt.md" v-if="$q.screen.lt.md"
class="!bg-white dark:!bg-[#1e293b] text-primary" class="!bg-white dark:!bg-[#1e293b] text-primary"

View file

@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file default.vue * @file default.vue
* @description Master layout for the EduLearn platform. * @description เลยเอาตหล (Master Layout) สำหรบผใชงานทเขาสระบบแล
* ประกอบดวยแถบเมนานบน (Header), แถบเมนานขาง (Sidebar) และพนทเนอหา
*/ */
useThemeMode() useThemeMode()
@ -13,7 +14,7 @@ const toggleLeftDrawer = () => {
} }
const route = useRoute() const route = useRoute()
// Show sidebar for these routes // path (Sidebar)
const isDashboardRoute = computed(() => { const isDashboardRoute = computed(() => {
const routes = ['/dashboard', '/browse', '/classroom', '/course'] const routes = ['/dashboard', '/browse', '/classroom', '/course']
return routes.some(r => route.path.startsWith(r)) return routes.some(r => route.path.startsWith(r))
@ -23,14 +24,14 @@ const isDashboardRoute = computed(() => {
<template> <template>
<q-layout view="lHh Lpr lFf" class="bg-[#F8FAFC] dark:!bg-[#020617] text-slate-900 dark:!text-slate-50"> <q-layout view="lHh Lpr lFf" class="bg-[#F8FAFC] dark:!bg-[#020617] text-slate-900 dark:!text-slate-50">
<!-- Header --> <!-- วนห (Header) -->
<q-header <q-header
class="bg-transparent text-slate-900 dark:!text-white border-none shadow-none" class="bg-transparent text-slate-900 dark:!text-white border-none shadow-none"
> >
<AppHeader @toggleSidebar="toggleLeftDrawer" /> <AppHeader @toggleSidebar="toggleLeftDrawer" />
</q-header> </q-header>
<!-- Navigation Sidebar --> <!-- แถบเมนานขาง (Navigation Sidebar) -->
<q-drawer <q-drawer
v-model="leftDrawerOpen" v-model="leftDrawerOpen"
show-if-above show-if-above
@ -42,7 +43,7 @@ const isDashboardRoute = computed(() => {
<AppSidebar /> <AppSidebar />
</q-drawer> </q-drawer>
<!-- Main Content Area --> <!-- นทแสดงเนอหาหล (Main Content Area) -->
<q-page-container> <q-page-container>
<q-page class="px-3 py-6 md:p-8"> <q-page class="px-3 py-6 md:p-8">
<div class="max-w-[1600px] mx-auto"> <div class="max-w-[1600px] mx-auto">

View file

@ -23,20 +23,20 @@ onMounted(() => {
<q-layout view="lHh LpR lFf" class="bg-white text-slate-900"> <q-layout view="lHh LpR lFf" class="bg-white text-slate-900">
<!-- Header (Transparent & Overlay) --> <!-- วนหวของเพจ (แบบโปรงใส และซอนทบใหโชวเนอหาพนหลงได) -->
<q-header class="bg-transparent" style="height: auto;"> <q-header class="bg-transparent" style="height: auto;">
<LandingHeader /> <LandingHeader />
</q-header> </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-container style="padding-top: 0 !important;">
<q-page> <q-page>
<slot /> <slot />
</q-page> </q-page>
</q-page-container> </q-page-container>
<!-- Footer --> <!-- วนทายของเพจ -->
<LandingFooter /> <LandingFooter />

File diff suppressed because it is too large Load diff

View file

@ -19,9 +19,14 @@
}, },
"devDependencies": { "devDependencies": {
"@nuxt/eslint-config": "^1.12.1", "@nuxt/eslint-config": "^1.12.1",
"@nuxt/test-utils": "^4.0.0",
"@nuxtjs/i18n": "^10.2.1", "@nuxtjs/i18n": "^10.2.1",
"@playwright/test": "^1.58.2",
"@types/node": "^22.19.8", "@types/node": "^22.19.8",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.39.2", "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 rememberMe = ref(false)
const showPassword = ref(false) const showPassword = ref(false)
// Form data model //
const loginForm = reactive({ const loginForm = reactive({
email: '', email: '',
password: '' password: ''
@ -31,7 +31,7 @@ const loginForm = reactive({
type LoginField = keyof typeof loginForm type LoginField = keyof typeof loginForm
// Validation rules definition // (Validation Rules)
// (Validation Rules) // (Validation Rules)
const loginRules = { const loginRules = {
email: { 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 ไม่ถูกต้อง') { if (result.error === 'Email ไม่ถูกต้อง') {
errors.value.email = result.error // Role mismatch case errors.value.email = result.error // Role
} else { } else {
// Generic login failure (401, 404, etc.) // ( , )
const msg = 'กรุณาเช็ค Email หรือ รหัสผ่านใหม่อีกครั้ง' const msg = 'กรุณาเช็ค Email หรือ รหัสผ่านใหม่อีกครั้ง'
errors.value.email = msg errors.value.email = msg
errors.value.password = msg errors.value.password = msg
@ -147,7 +147,7 @@ onMounted(() => {
========================================== --> ========================================== -->
<div class="w-full max-w-[460px] relative z-10 slide-up"> <div class="w-full max-w-[460px] relative z-10 slide-up">
<!-- Header / Logo --> <!-- วนหวโปรไฟล / โลโก (Header / Logo) -->
<div class="text-center mb-8"> <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"> <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> <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"> <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"> <form @submit.prevent="handleLogin" class="flex flex-col gap-5">
<!-- Email Input --> <!-- องกรอกอเมล (Email Input) -->
<div> <div>
<label class="block text-sm font-semibold text-slate-700 mb-2 ml-1">เมล</label> <label class="block text-sm font-semibold text-slate-700 mb-2 ml-1">เมล</label>
<div class="relative group"> <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> <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> </div>
<!-- Password Input --> <!-- องกรอกรหสผาน (Password Input) -->
<div> <div>
<label class="block text-sm font-semibold text-slate-700 mb-2 ml-1">รหสผาน</label> <label class="block text-sm font-semibold text-slate-700 mb-2 ml-1">รหสผาน</label>
<div class="relative group"> <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> <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> </div>
<!-- Options --> <!-- วเลอกเพมเต (จดจำฉ, มรหสผาน) (Options) -->
<div class="flex items-center justify-between mt-1"> <div class="flex items-center justify-between mt-1">
<label class="flex items-center gap-2.5 cursor-pointer group select-none"> <label class="flex items-center gap-2.5 cursor-pointer group select-none">
<div class="relative flex items-center"> <div class="relative flex items-center">
@ -227,7 +227,7 @@ onMounted(() => {
</NuxtLink> </NuxtLink>
</div> </div>
<!-- Submit Button --> <!-- มยนยนเขาสระบบ (Submit Button) -->
<button <button
type="submit" type="submit"
:disabled="isLoading" :disabled="isLoading"
@ -237,7 +237,7 @@ onMounted(() => {
<div v-else class="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div> <div v-else class="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
</button> </button>
<!-- Test Credentials Box --> <!-- กลองแนะนำบญชสำหรบทดสอบ (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="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="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="flex flex-col items-center gap-1">
@ -253,7 +253,7 @@ onMounted(() => {
</form> </form>
<!-- Register Link --> <!-- งกสำหรบสมครสมาชกใหม (Register Link) -->
<div class="text-center mt-8"> <div class="text-center mt-8">
<p class="text-slate-600 text-sm"> <p class="text-slate-600 text-sm">
งไมญชสมาช? งไมญชสมาช?
@ -265,7 +265,7 @@ onMounted(() => {
</div> </div>
<!-- Back Link --> <!-- งกอนกล (Back Link) -->
<div class="mt-8 text-center text-slate-500"> <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"> <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> กลบไปหนาแรก <span class="group-hover:-translate-x-1 transition-transform"></span> กลบไปหนาแรก
@ -276,7 +276,7 @@ onMounted(() => {
</template> </template>
<style scoped> <style scoped>
/* Animations */ /* เอฟเฟกต์การเคลื่อนไหว (Animations) */
@keyframes pulse-slow { @keyframes pulse-slow {
0%, 100% { opacity: 0.3; transform: scale(1); } 0%, 100% { opacity: 0.3; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.15); } 50% { opacity: 0.5; transform: scale(1.15); }

View file

@ -27,7 +27,7 @@ const sortBy = ref('ยอดนิยม');
const sortOptions = ['ยอดนิยม', 'ล่าสุด', 'ราคาต่ำ-สูงสุด', 'ราคาสูง-ต่ำสุด']; const sortOptions = ['ยอดนิยม', 'ล่าสุด', 'ราคาต่ำ-สูงสุด', 'ราคาสูง-ต่ำสุด'];
const categories = ref<any[]>([]); const categories = ref<any[]>([]);
const courses = ref<any[]>([]); const allCourses = ref<any[]>([]); // client-side
const selectedCourse = ref<any>(null); const selectedCourse = ref<any>(null);
const isLoading = ref(false); const isLoading = ref(false);
@ -76,20 +76,17 @@ const loadCategories = async () => {
if (res.success) categories.value = res.data || []; if (res.success) categories.value = res.data || [];
}; };
const loadCourses = async (page = 1) => { const loadCourses = async () => {
isLoading.value = true; isLoading.value = true;
const categoryId = activeCategory.value === 'all' ? undefined : activeCategory.value as number;
// (limit client-side filter)
const res = await fetchCourses({ const res = await fetchCourses({
category_id: categoryId, limit: 500,
search: searchQuery.value,
page: page,
limit: itemsPerPage,
forceRefresh: true, forceRefresh: true,
}); });
if (res.success) { if (res.success) {
courses.value = (res.data || []).map(c => { allCourses.value = (res.data || []).map(c => {
const cat = categories.value.find(cat => cat.id === c.category_id); const cat = categories.value.find(cat => cat.id === c.category_id);
return { return {
...c, ...c,
@ -100,12 +97,33 @@ const loadCourses = async (page = 1) => {
reviews_count: c.total_lessons ? c.total_lessons * 123 : Math.floor(Math.random() * 2000) + 100 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;
} }
isLoading.value = false; isLoading.value = false;
}; };
// Computed: real-time searchQuery + activeCategory
const filteredCourses = computed(() => {
let result = allCourses.value;
//
if (activeCategory.value !== 'all') {
result = result.filter(c => c.category_id === activeCategory.value);
}
// ( th en)
if (searchQuery.value.trim()) {
const query = searchQuery.value.trim().toLowerCase();
result = result.filter(c => {
const titleTh = (c.title?.th || '').toLowerCase();
const titleEn = (c.title?.en || '').toLowerCase();
const titleStr = (typeof c.title === 'string' ? c.title : '').toLowerCase();
return titleTh.includes(query) || titleEn.includes(query) || titleStr.includes(query);
});
}
return result;
});
const selectCourse = async (id: number) => { const selectCourse = async (id: number) => {
isLoadingDetail.value = true; isLoadingDetail.value = true;
selectedCourse.value = null; selectedCourse.value = null;
@ -137,10 +155,10 @@ watch(
activeCategory, activeCategory,
() => { () => {
currentPage.value = 1; currentPage.value = 1;
loadCourses(1);
} }
); );
onMounted(async () => { onMounted(async () => {
await loadCategories(); await loadCategories();
@ -150,7 +168,7 @@ onMounted(async () => {
activeCategory.value = Number(route.query.category_id); activeCategory.value = Number(route.query.category_id);
} }
await loadCourses(1); await loadCourses();
if (route.query.course_id) { if (route.query.course_id) {
selectCourse(Number(route.query.course_id)); selectCourse(Number(route.query.course_id));
@ -162,19 +180,19 @@ onMounted(async () => {
<div class="bg-[#F8F9FA] dark:bg-[#020617] min-h-screen p-4 md:p-8 transition-colors duration-300"> <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"> <div class="max-w-[1240px] mx-auto">
<!-- วนของการคนหาคอร (Catalog View) --> <!-- วนของการคนหาคอร (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"> <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 transition-colors">
<!-- วนหวและการคนหา --> <!-- วนหวและการคนหา -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8"> <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> <h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-[#f8fafc] tracking-tight">{{ $t('discovery.title') }}</h2>
<div class="flex flex-wrap sm:flex-nowrap items-center gap-3 w-full md:w-auto"> <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"> <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]" /> <q-icon name="search" size="18px" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" />
<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="ค้นหาคอร์ส..." /> <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('discovery.searchPlaceholder')" />
</div> </div>
<div class="flex items-center gap-2 shrink-0"> <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 = 'grid'" :class="viewMode === 'grid' ? 'bg-[#E9EFFD] dark:!bg-blue-900/40 text-[#3B6BE8] dark:!text-blue-400 border-[#3B6BE8] dark:!border-blue-400' : 'bg-white border-slate-200 dark:!bg-slate-800 dark:!border-slate-700 text-slate-400 dark:!text-slate-300 hover:bg-slate-50 dark:hover:!bg-slate-700'" 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> <button @click="viewMode = 'list'" :class="viewMode === 'list' ? 'bg-[#E9EFFD] dark:!bg-blue-900/40 text-[#3B6BE8] dark:!text-blue-400 border-[#3B6BE8] dark:!border-blue-400' : 'bg-white border-slate-200 dark:!bg-slate-800 dark:!border-slate-700 text-slate-400 dark:!text-slate-300 hover:bg-slate-50 dark:hover:!bg-slate-700'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="view_list" size="20px" /></button>
</div> </div>
</div> </div>
</div> </div>
@ -185,17 +203,17 @@ onMounted(async () => {
<div class="flex flex-wrap items-center gap-3 w-full xl:w-auto"> <div class="flex flex-wrap items-center gap-3 w-full xl:w-auto">
<button <button
@click="activeCategory = 'all'" @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="activeCategory === 'all' ? 'bg-[#E9EFFD] dark:!bg-blue-900/40 text-[#3B6BE8] dark:!text-blue-400 border-transparent font-bold' : 'bg-white dark:!bg-slate-800 border-slate-200 dark:!border-slate-700 text-slate-800 dark:!text-slate-200 hover:border-slate-300 dark:hover:!border-slate-600 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"> 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'"/> งหมด <q-icon name="check_circle_outline" size="18px" :class="activeCategory === 'all' ? 'text-[#3B6BE8] dark:!text-blue-400' : 'text-slate-400 dark:!text-slate-400'"/> {{ $t('discovery.allCategory') }}
</button> </button>
<button <button
v-for="cat in categories" :key="cat.id" v-for="cat in categories" :key="cat.id"
@click="activeCategory = 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="activeCategory === cat.id ? 'bg-[#E9EFFD] dark:!bg-blue-900/40 text-[#3B6BE8] dark:!text-blue-400 border-transparent font-bold' : 'bg-white dark:!bg-slate-800 border-slate-200 dark:!border-slate-700 text-slate-800 dark:!text-slate-200 hover:border-slate-300 dark:hover:!border-slate-600 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"> 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="getCategoryIcon(cat.name)" size="18px" :class="activeCategory === cat.id ? 'text-[#3B6BE8]' : 'text-slate-600 dark:text-slate-400'"/> <q-icon :name="getCategoryIcon(cat.name)" size="18px" :class="activeCategory === cat.id ? 'text-[#3B6BE8] dark:!text-blue-400' : 'text-slate-600 dark:!text-slate-400'"/>
{{ getLocalizedText(cat.name) }} {{ getLocalizedText(cat.name) }}
</button> </button>
</div> </div>
@ -208,10 +226,10 @@ onMounted(async () => {
<q-spinner size="3rem" color="primary" /> <q-spinner size="3rem" color="primary" />
</div> </div>
<div v-else-if="courses.length > 0"> <div v-else-if="filteredCourses.length > 0">
<!-- GRID VIEW --> <!-- 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-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)"> <div v-for="course in filteredCourses" :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 --> <!-- Thumbnail -->
<div class="relative w-full aspect-[16/10] bg-slate-100 dark:bg-slate-800 overflow-hidden"> <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" /> <img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
@ -222,7 +240,7 @@ onMounted(async () => {
<!-- Body --> <!-- Body -->
<div class="p-5 flex flex-col flex-1"> <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> <h3 class="font-bold text-slate-900 dark:text-[#f8fafc] text-[15px] leading-snug line-clamp-2 mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">{{ getLocalizedText(course.title) }}</h3>
@ -230,8 +248,7 @@ onMounted(async () => {
<div class="font-[900] text-[18px]" :class="course.is_free ? 'text-green-500' : 'text-[#2563EB] dark:text-blue-400'"> <div class="font-[900] text-[18px]" :class="course.is_free ? 'text-green-500' : 'text-[#2563EB] dark:text-blue-400'">
{{ course.formatted_price }} {{ course.formatted_price }}
</div> </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-300 flex items-center justify-center hover:bg-blue-50 hover:text-blue-600 dark:hover:!bg-slate-700 dark:hover:!text-blue-400 border border-slate-100 dark:!border-slate-700 transition-colors shadow-sm outline-none">
<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" /> <q-icon name="visibility" size="18px" />
</button> </button>
</div> </div>
@ -241,7 +258,7 @@ onMounted(async () => {
<!-- LIST VIEW --> <!-- LIST VIEW -->
<div v-else class="flex flex-col gap-5"> <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 v-for="course in filteredCourses" :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"> <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" /> <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;"> <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;">
@ -250,15 +267,15 @@ onMounted(async () => {
</div> </div>
<div class="flex flex-col flex-1 py-1"> <div class="flex flex-col flex-1 py-1">
<div class="flex-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> <h3 class="font-bold text-slate-900 dark:text-[#f8fafc] text-[16px] md:text-[18px] leading-snug line-clamp-2 md:line-clamp-1 mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">{{ getLocalizedText(course.title) }}</h3>
</div> </div>
<div class="mt-4 sm:mt-auto flex items-center justify-between"> <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'"> <div class="font-[900] text-[20px]" :class="course.is_free ? 'text-green-500' : 'text-[#2563EB] dark:text-blue-400'">
{{ course.formatted_price }} {{ course.formatted_price }}
</div> </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"> <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 hover:text-blue-600 dark:hover:!bg-slate-700 dark:hover:!text-blue-400 border border-slate-100 dark:!border-slate-700 transition-colors">
<q-icon name="visibility" size="16px" /> รายละเอยด <q-icon name="visibility" size="16px" /> {{ $t('discovery.viewDetails') }}
</button> </button>
</div> </div>
</div> </div>
@ -272,9 +289,9 @@ onMounted(async () => {
</div> </div>
<!-- Empty State --> <!-- 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"> <div v-else class="flex flex-col items-center justify-center py-20 bg-white dark:!bg-slate-900/50 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" /> <q-icon name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" />
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ $t("discovery.emptyTitle") }}</h3> <h3 class="text-xl font-bold text-slate-900 dark:!text-white mb-2">{{ $t("discovery.emptyTitle") }}</h3>
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">{{ $t("discovery.emptyDesc") }}</p> <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';"> <button class="mt-6 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''; activeCategory = 'all';">
{{ $t("discovery.showAll") }} {{ $t("discovery.showAll") }}

View file

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file index.vue * @file index.vue
* @description Page displaying all available courses in a public catalog format. * @description หนาแสดงคอรสเรยนทงหมดในรปแบบแคตตาลอกสาธารณะ
* Matches the requested modern layout. * ไซนปรบใหนสมยเพอดงดดผใชงานใหม
*/ */
definePageMeta({ definePageMeta({
@ -13,6 +13,7 @@ useHead({
title: 'คอร์สทั้งหมด - E-Learning System' title: 'คอร์สทั้งหมด - E-Learning System'
}) })
const { t } = useI18n()
const searchQuery = ref('') const searchQuery = ref('')
const { fetchCourses } = useCourse() const { fetchCourses } = useCourse()
const { fetchCategories, categories } = useCategory() const { fetchCategories, categories } = useCategory()
@ -54,7 +55,7 @@ await useAsyncData('categories-list', () => fetchCategories())
const { data: coursesResponse, pending: isLoading, error, refresh } = await useAsyncData( const { data: coursesResponse, pending: isLoading, error, refresh } = await useAsyncData(
'browse-courses-list', 'browse-courses-list',
() => { () => {
const params: any = {} const params: any = { limit: 500 }
if (selectedCategory.value !== 'all') { if (selectedCategory.value !== 'all') {
const category = categories.value.find(c => c.slug === selectedCategory.value) const category = categories.value.find(c => c.slug === selectedCategory.value)
if (category) { if (category) {
@ -126,16 +127,16 @@ const viewMode = ref<'grid' | 'list'>('grid')
<template> <template>
<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="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"> <div class="max-w-[1240px] mx-auto">
<!-- Catalog View --> <!-- มมองแคตตาลอกแสดงคอร (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"> <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"> <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> <h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">{{ $t('discovery.title') }}</h2>
<div class="flex flex-wrap sm:flex-nowrap items-center gap-3 w-full md:w-auto"> <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"> <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]" /> <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="ค้นหาคอร์ส..." /> <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('discovery.searchPlaceholder')" />
</div> </div>
<div class="flex items-center gap-2 shrink-0"> <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 = '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>
@ -144,14 +145,14 @@ const viewMode = ref<'grid' | 'list'>('grid')
</div> </div>
</div> </div>
<!-- Filters Category --> <!-- วกรองหมวดหม (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-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"> <div class="flex flex-wrap items-center gap-3 w-full xl:w-auto">
<button <button
@click="selectCategory('all')" @click="selectCategory('all')"
:class="selectedCategory === 'all' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'" :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"> 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'"/> งหมด <q-icon name="check_circle_outline" size="18px" :class="selectedCategory === 'all' ? 'text-[#3B6BE8]' : 'text-slate-400'"/> {{ $t('discovery.allCategory') }}
</button> </button>
<button <button
@ -165,16 +166,16 @@ const viewMode = ref<'grid' | 'list'>('grid')
</div> </div>
</div> </div>
<!-- Loader --> <!-- วแสดงการโหลด (Loader) -->
<div v-if="isLoading" class="flex justify-center py-24"> <div v-if="isLoading" class="flex justify-center py-24">
<q-spinner size="3rem" color="primary" /> <q-spinner size="3rem" color="primary" />
</div> </div>
<div v-else-if="filteredCourses.length > 0"> <div v-else-if="filteredCourses.length > 0">
<!-- GRID VIEW --> <!-- มมองแบบกร (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-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"> <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 --> <!-- ปหนาปก (Thumbnail) -->
<div class="relative w-full aspect-[16/10] bg-slate-100 dark:bg-slate-800 overflow-hidden"> <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" /> <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;"> <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;">
@ -182,25 +183,25 @@ const viewMode = ref<'grid' | 'list'>('grid')
</div> </div>
</div> </div>
<!-- Body --> <!-- เนอหาคอร (Body) -->
<div class="p-5 flex flex-col flex-1"> <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> <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"> <div class="flex items-center gap-2 mb-4">
<span class="text-[12px] text-slate-500 font-medium">โดย {{ course.instructor_name }}</span> <span class="text-[12px] text-slate-500 font-medium">{{ $t('discovery.byInstructor') }} {{ course.instructor_name }}</span>
</div> </div>
<div class="flex items-center gap-1.5 mb-5"> <div class="flex items-center gap-1.5 mb-5">
<q-icon name="star" class="text-amber-400" size="16px" /> <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-[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> <span class="text-[12px] text-slate-400">({{ course.reviews_count.toLocaleString() }} {{ $t('discovery.students') }})</span>
</div> </div>
<div class="mt-auto flex items-center justify-between"> <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'"> <div class="font-[900] text-[18px]" :class="course.is_free ? 'text-green-500' : 'text-[#2563EB] dark:text-blue-400'">
{{ course.formatted_price }} {{ course.formatted_price }}
</div> </div>
<!-- Eye icon circle button --> <!-- มกดรปตาเพอดรายละเอยด (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"> <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" /> <q-icon name="visibility" size="18px" />
</button> </button>
@ -209,7 +210,7 @@ const viewMode = ref<'grid' | 'list'>('grid')
</NuxtLink> </NuxtLink>
</div> </div>
<!-- LIST VIEW --> <!-- มมองแบบรายการ (LIST VIEW) -->
<div v-else class="flex flex-col gap-5"> <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"> <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"> <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">
@ -222,12 +223,12 @@ const viewMode = ref<'grid' | 'list'>('grid')
<div class="flex-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> <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"> <div class="flex items-center gap-2 mb-3">
<span class="text-[13px] text-slate-500 font-medium">โดย {{ course.instructor_name }}</span> <span class="text-[13px] text-slate-500 font-medium">{{ $t('discovery.byInstructor') }} {{ course.instructor_name }}</span>
</div> </div>
<div class="flex items-center gap-1.5 mb-2"> <div class="flex items-center gap-1.5 mb-2">
<q-icon name="star" class="text-amber-400" size="16px" /> <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-[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> <span class="text-[12px] text-slate-400">({{ course.reviews_count.toLocaleString() }} {{ $t('discovery.students') }})</span>
</div> </div>
</div> </div>
<div class="mt-4 sm:mt-auto flex items-center justify-between"> <div class="mt-4 sm:mt-auto flex items-center justify-between">
@ -235,7 +236,7 @@ const viewMode = ref<'grid' | 'list'>('grid')
{{ course.formatted_price }} {{ course.formatted_price }}
</div> </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"> <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" /> รายละเอยด <q-icon name="visibility" size="16px" /> {{ $t('discovery.viewDetails') }}
</button> </button>
</div> </div>
</div> </div>
@ -243,13 +244,13 @@ const viewMode = ref<'grid' | 'list'>('grid')
</div> </div>
</div> </div>
<!-- Empty State --> <!-- กรณไมพบขอมลคอร (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"> <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" /> <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> <h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ $t('discovery.emptyTitle') }}</h3>
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">ลองใชคำคนหาอ หรอเลอกหมวดหมนเพอดคอรสทเรามใหบรการ</p> <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 = ''; selectedCategory = 'all';"> <button class="mt-6 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''; selectedCategory = 'all';">
แสดงคอรสทงหมด {{ $t('discovery.showAll') }}
</button> </button>
</div> </div>
</div> </div>
@ -258,7 +259,7 @@ const viewMode = ref<'grid' | 'list'>('grid')
</template> </template>
<style scoped> <style scoped>
/* Disable default scrollbar for filter container */ /* ปิดการแสดงแถบเลื่อนบนคอนเทนเนอร์ของตัวกรอง (Disable default scrollbar for filter container) */
.no-scrollbar::-webkit-scrollbar { .no-scrollbar::-webkit-scrollbar {
display: none; display: none;
} }

View file

@ -22,7 +22,7 @@ const { user } = useAuth()
const { fetchCourseLearningInfo, fetchLessonContent, saveVideoProgress, checkLessonAccess, fetchVideoProgress, fetchCourseAnnouncements, markLessonComplete, getLocalizedText } = useCourse() const { fetchCourseLearningInfo, fetchLessonContent, saveVideoProgress, checkLessonAccess, fetchVideoProgress, fetchCourseAnnouncements, markLessonComplete, getLocalizedText } = useCourse()
const $q = useQuasar() const $q = useQuasar()
// State management // (State management)
const sidebarOpen = ref(false) const sidebarOpen = ref(false)
const courseId = computed(() => Number(route.query.course_id)) const courseId = computed(() => Number(route.query.course_id))
@ -31,11 +31,11 @@ const courseId = computed(() => Number(route.query.course_id))
// ========================================== // ==========================================
// courseData: () // courseData: ()
const courseData = ref<any>(null) const courseData = ref<any>(null)
const announcements = ref<any[]>([]) // Announcements state const announcements = ref<any[]>([]) // (Announcements state)
const showAnnouncementsModal = ref(false) // Modal state const showAnnouncementsModal = ref(false) // (Modal state)
const hasUnreadAnnouncements = ref(false) // Unread state tracking const hasUnreadAnnouncements = ref(false) // (Unread state tracking)
// Helper for persistent read status // (Helper for persistent read status)
const getAnnouncementStorageKey = () => { const getAnnouncementStorageKey = () => {
if (!user.value?.id || !courseId.value) return '' if (!user.value?.id || !courseId.value) return ''
return `read_announcements:${user.value.id}:${courseId.value}` return `read_announcements:${user.value.id}:${courseId.value}`
@ -61,17 +61,17 @@ const checkUnreadAnnouncements = () => {
const lastReadDate = new Date(lastRead).getTime() const lastReadDate = new Date(lastRead).getTime()
const hasNew = announcements.value.some(a => { const hasNew = announcements.value.some(a => {
const annDate = new Date(a.created_at || Date.now()).getTime() 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 return annDate > lastReadDate
}) })
hasUnreadAnnouncements.value = hasNew hasUnreadAnnouncements.value = hasNew
} }
// Handler for opening announcements // (Handler for opening announcements)
const handleOpenAnnouncements = () => { const handleOpenAnnouncements = () => {
showAnnouncementsModal.value = true showAnnouncementsModal.value = true
hasUnreadAnnouncements.value = false // Clear unread badge on click hasUnreadAnnouncements.value = false // (Clear unread badge on click)
const key = getAnnouncementStorageKey() const key = getAnnouncementStorageKey()
if (key) { if (key) {
@ -98,7 +98,7 @@ const toggleSidebar = () => {
sidebarOpen.value = !sidebarOpen.value sidebarOpen.value = !sidebarOpen.value
} }
// Logic Quiz Attempt Management // (Logic Quiz Attempt Management)
const quizStatus = computed(() => { const quizStatus = computed(() => {
if (!currentLesson.value || currentLesson.value.type !== 'QUIZ' || !currentLesson.value.quiz) return null if (!currentLesson.value || currentLesson.value.type !== 'QUIZ' || !currentLesson.value.quiz) return null
@ -106,7 +106,7 @@ const quizStatus = computed(() => {
const latestAttempt = quiz.latest_attempt const latestAttempt = quiz.latest_attempt
const allowMultiple = quiz.allow_multiple_attempts const allowMultiple = quiz.allow_multiple_attempts
// If never attempted // (If never attempted)
if (!latestAttempt) { if (!latestAttempt) {
return { return {
canStart: true, canStart: true,
@ -116,7 +116,7 @@ const quizStatus = computed(() => {
} }
} }
// If multiple attempts allowed // (If multiple attempts allowed)
if (allowMultiple) { if (allowMultiple) {
return { return {
canStart: true, canStart: true,
@ -128,8 +128,8 @@ const quizStatus = computed(() => {
} }
} }
// allowMultiple is false (Single attempt only) // (allowMultiple is false (Single attempt only))
// Lock the quiz regardless of pass/fail once attempted // (Lock the quiz regardless of pass/fail once attempted)
return { return {
canStart: false, canStart: false,
label: latestAttempt.is_passed ? t('quiz.passedStatus') : t('quiz.failedStatus'), label: latestAttempt.is_passed ? t('quiz.passedStatus') : t('quiz.failedStatus'),
@ -145,7 +145,7 @@ const handleStartQuiz = () => {
const quiz = currentLesson.value.quiz 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) { if (!quiz.allow_multiple_attempts && !quiz.latest_attempt) {
$q.dialog({ $q.dialog({
title: `<div class="text-slate-900 dark:text-white font-black text-xl">${t('quiz.warningTitle')}</div>`, title: `<div class="text-slate-900 dark:text-white font-black text-xl">${t('quiz.warningTitle')}</div>`,
@ -193,18 +193,18 @@ const resetAndNavigate = (path: string) => {
} }
} }
// 2. Clear all localStorage // 2. localStorage (Clear all localStorage)
localStorage.clear() localStorage.clear()
// 3. Restore ONLY whitelisted keys // 3. (Restore ONLY whitelisted keys)
Object.keys(whitelist).forEach(key => { Object.keys(whitelist).forEach(key => {
localStorage.setItem(key, whitelist[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 window.location.href = path
} else { } else {
// SSR Fallback // SSR (SSR Fallback)
router.push(path) router.push(path)
} }
} }
@ -213,13 +213,13 @@ const resetAndNavigate = (path: string) => {
const handleLessonSelect = (lessonId: number) => { const handleLessonSelect = (lessonId: number) => {
if (currentLesson.value?.id === lessonId) return 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() } }) router.push({ query: { ...route.query, lesson_id: lessonId.toString() } })
// 2. Load content without refresh // 2. (Load content without refresh)
loadLesson(lessonId) loadLesson(lessonId)
// Close sidebar on mobile // (Close sidebar on mobile)
if (sidebarOpen.value) { if (sidebarOpen.value) {
sidebarOpen.value = false sidebarOpen.value = false
} }
@ -245,7 +245,7 @@ const loadCourseData = async () => {
if (res.success) { if (res.success) {
courseData.value = res.data 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 const urlLessonId = route.query.lesson_id ? Number(route.query.lesson_id) : null
if (urlLessonId) { if (urlLessonId) {
@ -258,7 +258,7 @@ const loadCourseData = async () => {
} }
} }
// Fetch Course Announcements // (Fetch Course Announcements)
const annRes = await fetchCourseAnnouncements(courseId.value) const annRes = await fetchCourseAnnouncements(courseId.value)
if (annRes.success) { if (annRes.success) {
announcements.value = annRes.data || [] announcements.value = annRes.data || []
@ -275,7 +275,7 @@ const loadCourseData = async () => {
const loadLesson = async (lessonId: number) => { const loadLesson = async (lessonId: number) => {
if (currentLesson.value?.id === lessonId) return 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 isPlaying.value = false
videoProgress.value = 0 videoProgress.value = 0
currentTime.value = 0 currentTime.value = 0
@ -285,16 +285,16 @@ const loadLesson = async (lessonId: number) => {
lastSavedTimestamp.value = 0 lastSavedTimestamp.value = 0
lastLocalSaveTimestamp.value = 0 lastLocalSaveTimestamp.value = 0
currentDuration.value = 0 currentDuration.value = 0
currentLesson.value = null // This will unmount VideoPlayer and hide content currentLesson.value = null // (This will unmount VideoPlayer and hide content)
isLessonLoading.value = true isLessonLoading.value = true
try { try {
// Optional: Check access first // : (Optional: Check access first)
const accessRes = await checkLessonAccess(courseId.value, lessonId) const accessRes = await checkLessonAccess(courseId.value, lessonId)
if (accessRes.success && !accessRes.data.is_accessible) { if (accessRes.success && !accessRes.data.is_accessible) {
let msg = t('classroom.notAvailable') let msg = t('classroom.notAvailable')
// Handle specific lock reasons // (Handle specific lock reasons)
if (accessRes.data.lock_reason) { if (accessRes.data.lock_reason) {
msg = accessRes.data.lock_reason msg = accessRes.data.lock_reason
} else if (accessRes.data.required_quiz_pass && !accessRes.data.required_quiz_pass.is_passed) { } else if (accessRes.data.required_quiz_pass && !accessRes.data.required_quiz_pass.is_passed) {
@ -314,32 +314,32 @@ const loadLesson = async (lessonId: number) => {
return return
} }
// 1. Fetch content // 1. (Fetch content)
const res = await fetchLessonContent(courseId.value, lessonId) const res = await fetchLessonContent(courseId.value, lessonId)
if (res.success) { if (res.success) {
currentLesson.value = res.data currentLesson.value = res.data
// Initialize progress object if missing (Critical for New Users) // () (Initialize progress object if missing)
if (!currentLesson.value.progress) { if (!currentLesson.value.progress) {
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) { if (currentLesson.value?.progress?.is_completed && courseData.value) {
for (const chapter of courseData.value.chapters) { for (const chapter of courseData.value.chapters) {
const lesson = chapter.lessons.find((l: any) => l.id === lessonId) const lesson = chapter.lessons.find((l: any) => l.id === lessonId)
if (lesson) { if (lesson) {
if (!lesson.progress) lesson.progress = {} if (!lesson.progress) lesson.progress = {}
lesson.progress.is_completed = true lesson.progress.is_completed = true
lesson.is_completed = true // Standardize completion property lesson.is_completed = true // (Standardize completion property)
break break
} }
} }
} }
// 2. Fetch Initial Progress (Resume Playback) // 2. (Fetch Initial Progress (Resume Playback))
if (currentLesson.value.type === 'VIDEO') { if (currentLesson.value.type === 'VIDEO') {
// If already completed, clear local resume point to allow fresh re-watch // (If already completed, clear local resume point to allow fresh re-watch)
const isCompleted = currentLesson.value.progress?.is_completed || false const isCompleted = currentLesson.value.progress?.is_completed || false
if (isCompleted) { if (isCompleted) {
@ -351,7 +351,7 @@ const loadLesson = async (lessonId: number) => {
maxWatchedTime.value = 0 maxWatchedTime.value = 0
currentTime.value = 0 currentTime.value = 0
} else { } else {
// Not completed? Resume from where we left off // ? (Not completed? Resume from where we left off)
const progressRes = await fetchVideoProgress(lessonId) const progressRes = await fetchVideoProgress(lessonId)
let serverProgress = 0 let serverProgress = 0
if (progressRes.success && progressRes.data?.video_progress_seconds) { if (progressRes.success && progressRes.data?.video_progress_seconds) {
@ -379,24 +379,24 @@ const loadLesson = async (lessonId: number) => {
} }
} }
// Video Player Ref (Component) // (Video Player Ref (Component))
const videoPlayerComp = ref(null) const videoPlayerComp = ref(null)
// Video & Progress State // (Video & Progress State)
const initialSeekTime = ref(0) 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 lastSavedTime = ref(-1)
const lastSavedTimestamp = ref(0) // Server throttle timestamp const lastSavedTimestamp = ref(0) // (Server throttle timestamp)
const lastLocalSaveTimestamp = ref(0) // Local throttle timestamp const lastLocalSaveTimestamp = ref(0) // (Local throttle timestamp)
const currentDuration = ref(0) // Track duration for save logic 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) => { const getLocalProgressKey = (lessonId: number) => {
if (!user.value?.id) return null if (!user.value?.id) return null
return `progress:${user.value.id}:${lessonId}` return `progress:${user.value.id}:${lessonId}`
} }
// Helper: Get Local Progress // : (Helper: Get Local Progress)
const getLocalProgress = (lessonId: number): number => { const getLocalProgress = (lessonId: number): number => {
try { try {
const key = getLocalProgressKey(lessonId) const key = getLocalProgressKey(lessonId)
@ -408,7 +408,7 @@ const getLocalProgress = (lessonId: number): number => {
} }
} }
// Helper: Save to Local Storage // : (Helper: Save to Local Storage)
const saveLocalProgress = (lessonId: number, time: number) => { const saveLocalProgress = (lessonId: number, time: number) => {
try { try {
const key = getLocalProgressKey(lessonId) const key = getLocalProgressKey(lessonId)
@ -416,31 +416,31 @@ const saveLocalProgress = (lessonId: number, time: number) => {
localStorage.setItem(key, time.toString()) localStorage.setItem(key, time.toString())
} }
} catch (e) { } 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) => { const handleVideoTimeUpdate = (cTime: number, dur: number) => {
currentDuration.value = dur || 0 currentDuration.value = dur || 0
// Update Monotonic Progress // (Update Monotonic Progress)
if (cTime > maxWatchedTime.value) { if (cTime > maxWatchedTime.value) {
maxWatchedTime.value = cTime maxWatchedTime.value = cTime
} }
// Logic: Periodic Save // : (Logic: Periodic Save)
if (currentLesson.value?.id) { if (currentLesson.value?.id) {
const now = Date.now() const now = Date.now()
// 1. Local Save Throttle (5 seconds) // 1. (5 ) (Local Save Throttle (5 seconds))
if (now - lastLocalSaveTimestamp.value > 5000) { if (now - lastLocalSaveTimestamp.value > 5000) {
saveLocalProgress(currentLesson.value.id, maxWatchedTime.value) saveLocalProgress(currentLesson.value.id, maxWatchedTime.value)
lastLocalSaveTimestamp.value = now lastLocalSaveTimestamp.value = now
} }
// 2. Server Save Throttle (handled inside performSaveProgress) // 2. ( performSaveProgress)
// Note: We don't check isPlaying here because if time is updating, it IS playing. // : isPlaying (Note: We don't check isPlaying here because if time is updating, it IS playing.)
performSaveProgress(false, false) performSaveProgress(false, false)
} }
} }
@ -451,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 performSaveProgress = async (force: boolean = false, keepalive: boolean = false) => {
const lesson = currentLesson.value const lesson = currentLesson.value
if (!lesson || lesson.type !== 'VIDEO') return if (!lesson || lesson.type !== 'VIDEO') return
// Ensure progress object exists // (Ensure progress object exists)
if (!lesson.progress) lesson.progress = {} 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 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 if (isCompleting.value) return
const now = Date.now() 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) 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 (!force) {
if (lastSavedTime.value === -1) { if (lastSavedTime.value === -1) {
// First time save: allow 0 or more // : 0 (First time save: allow 0 or more)
if (maxSec < 0) return if (maxSec < 0) return
} else if (maxSec <= lastSavedTime.value) { } else if (maxSec <= lastSavedTime.value) {
// Subsequent saves: must be greater than last saved // : (Subsequent saves: must be greater than last saved)
return return
} }
} }
// 4. Throttle Check: Server Throttle (15 seconds) // 4. : (15 ) (Throttle Check: Server Throttle (15 seconds))
if (!force && (now - lastSavedTimestamp.value < 15000)) return if (!force && (now - lastSavedTimestamp.value < 15000)) return
// Prepare for Save // (Prepare for Save)
lastSavedTime.value = maxSec lastSavedTime.value = maxSec
lastSavedTimestamp.value = now 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) const isFinishing = force || (durationSec > 0 && maxSec >= durationSec)
if (isFinishing) { if (isFinishing) {
@ -503,8 +503,8 @@ const performSaveProgress = async (force: boolean = false, keepalive: boolean =
try { try {
const res = await saveVideoProgress(lesson.id, maxSec, durationSec, keepalive) const res = await saveVideoProgress(lesson.id, maxSec, durationSec, keepalive)
// Handle Completion (Frontend-only strategy: 95% threshold) // (: 95%) (Handle Completion (Frontend-only strategy: 95% threshold))
// This ensures the checkmark appears at 95% to match backend. // 95% (This ensures the checkmark appears at 95% to match backend.)
const progressPercentage = durationSec > 0 ? (maxSec / durationSec) : 0 const progressPercentage = durationSec > 0 ? (maxSec / durationSec) : 0
const isCompletedNow = res.success && (res.data?.is_completed || progressPercentage >= 0.95) const isCompletedNow = res.success && (res.data?.is_completed || progressPercentage >= 0.95)
@ -513,7 +513,7 @@ const performSaveProgress = async (force: boolean = false, keepalive: boolean =
markLessonAsCompletedLocally(lesson.id) markLessonAsCompletedLocally(lesson.id)
if (lesson.progress) lesson.progress.is_completed = true 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) { if (!wasAlreadyCompleted) {
await loadCourseData() await loadCourseData()
} }
@ -527,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) => { const markLessonAsCompletedLocally = (lessonId: number) => {
if (courseData.value) { if (courseData.value) {
for (const chapter of courseData.value.chapters) { for (const chapter of courseData.value.chapters) {
const lesson = chapter.lessons.find((l: any) => l.id === lessonId) const lesson = chapter.lessons.find((l: any) => l.id === lessonId)
if (lesson) { if (lesson) {
// Compatible with API structure // API (Compatible with API structure)
lesson.is_completed = true lesson.is_completed = true
if (!lesson.progress) lesson.progress = {} if (!lesson.progress) lesson.progress = {}
lesson.progress.is_completed = true lesson.progress.is_completed = true
@ -547,11 +547,11 @@ const videoSrc = computed(() => {
if (!currentLesson.value) return '' if (!currentLesson.value) return ''
let url = '' let url = ''
// Use explicit video_url from API first // video_url API (Use explicit video_url from API first)
if (currentLesson.value.video_url) { if (currentLesson.value.video_url) {
url = currentLesson.value.video_url url = currentLesson.value.video_url
} else { } else {
// Fallback (deprecated logic) // ()
const content = getLocalizedText(currentLesson.value.content) const content = getLocalizedText(currentLesson.value.content)
if (content && (content.startsWith('http') || content.startsWith('/')) && !content.includes(' ')) { if (content && (content.startsWith('http') || content.startsWith('/')) && !content.includes(' ')) {
url = content url = content
@ -560,7 +560,7 @@ const videoSrc = computed(() => {
if (!url) return '' if (!url) return ''
// Support Resume for YouTube // YouTube (Support Resume for YouTube)
const isYoutube = url.toLowerCase().includes('youtube.com') || url.toLowerCase().includes('youtu.be') const isYoutube = url.toLowerCase().includes('youtube.com') || url.toLowerCase().includes('youtu.be')
if (isYoutube && initialSeekTime.value > 0) { if (isYoutube && initialSeekTime.value > 0) {
const separator = url.includes('?') ? '&' : '?' const separator = url.includes('?') ? '&' : '?'
@ -575,7 +575,7 @@ const onVideoEnded = async () => {
const lesson = currentLesson.value const lesson = currentLesson.value
if (!lesson) return if (!lesson) return
// Clear local storage on end since it's completed // localStorage (Clear local storage on end since it's completed)
const key = getLocalProgressKey(lesson.id) const key = getLocalProgressKey(lesson.id)
if (key && typeof window !== 'undefined') { if (key && typeof window !== 'undefined') {
localStorage.removeItem(key) localStorage.removeItem(key)
@ -598,7 +598,7 @@ onMounted(() => {
}) })
onBeforeUnmount(() => { 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 courseData.value = null
currentLesson.value = null currentLesson.value = null
}) })
@ -665,7 +665,7 @@ onBeforeUnmount(() => {
</q-toolbar> </q-toolbar>
</q-header> </q-header>
<!-- Sidebar (Curriculum) - Positioned Right via component prop --> <!-- แถบดานขาง (บทเรยน) - วางชดขวาผานพรอพพ -->
<CurriculumSidebar <CurriculumSidebar
v-model="sidebarOpen" v-model="sidebarOpen"
:courseData="courseData" :courseData="courseData"
@ -676,14 +676,14 @@ onBeforeUnmount(() => {
@open-announcements="handleOpenAnnouncements" @open-announcements="handleOpenAnnouncements"
/> />
<!-- Main Content --> <!-- นทเนอหาหล (Main Content) -->
<q-page-container class="bg-white dark:bg-slate-900"> <q-page-container class="bg-white dark:bg-slate-900">
<q-page class="flex flex-col h-full bg-slate-50 dark:bg-[#0B0F1A]"> <q-page class="flex flex-col h-full bg-slate-50 dark:bg-[#0B0F1A]">
<!-- Video Player & Content Area --> <!-- กรอบวโอและพนทเนอหา (Video Player & Content Area) -->
<div class="w-full h-full p-4 md:p-6 flex-grow overflow-y-auto"> <div class="w-full h-full p-4 md:p-6 flex-grow overflow-y-auto">
<!-- 1. LOADING STATE (Comprehensive Skeleton) --> <!-- 1. สถานะกำลงโหลด (โครงสรางเสมอน (Skeleton) สมบรณแบบ) (LOADING STATE (Comprehensive Skeleton)) -->
<div v-if="isLessonLoading" class="animate-fade-in"> <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"> <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 <img
v-if="courseData?.course?.thumbnail_url" v-if="courseData?.course?.thumbnail_url"
@ -697,7 +697,7 @@ onBeforeUnmount(() => {
</div> </div>
</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="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-10 bg-slate-200 dark:bg-slate-800 rounded-xl w-3/4 mb-4 animate-pulse"></div>
<div class="h-4 bg-slate-100 dark:bg-slate-800 rounded-lg w-full mb-2 animate-pulse"></div> <div class="h-4 bg-slate-100 dark:bg-slate-800 rounded-lg w-full mb-2 animate-pulse"></div>
@ -705,9 +705,9 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
<!-- 2. READY STATE (Real Lesson Content) --> <!-- 2. สถานะพรอมใชงาน (อมลบทเรยนจร) (READY STATE (Real Lesson Content)) -->
<div v-else-if="currentLesson" class="animate-fade-in"> <div v-else-if="currentLesson" class="animate-fade-in">
<!-- Video Player --> <!-- วนการเลนวโอ (Video Player) -->
<VideoPlayer <VideoPlayer
v-if="videoSrc" v-if="videoSrc"
ref="videoPlayerComp" ref="videoPlayerComp"
@ -719,7 +719,7 @@ onBeforeUnmount(() => {
@loadedmetadata="(d: number) => onVideoMetadataLoaded(d)" @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="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"> <div class="flex items-start justify-between gap-4 mb-4">
@ -728,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> <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 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"> <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" /> <q-icon name="quiz" size="40px" />
@ -783,7 +783,7 @@ onBeforeUnmount(() => {
<div v-html="getLocalizedText(currentLesson.content)" class="leading-relaxed text-slate-800 dark:text-slate-200"></div> <div v-html="getLocalizedText(currentLesson.content)" class="leading-relaxed text-slate-800 dark:text-slate-200"></div>
</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"> <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"> <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"> <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"> <script setup lang="ts">
/** /**
* @file quiz.vue * @file quiz.vue
* @description Quiz Interface. * @description หนาสำหรบทำแบบทดสอบ (Quiz Interface)
* Manages the entire quiz lifecycle: Start -> Taking -> Results -> Review. * ดการวงจรชตของการทำแบบทดสอบทงหมด: เรมต -> ทำขอสอบ -> ผลลพธ -> ทบทวน
* Features a timer, question navigation, and detailed result analysis. * เจอรบเวลา การนำทางระหวางคำถาม และการวเคราะหผลลพธอยางละเอยด
*/ */
definePageMeta({ definePageMeta({
@ -17,7 +17,7 @@ const router = useRouter()
const $q = useQuasar() const $q = useQuasar()
const { fetchCourseLearningInfo, fetchLessonContent, submitQuiz: apiSubmitQuiz, markLessonComplete } = useCourse() const { fetchCourseLearningInfo, fetchLessonContent, submitQuiz: apiSubmitQuiz, markLessonComplete } = useCourse()
// State Management // (State Management)
const currentScreen = ref<'start' | 'taking' | 'result' | 'review'>('start') const currentScreen = ref<'start' | 'taking' | 'result' | 'review'>('start')
const timeLeft = ref(0) const timeLeft = ref(0)
let timerInterval: ReturnType<typeof setInterval> | null = null let timerInterval: ReturnType<typeof setInterval> | null = null
@ -30,20 +30,24 @@ const quizData = ref<any>(null)
const isLoading = ref(true) const isLoading = ref(true)
const isSubmitting = ref(false) const isSubmitting = ref(false)
// Quiz Taking State // (Quiz Taking State)
const currentQuestionIndex = ref(0) const currentQuestionIndex = ref(0)
const userAnswers = ref<Record<number, number>>({}) // questionId -> choiceId const userAnswers = ref<Record<number, number>>({}) // ID -> ID (questionId -> choiceId)
const visitedQuestions = ref<Set<number>>(new Set()) // Track visited indices const visitedQuestions = ref<Set<number>>(new Set()) // (Track visited indices)
const quizResult = ref<any>(null) const quizResult = ref<any>(null)
// Tracking visited questions const questionPageSize = 10
const questionPage = ref(0)
// (Tracking visited questions)
watch(currentQuestionIndex, (newVal) => { watch(currentQuestionIndex, (newVal) => {
visitedQuestions.value.add(newVal) visitedQuestions.value.add(newVal)
questionPage.value = Math.floor(newVal / questionPageSize)
}, { immediate: true }) }, { immediate: true })
// Helper: Get Status Color Class // : (Helper: Get Status Color Class)
const getQuestionStatusClass = (index: number, questionId: number) => { const getQuestionStatusClass = (index: number, questionId: number) => {
// 1. Current = Blue // 1. = (Current = Blue)
if (index === currentQuestionIndex.value) { if (index === currentQuestionIndex.value) {
return 'bg-blue-500 text-white border-blue-600 ring-2 ring-blue-200 dark:ring-blue-900' 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 hasAnswer = userAnswers.value[questionId] !== undefined
const isVisited = visitedQuestions.value.has(index) const isVisited = visitedQuestions.value.has(index)
// 2. Completed = Green // 2. = (Completed = Green)
if (hasAnswer) { if (hasAnswer) {
return 'bg-emerald-500 text-white border-emerald-600' return 'bg-emerald-500 text-white border-emerald-600'
} }
// 3. Skipped = Orange (Visited but no answer) // 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.
if (isVisited && !hasAnswer) { if (isVisited && !hasAnswer) {
return 'bg-orange-500 text-white border-orange-600' 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' 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) => { const jumpToQuestion = (targetIndex: number) => {
if (targetIndex === currentQuestionIndex.value) return if (targetIndex === currentQuestionIndex.value) return
// Validation before leaving current (same logic as Next) // (Validation before leaving current)
if (targetIndex > currentQuestionIndex.value) { 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 isAnswered = userAnswers.value[currentQuestion.value.id] !== undefined
const isSkippable = quizData.value?.is_skippable 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 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(() => { const currentQuestion = computed(() => {
if (!quizData.value || !quizData.value.questions) return null if (!quizData.value || !quizData.value.questions) return null
return quizData.value.questions[currentQuestionIndex.value] return quizData.value.questions[currentQuestionIndex.value]
@ -116,7 +140,7 @@ const timerDisplay = computed(() => {
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
}) })
// Helper for localization // (Helper for localization)
const getLocalizedText = (text: any) => { const getLocalizedText = (text: any) => {
if (!text) return '' if (!text) return ''
if (typeof text === 'string') return text if (typeof text === 'string') return text
@ -126,7 +150,7 @@ const getLocalizedText = (text: any) => {
const lessonProgress = ref<any>(null) const lessonProgress = ref<any>(null)
// Data Fetching // (Data Fetching)
const loadData = async () => { const loadData = async () => {
isLoading.value = true isLoading.value = true
try { try {
@ -138,9 +162,9 @@ const loadData = async () => {
if (courseId && lessonId) { if (courseId && lessonId) {
const lessonRes = await fetchLessonContent(courseId, lessonId) const lessonRes = await fetchLessonContent(courseId, lessonId)
if (lessonRes.success) { 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 quizData.value = lessonRes.data.quiz || lessonRes.data
lessonProgress.value = lessonRes.progress // Capture progress lessonProgress.value = lessonRes.progress // (Capture progress)
if (quizData.value?.time_limit) { if (quizData.value?.time_limit) {
timeLeft.value = quizData.value.time_limit * 60 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[] => { const shuffleArray = <T>(array: T[]): T[] => {
return array return array
.map(value => ({ value, sort: Math.random() })) .map(value => ({ value, sort: Math.random() }))
@ -161,18 +185,18 @@ const shuffleArray = <T>(array: T[]): T[] => {
.map(({ value }) => value) .map(({ value }) => value)
} }
// Quiz Actions // (Quiz Actions)
const startQuiz = () => { const startQuiz = () => {
// Deep copy to reset and apply shuffle // (Deep copy to reset and apply shuffle)
const rawQuiz = JSON.parse(JSON.stringify(quizData.value)) const rawQuiz = JSON.parse(JSON.stringify(quizData.value))
if (rawQuiz) { if (rawQuiz) {
// Shuffle Questions // (Shuffle Questions)
if (rawQuiz.shuffle_questions && rawQuiz.questions) { if (rawQuiz.shuffle_questions && rawQuiz.questions) {
rawQuiz.questions = shuffleArray(rawQuiz.questions) rawQuiz.questions = shuffleArray(rawQuiz.questions)
} }
// Shuffle Choices // (Shuffle Choices)
if (rawQuiz.shuffle_choices && rawQuiz.questions) { if (rawQuiz.shuffle_choices && rawQuiz.questions) {
rawQuiz.questions.forEach((q: any) => { rawQuiz.questions.forEach((q: any) => {
if (q.choices) { if (q.choices) {
@ -180,7 +204,7 @@ const startQuiz = () => {
} }
}) })
} }
// Update state with shuffled data // (Update state with shuffled data)
quizData.value = rawQuiz quizData.value = rawQuiz
} }
@ -196,7 +220,7 @@ const startQuiz = () => {
}, 1000) }, 1000)
} }
// Mark first as visited // (Mark first as visited)
visitedQuestions.value = new Set([0]) visitedQuestions.value = new Set([0])
} }
@ -209,12 +233,12 @@ const selectAnswer = (choiceId: number) => {
const nextQuestion = () => { const nextQuestion = () => {
if (!currentQuestion.value) return 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 isAnswered = userAnswers.value[currentQuestion.value.id] !== undefined
const isSkippable = quizData.value?.is_skippable const isSkippable = quizData.value?.is_skippable
if (!isAnswered && !isSkippable) { if (!isAnswered && !isSkippable) {
// Show warning // (Show warning)
$q.notify({ $q.notify({
type: 'warning', type: 'warning',
message: t('quiz.pleaseSelectAnswer', 'กรุณาเลือกคำตอบ'), message: t('quiz.pleaseSelectAnswer', 'กรุณาเลือกคำตอบ'),
@ -241,7 +265,7 @@ const retryQuiz = () => {
} }
const submitQuiz = async (auto = false) => { 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) { if (!auto) {
const answeredCount = Object.keys(userAnswers.value).length const answeredCount = Object.keys(userAnswers.value).length
if (answeredCount < totalQuestions.value) { if (answeredCount < totalQuestions.value) {
@ -254,7 +278,7 @@ const submitQuiz = async (auto = false) => {
return return
} }
// Premium Confirmation before submission // (Premium Confirmation before submission)
$q.dialog({ $q.dialog({
title: `<div class="text-slate-900 dark:text-white font-black text-xl">${t('quiz.warningTitle')}</div>`, 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>`, 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) => { const processSubmitQuiz = async (auto = false) => {
// 2. Start Submission Process // 2. (Start Submission Process)
if (timerInterval) clearInterval(timerInterval) if (timerInterval) clearInterval(timerInterval)
isSubmitting.value = true isSubmitting.value = true
currentScreen.value = 'result' // Switch to result screen to show progress currentScreen.value = 'result' // (Switch to result screen to show progress)
try { try {
// Prepare Payload // API (Prepare Payload)
const answersPayload = Object.entries(userAnswers.value).map(([qId, cId]) => ({ const answersPayload = Object.entries(userAnswers.value).map(([qId, cId]) => ({
question_id: Number(qId), question_id: Number(qId),
choice_id: cId choice_id: cId
})) }))
// Check if already passed // (Check if already passed)
const alreadyPassed = lessonProgress.value?.is_passed || lessonProgress.value?.is_completed || false const alreadyPassed = lessonProgress.value?.is_passed || lessonProgress.value?.is_completed || false
// Call API // API (Call API)
const res = await apiSubmitQuiz(courseId, lessonId, answersPayload, alreadyPassed) const res = await apiSubmitQuiz(courseId, lessonId, answersPayload, alreadyPassed)
if (res.success && res.data) { if (res.success && res.data) {
quizResult.value = 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 (res.data.is_passed && !alreadyPassed) {
if (lessonProgress.value) lessonProgress.value.is_passed = true if (lessonProgress.value) lessonProgress.value.is_passed = true
} }
} else { } else {
// Fallback error handling // (Fallback error handling)
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
message: res.error || 'Failed to submit quiz' message: res.error || 'Failed to submit quiz'
@ -364,14 +388,14 @@ const reviewQuiz = () => {
currentScreen.value = 'review' 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) => { const getChoiceLabel = (index: number) => {
return String.fromCharCode(65 + index) // 65 is 'A' return String.fromCharCode(65 + index) // 65 is 'A'
} }
const getCorrectChoiceId = (questionId: number) => { const getCorrectChoiceId = (questionId: number) => {
if (!quizResult.value?.answers_review) return null if (!quizResult.value?.answers_review) return null
// Type checking for safety // (Type checking for safety)
const review = Array.isArray(quizResult.value.answers_review) const review = Array.isArray(quizResult.value.answers_review)
? quizResult.value.answers_review.find((r: any) => r.question_id === questionId) ? quizResult.value.answers_review.find((r: any) => r.question_id === questionId)
: null : null
@ -384,7 +408,7 @@ const getCorrectChoiceId = (questionId: number) => {
<q-page-container> <q-page-container>
<q-page> <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"> <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"> <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 w-full justify-between">
<div class="flex items-center"> <div class="flex items-center">
@ -410,7 +434,7 @@ const getCorrectChoiceId = (questionId: number) => {
</div> </div>
</header> </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"> <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"> <div v-if="isLoading" class="flex flex-col items-center gap-4">
@ -435,9 +459,9 @@ const getCorrectChoiceId = (questionId: number) => {
</div> </div>
<template v-else> <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"> <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="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"> <div class="flex justify-center mb-10">
@ -459,7 +483,7 @@ const getCorrectChoiceId = (questionId: number) => {
</p> </p>
</div> </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"> <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"> <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') }} {{ $t('quiz.instructionTitle') }}
@ -483,35 +507,58 @@ const getCorrectChoiceId = (questionId: number) => {
</div> </div>
</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="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"> <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="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 class="h-full bg-blue-500 transition-all duration-300" :style="{ width: ((currentQuestionIndex + 1) / totalQuestions) * 100 + '%' }"></div>
</div> </div>
<!-- Question Map / Pagination --> <!-- แผนทคำถาม / การเปลยนหน (Question Map / Pagination) -->
<div class="flex flex-wrap gap-2 mb-8 mt-4"> <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 <button
v-for="(q, idx) in quizData?.questions" v-if="totalQuestionPages > 1"
:key="q.id" @click="prevQuestionPage"
@click="jumpToQuestion(Number(idx))" :disabled="questionPage === 0"
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="w-8 h-8 md:w-10 md:h-10 rounded-lg flex items-center justify-center transition-all flex-shrink-0"
:class="getQuestionStatusClass(Number(idx), q.id)" :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> </button>
</div> </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"> <h3 class="text-xl md:text-2xl font-bold text-slate-900 dark:text-white mb-10 leading-relaxed">
{{ getLocalizedText(currentQuestion.question) }} {{ getLocalizedText(currentQuestion.question) }}
</h3> </h3>
<!-- Choices --> <!-- วนการเลอกคำตอบ (Choices) -->
<div class="flex flex-col gap-4 mb-12"> <div class="flex flex-col gap-4 mb-12">
<button <button
v-for="choice in currentQuestion.choices" 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"> <div class="flex justify-between items-center pt-8 border-t border-slate-100 dark:border-white/5">
<button <button
@click="prevQuestion" @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 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"> <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> </p>
</div> </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="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-center">
<div class="text-xs font-black text-slate-400 uppercase tracking-widest mb-1">{{ $t('quiz.scoreLabel') }}</div> <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>
</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 v-if="currentScreen === 'review'" class="w-full max-w-[840px] animate-fade-in py-12 pb-24">
<div class="space-y-6"> <div class="space-y-6">
<div <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) '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 <div
class="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 font-bold text-sm border-2" class="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 font-bold text-sm border-2"
:class="{ :class="{
@ -668,7 +715,7 @@ const getCorrectChoiceId = (questionId: number) => {
<span class="font-medium text-slate-700 dark:text-slate-300">{{ getLocalizedText(choice.text) }}</span> <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"> <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') }} {{ $t('quiz.correctLabel', 'Correct') }}
</div> </div>
@ -695,11 +742,11 @@ const getCorrectChoiceId = (questionId: number) => {
</main> </main>
</div> <!-- Close quiz-shell --> </div> <!-- ดสวนเปลอกขอสอบ (Close quiz-shell) -->
<!-- Question Navigator Sidebar/Floating (Desktop) - Outside Main Flow --> <!-- อปอปแผนทคำถาม (เดสกอป) - อยนอกพนททำงานปกต (Question Navigator Sidebar/Floating (Desktop) - Outside Main Flow) -->
<!-- Using QPageSticky properly inside q-page/q-layout context we added --> <!-- ใชงาน QPageSticky ใหกท (Using QPageSticky properly inside q-page/q-layout context we added) -->
<q-page-sticky <q-page-sticky
v-if="false" v-if="false"
position="top-right" position="top-right"

View file

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

View file

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

View file

@ -126,7 +126,7 @@ const navigateToCategory = (catName: string) => {
<div class="bg-[#F8F9FA] dark:bg-[#020617] min-h-screen font-inter p-4 md:p-8 transition-colors duration-300"> <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="max-w-[1400px] mx-auto grid grid-cols-1 xl:grid-cols-3 gap-8">
<!-- Left Column (Main Content) --> <!-- คอลมนาย (เนอหาหล) -->
<div class="xl:col-span-2 space-y-6"> <div class="xl:col-span-2 space-y-6">
<!-- ายตอนร (Welcome Banner) --> <!-- ายตอนร (Welcome Banner) -->
@ -179,7 +179,7 @@ const navigateToCategory = (catName: string) => {
</div> </div>
</div> </div>
<!-- Continue Learning (เรยนตอจากครงกอน) --> <!-- วนเรยนตอจากครงกอน (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="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"> <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> <h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">{{ $t('dashboard.continueLearningTitle') }}</h2>
@ -195,7 +195,7 @@ const navigateToCategory = (catName: string) => {
<div class="flex-1 w-full flex flex-col"> <div class="flex-1 w-full flex flex-col">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<!-- Category Badge --> <!-- ายบอกหมวดหม (Category Badge) -->
<div v-if="heroCourse.category"> <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> <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>
@ -208,7 +208,7 @@ const navigateToCategory = (catName: string) => {
<h3 class="text-2xl font-bold text-slate-900 dark:text-white mb-2 leading-snug line-clamp-2"> <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 มาสเตอร์คลาส' }} {{ getLocalizedText(heroCourse.title) || 'Advanced UI/UX Design มาสเตอร์คลาส' }}
</h3> </h3>
<!-- Removed Lesson Title/Number as per request --> <!-- ไมแสดงเลขบทเรยนตามทไดบคำขอมา (Removed Lesson Title/Number as per request) -->
<div class="mb-6 mt-4"> <div class="mb-6 mt-4">
<div class="flex justify-between text-[13px] font-bold mb-2"> <div class="flex justify-between text-[13px] font-bold mb-2">
@ -245,11 +245,11 @@ const navigateToCategory = (catName: string) => {
</div> </div>
</div> </div>
<!-- Right Column (Sidebar/Profile Content) --> <!-- คอลมนขวา (แถบขอมลโปรไฟลและคอรสแนะนำ) -->
<div class="xl:col-span-1 space-y-6"> <div class="xl:col-span-1 space-y-6">
<!-- Profile Widget --> <!-- ดเจตโปรไฟลใช -->
<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"> <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 --> <!-- นหลงตกแต (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="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"> <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">
@ -270,28 +270,28 @@ const navigateToCategory = (catName: string) => {
<div class="flex w-full gap-3 mt-7 relative z-10 px-2"> <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"> <div class="flex-1 bg-[#F8FAFC] dark:bg-slate-800 rounded-2xl p-3.5 flex flex-col items-center justify-center transition-colors shadow-sm">
<span class="text-[1.35rem] font-black text-[#3B6BE8] dark:text-blue-400 mb-1 leading-none">{{ String(enrolledCourses.length || 0).padStart(2, '0') }}</span> <span class="text-[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('myCourses.filterProgress') }}</span> <span class="text-slate-400 text-[10px] font-bold tracking-wider">{{ $t('sidebar.onlineCourses') }}</span>
</div> </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"> <div class="flex-1 bg-[#F8FAFC] dark:bg-slate-800 rounded-2xl p-3.5 flex flex-col items-center justify-center transition-colors shadow-sm">
<span class="text-[1.35rem] font-black text-[#10B981] dark:text-emerald-400 mb-1 leading-none z-10">{{ String(enrolledCourses.filter(c => c.progress >= 100).length || 0).padStart(2, '0') }}</span> <span class="text-[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> <span class="text-slate-400 text-[10px] font-bold tracking-wider z-10">{{ $t('myCourses.filterCompleted') }}</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Recommended Courses Widget --> <!-- ดเจตคอรสแนะนำ -->
<div v-if="recommendedCourses.length > 0" class="bg-white dark:!bg-slate-900 rounded-[2rem] p-6 shadow-sm border border-slate-100 dark:border-slate-800 transition-colors"> <div 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"> <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') }} {{ $t('dashboard.recommendedCourses') }}
</h2> </h2>
<div class="flex flex-col gap-5"> <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}`)"> <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 --> <!-- ปหนาปก (Thumbnail) -->
<div class="w-24 h-[68px] rounded-xl overflow-hidden bg-slate-100 shrink-0 relative shadow-sm"> <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" /> <img :src="course.image" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
</div> </div>
<!-- Info --> <!-- อมลคอร (Info) -->
<div class="flex-1 flex flex-col justify-center min-w-0"> <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> <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"> <div class="flex items-center justify-between mt-auto">

View file

@ -171,17 +171,17 @@ onMounted(() => {
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <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"> <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 --> <!-- วนรปภาพหนาปก (Image) -->
<div class="w-full sm:w-[160px] h-[120px] rounded-[1.25rem] overflow-hidden bg-slate-200 shrink-0 relative group"> <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"/> <img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"/>
<!-- Quick play overlay --> <!-- เลเยอรเลนวโอดวน (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 @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"> <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" /> <q-icon name="play_arrow" color="white" size="20px" class="ml-0.5" />
</div> </div>
</div> </div>
</div> </div>
<!-- Info --> <!-- วนขอมลเนอหา (Info) -->
<div class="flex-1 flex flex-col justify-center min-w-0 py-1 w-full"> <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"> <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> <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>
@ -249,19 +249,19 @@ onMounted(() => {
<div v-else-if="filteredEnrolledCourses.length > 0"> <div v-else-if="filteredEnrolledCourses.length > 0">
<!-- GRID VIEW --> <!-- หมวดแสดงสไตลกรดตาราง (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-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}`)"> <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 --> <!-- วนรปภาพหนาปก (Thumbnail) -->
<div class="relative w-full aspect-[4/3] bg-slate-100 dark:bg-slate-800 overflow-hidden"> <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" /> <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 --> <!-- ายหมวดหมบนมมซายของร (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"> <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 }} {{ course.category_name }}
</div> </div>
</div> </div>
<!-- Card Body --> <!-- วการ (Card Body) -->
<div class="p-5 flex flex-col flex-1"> <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> <h3 class="font-bold text-slate-900 dark:text-white text-[14px] leading-snug line-clamp-2 mb-3">{{ getLocalizedText(course.title) }}</h3>
@ -275,11 +275,11 @@ onMounted(() => {
</div> </div>
</div> </div>
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 mt-3 sm:mt-0"> <div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 mt-3 sm:mt-0">
<!-- Certificate Button --> <!-- มดาวนโหลดใบเซอร (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"> <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') }} <q-icon name="workspace_premium" size="14px" /> {{ $t('course.certificate') }}
</button> </button>
<!-- Continue/Replay 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"> <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') }} {{ course.completed ? $t('course.studyAgain') : $t('dashboard.continue') }}
</button> </button>
@ -289,20 +289,20 @@ onMounted(() => {
</div> </div>
</div> </div>
<!-- LIST VIEW --> <!-- หมวดแสดงสไตลบรรทดรายการ (LIST VIEW) -->
<div v-else class="flex flex-col gap-4"> <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}`)"> <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 --> <!-- วนภาพหนาปกดานซาย (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"> <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" /> <img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
<!-- Badge inside Image --> <!-- ายหมวดหมในรปภาพ (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"> <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 }} {{ course.category_name }}
</div> </div>
</div> </div>
<!-- Content Right --> <!-- เนอหาอยทางขวา (Content Right) -->
<div class="flex-1 w-full flex flex-col md:flex-row gap-6 md:items-center"> <div class="flex-1 w-full flex flex-col md:flex-row gap-6 md:items-center">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
@ -310,7 +310,7 @@ onMounted(() => {
</div> </div>
<!-- Progress and Button Zone --> <!-- โซนความคบหนาและปมกด (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="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="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"> <div class="flex justify-between items-center text-[11px] font-bold text-slate-700 dark:text-slate-300 mb-2 tracking-wide">
@ -323,11 +323,11 @@ onMounted(() => {
</div> </div>
<div class="flex flex-col items-stretch md:items-end gap-2 mt-3 sm:mt-0 w-full sm:w-auto"> <div class="flex flex-col items-stretch md:items-end gap-2 mt-3 sm:mt-0 w-full sm:w-auto">
<!-- Certificate Button --> <!-- มดาวนโหลดใบเซอร (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"> <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') }} <q-icon name="workspace_premium" size="16px" /> {{ $t('course.downloadCertificate') }}
</button> </button>
<!-- Continue/Replay 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"> <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') }} {{ course.completed ? $t('course.studyAgain') : $t('dashboard.continue') }}
</button> </button>
@ -340,7 +340,7 @@ onMounted(() => {
</div> </div>
<!-- Empty filter state --> <!-- กรณนหา/กรองแลวไมพบขอม (Empty filter state) -->
<div v-else class="flex flex-col items-center justify-center py-20"> <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" /> <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> <h3 class="text-lg font-bold text-slate-900 dark:text-white mb-2">{{ $t('myCourses.searchNoResult') }}</h3>
@ -350,7 +350,7 @@ onMounted(() => {
</div> </div>
<!-- MODAL: Enrollment Success --> <!-- หนาตางแจงเตอน: ลงทะเบยนสำเร (MODAL: Enrollment Success) -->
<q-dialog v-model="showEnrollModal" backdrop-filter="blur(4px)"> <q-dialog v-model="showEnrollModal" backdrop-filter="blur(4px)">
<q-card class="rounded-[1.5rem] shadow-2xl p-8 max-w-sm w-full text-center relative overflow-hidden bg-white dark:bg-slate-800"> <q-card class="rounded-[1.5rem] shadow-2xl p-8 max-w-sm w-full text-center relative overflow-hidden bg-white dark:bg-slate-800">
<div class="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-green-400 to-emerald-600"></div> <div class="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-green-400 to-emerald-600"></div>

View file

@ -67,22 +67,22 @@ const showPassword = reactive({
}) })
// Rules have been moved to components // (Rules have been moved to components)
const fileInput = ref<HTMLInputElement | null>(null) // Used in view mode (outside component) const fileInput = ref<HTMLInputElement | null>(null) // () (Used in view mode (outside component))
const toggleEdit = (edit: boolean) => { const toggleEdit = (edit: boolean) => {
isEditing.value = edit isEditing.value = edit
} }
// Updated to accept File object directly (or Event for view mode compatibility if needed) // File ( Event ) (Updated to accept File object directly (or Event for view mode compatibility if needed))
const handleFileUpload = async (fileOrEvent: File | Event) => { const handleFileUpload = async (fileOrEvent: File | Event) => {
let file: File | null = null let file: File | null = null
if (fileOrEvent instanceof File) { if (fileOrEvent instanceof File) {
file = fileOrEvent file = fileOrEvent
} else { } else {
// Fallback for native input change event // input change (Fallback for native input change event)
const target = (fileOrEvent as Event).target as HTMLInputElement const target = (fileOrEvent as Event).target as HTMLInputElement
if (target.files && target.files[0]) { if (target.files && target.files[0]) {
file = target.files[0] file = target.files[0]
@ -112,7 +112,7 @@ const handleFileUpload = async (fileOrEvent: File | Event) => {
} }
} }
// Trigger upload for VIEW mode avatar click // View (Trigger upload for VIEW mode avatar click)
const triggerUpload = () => { const triggerUpload = () => {
fileInput.value?.click() fileInput.value?.click()
} }
@ -191,7 +191,7 @@ const handleUpdatePassword = async () => {
isPasswordSaving.value = false isPasswordSaving.value = false
} }
// Watch for changes in global user state (e.g. after avatar upload or profile update) // ( ) (Watch for changes in global user state (e.g. after avatar upload or profile update))
watch(() => currentUser.value, (newUser) => { watch(() => currentUser.value, (newUser) => {
if (newUser) { if (newUser) {
userData.value.photoURL = newUser.photoURL || '' userData.value.photoURL = newUser.photoURL || ''
@ -220,7 +220,7 @@ onMounted(async () => {
<q-spinner size="3rem" color="primary" /> <q-spinner size="3rem" color="primary" />
</div> </div>
<!-- MAIN PROFILE SETTINGS --> <!-- การตงคาโปรไฟลหล (MAIN PROFILE SETTINGS) -->
<div v-else class="max-w-5xl mx-auto pb-20 fade-in pt-4"> <div v-else class="max-w-5xl mx-auto pb-20 fade-in pt-4">
<!-- ตรขอมลโปรไฟล (Profile Card) --> <!-- ตรขอมลโปรไฟล (Profile Card) -->
@ -255,7 +255,7 @@ onMounted(async () => {
</div> </div>
</div> </div>
<!-- Form Inputs (2 Column Grid) --> <!-- ลดอมลฟอร (แบงเป 2 คอลมน) (Form Inputs (2 Column Grid)) -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6 mb-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6 mb-4">
<div class="md:col-span-2 relative"> <div class="md:col-span-2 relative">
<label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300 mb-2">{{ $t('profile.prefix') }}</label> <label class="block text-[13px] font-bold text-slate-700 dark:text-slate-300 mb-2">{{ $t('profile.prefix') }}</label>
@ -300,7 +300,7 @@ onMounted(async () => {
</div> </div>
<!-- Footer Buttons --> <!-- มกดยนยนตางๆ (Footer Buttons) -->
<div class="px-6 sm:px-8 py-5 border-t border-slate-200 dark:border-slate-800 flex flex-col sm:flex-row justify-center sm:justify-end gap-3 items-center bg-white dark:!bg-slate-900"> <div class="px-6 sm:px-8 py-5 border-t border-slate-200 dark:border-slate-800 flex flex-col sm:flex-row justify-center sm:justify-end gap-3 items-center bg-white dark:!bg-slate-900">
<button class="w-full sm:w-auto text-[13px] font-bold text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white px-4 py-2 transition order-2 sm:order-1" @click="fetchUserProfile(true)">{{ $t('common.cancel') }}</button> <button class="w-full sm:w-auto text-[13px] font-bold text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white px-4 py-2 transition order-2 sm:order-1" @click="fetchUserProfile(true)">{{ $t('common.cancel') }}</button>
<button @click="handleUpdateProfile" :disabled="isProfileSaving" class="w-full sm:w-auto bg-[#3B6BE8] hover:bg-blue-700 text-white px-6 py-2.5 rounded-lg text-[13px] font-bold transition shadow-sm disabled:opacity-50 order-1 sm:order-2"> <button @click="handleUpdateProfile" :disabled="isProfileSaving" class="w-full sm:w-auto bg-[#3B6BE8] hover:bg-blue-700 text-white px-6 py-2.5 rounded-lg text-[13px] font-bold transition shadow-sm disabled:opacity-50 order-1 sm:order-2">
@ -310,7 +310,7 @@ onMounted(async () => {
</div> </div>
</div> </div>
<!-- Security Card --> <!-- การดความปลอดภ (Security Card) -->
<div class="bg-white dark:!bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl shadow-sm overflow-hidden"> <div class="bg-white dark:!bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl shadow-sm overflow-hidden">
<div class="p-8 border-b border-slate-200 dark:border-slate-800"> <div class="p-8 border-b border-slate-200 dark:border-slate-800">
<h2 class="text-xl font-bold text-slate-900 dark:text-white">{{ $t('profile.security') }}</h2> <h2 class="text-xl font-bold text-slate-900 dark:text-white">{{ $t('profile.security') }}</h2>
@ -337,7 +337,7 @@ onMounted(async () => {
</div> </div>
<!-- Password Modal --> <!-- โมดอลเปลยนรหสผาน (Password Modal) -->
<q-dialog v-model="showPasswordModal"> <q-dialog v-model="showPasswordModal">
<q-card class="w-full max-w-md rounded-2xl p-2 dark:bg-slate-900 shadow-xl"> <q-card class="w-full max-w-md rounded-2xl p-2 dark:bg-slate-900 shadow-xl">
<q-form @submit="handleUpdatePassword"> <q-form @submit="handleUpdatePassword">

View file

@ -22,7 +22,7 @@ const { user } = useAuth()
const categoryCards = CATEGORY_CARDS const categoryCards = CATEGORY_CARDS
const whyChooseUs = WHY_CHOOSE_US const whyChooseUs = WHY_CHOOSE_US
// // (Level)
const levelModel = ref('ระดับทั้งหมด') const levelModel = ref('ระดับทั้งหมด')
const levelOptions = ['ระดับทั้งหมด','ระดับเริ่มต้น', 'ระดับกลาง', 'ระดับสูง'] const levelOptions = ['ระดับทั้งหมด','ระดับเริ่มต้น', 'ระดับกลาง', 'ระดับสูง']
@ -59,6 +59,19 @@ const loadData = async () => {
} }
} }
const getCategoryIcon = (categoryName: string) => {
if (!categoryName) return 'o_category'
const text = categoryName.toLowerCase()
if (text.includes('ออกแบบ') || text.includes('design')) return 'o_palette'
if (text.includes('โปรแกรม') || text.includes('code') || text.includes('dev')) return 'o_terminal'
if (text.includes('ธุรกิจ') || text.includes('business') || text.includes('การตลาด') || text.includes('market')) return 'o_storefront'
if (text.includes('ภาษา') || text.includes('language')) return 'o_language'
if (text.includes('ข้อมูล') || text.includes('data')) return 'o_analytics'
return 'o_interests'
}
const goBrowse = (slug: string) => { const goBrowse = (slug: string) => {
navigateTo({ path: '/browse', query: { category: slug } }) navigateTo({ path: '/browse', query: { category: slug } })
} }
@ -95,22 +108,22 @@ onMounted(() => {
<!-- Section 1: Hero Section --> <!-- Section 1: Hero Section -->
<section class="container mx-auto py-24 md:py-24 lg:py-28 px-6 lg:px-12 pb-16"> <section class="container mx-auto py-24 md:py-24 lg:py-28 px-6 lg:px-12 pb-16">
<div class="flex flex-col lg:flex-row items-center gap-10 lg:gap-10 justify-between animate-fade-in"> <div class="flex flex-col lg:flex-row items-center gap-10 lg:gap-10 justify-between animate-fade-in">
<!-- Left Content --> <!-- านซาย: อความและปมกด (Left Content) -->
<div class="flex flex-col items-start gap-6 flex-1 max-w-2xl "> <div class="flex flex-col items-start gap-6 flex-1 max-w-2xl ">
<!-- Heading --> <!-- วขอหล (Heading) -->
<h1 class="text-4xl sm:text-5xl lg:text-[55px] font-bold leading-tight lg:leading-[66px] slide-up" style="animation-delay: 0.2s;"> <h1 class="text-4xl sm:text-5xl lg:text-[55px] font-bold leading-tight lg:leading-[66px] slide-up" style="animation-delay: 0.2s;">
<span class="text-slate-900">ขยายขอบเขตความรของค</span><br> <span class="text-slate-900">ขยายขอบเขตความรของค</span><br>
<span class="text-blue-600">วยการเรยนรออนไลน</span> <span class="text-blue-600">วยการเรยนรออนไลน</span>
</h1> </h1>
<!-- Subtitle --> <!-- คำอธบายรอง (Subtitle) -->
<p class="text-slate-500 text-lg sm:text-xl leading-relaxed slide-up" style="animation-delay: 0.3s;"> <p class="text-slate-500 text-lg sm:text-xl leading-relaxed slide-up" style="animation-delay: 0.3s;">
ดประกายความรของค และเรมตนอปสกลกบผเชยวชาญ ดประกายความรของค และเรมตนอปสกลกบผเชยวชาญ
ในอตสาหกรรมทความรรอบดานหลากหลายในหลายสาขา ในอตสาหกรรมทความรรอบดานหลากหลายในหลายสาขา
เรยนไดกท กเวลา เรยนไดกท กเวลา
</p> </p>
<!-- Buttons --> <!-- มกดตางๆ (Buttons) -->
<div class=" w-full flex flex-col sm:flex-row items-center gap-4 pt-5 slide-up" style="animation-delay: 0.4s;"> <div class=" w-full flex flex-col sm:flex-row items-center gap-4 pt-5 slide-up" style="animation-delay: 0.4s;">
<q-btn <q-btn
unelevated unelevated
@ -134,7 +147,7 @@ onMounted(() => {
</div> </div>
</div> </div>
<!-- Right - Hero Image --> <!-- านขวา: ปภาพฮโร (Right - Hero Image) -->
<div class="flex-1 w-full max-w-lg md:max-w-md lg:max-w-xl pl-0 py-10"> <div class="flex-1 w-full max-w-lg md:max-w-md lg:max-w-xl pl-0 py-10">
<div class="relative rounded-2xl overflow-hidden shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)] aspect-square"> <div class="relative rounded-2xl overflow-hidden shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)] aspect-square">
<img <img
@ -144,7 +157,7 @@ onMounted(() => {
/> />
<div class="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent" /> <div class="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent" />
<!-- Course Card Overlay --> <!-- วนทบซอนสำหรบทำโมเดลการดคอร (Course Card Overlay) -->
<!-- <div class="absolute bottom-5 left-5 right-5"> <!-- <div class="absolute bottom-5 left-5 right-5">
<div class="bg-white/85 backdrop-blur-sm border border-white/20 rounded-3xl px-6 py-5"> <div class="bg-white/85 backdrop-blur-sm border border-white/20 rounded-3xl px-6 py-5">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
@ -170,7 +183,7 @@ onMounted(() => {
<!-- Section 2: ทำไมตองเลอกแพลตฟอรมของเรา --> <!-- Section 2: ทำไมตองเลอกแพลตฟอรมของเรา -->
<section class="pt-20 pb-14 bg-white relative flex-col"> <section class="pt-20 pb-14 bg-white relative flex-col">
<div class="container mx-auto px-6 lg:px-12"> <div class="container mx-auto px-6 lg:px-12">
<!-- Heading --> <!-- วขอหล (Heading) -->
<div class="text-center mb-16 slide-up"> <div class="text-center mb-16 slide-up">
<h2 class="text-4xl md:text-[2.4rem] font-bold text-slate-900 mb-6"> <h2 class="text-4xl md:text-[2.4rem] font-bold text-slate-900 mb-6">
ทำไมตองเลอกแพลตฟอรมของเรา? ทำไมตองเลอกแพลตฟอรมของเรา?
@ -180,7 +193,7 @@ onMounted(() => {
</p> </p>
</div> </div>
<!-- Horizontal Cards --> <!-- โซนแนะนำแบบการดแนวนอน (Horizontal Cards) -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div v-for="(item, i) in whyChooseUs" :key="i" <div v-for="(item, i) in whyChooseUs" :key="i"
class="slide-up p-10 rounded-2xl bg-[#F8FAFC] border border-[#f1f2f9] hover:border-[#2463eb61] hover:bg-white transition-all duration-500 group" class="slide-up p-10 rounded-2xl bg-[#F8FAFC] border border-[#f1f2f9] hover:border-[#2463eb61] hover:bg-white transition-all duration-500 group"
@ -203,25 +216,25 @@ onMounted(() => {
<!-- Section 3: เลอกเรยนตามเรองทณสนใจ --> <!-- Section 3: เลอกเรยนตามเรองทณสนใจ -->
<section class="py-20 md:py-24 bg-white"> <section class="py-20 md:py-24 bg-white">
<div class="container mx-auto px-6 lg:px-12"> <div class="container mx-auto px-6 lg:px-12">
<!-- Heading --> <!-- วข (Heading) -->
<div class="mb-12 slide-up"> <div class="mb-12 slide-up">
<h2 class="text-[1.4rem] text-3xl md:text-4xl font-semibold text-slate-900 px-4"> <h2 class="text-[1.4rem] text-3xl md:text-4xl font-semibold text-slate-900 px-4">
เลอกเรยนตามเรองทณสนใจ เลอกเรยนตามเรองทณสนใจ
</h2> </h2>
</div> </div>
<!-- Horizontal Cards --> <!-- โซนหมวดหมแบบการดแนวนอน (Horizontal Cards) -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 justify-center gap-6 px-4"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 justify-center gap-6 px-4">
<div v-for="(card, i) in categoryCards" :key="i" <div v-for="(card, i) in categoryCards" :key="i"
class="cursor-pointer bg-white rounded-3xl p-6 border border-slate-200/80 shadow-[0px_1px_2px_0px_rgba(0,0,0,0.05)] hover:shadow-2xl hover:shadow-blue-600/5 hover:-translate-y-1 hover:border-[#2463eb61] transition-all duration-500 flex items-center gap-5" class="cursor-pointer bg-white rounded-3xl p-6 border border-slate-200/80 shadow-[0px_1px_2px_0px_rgba(0,0,0,0.05)] hover:shadow-2xl hover:shadow-blue-600/5 hover:-translate-y-1 hover:border-[#2463eb61] transition-all duration-500 flex items-center gap-5"
@click="goBrowse(card.slug)" @click="goBrowse(card.slug)"
> >
<!-- Icon Box --> <!-- กลองไอคอน (Icon Box) -->
<div class="flex-shrink-0 w-16 h-16 rounded-2xl flex items-center justify-center bg-slate-50 group-hover:scale-110 transition-transform duration-500"> <div class="flex-shrink-0 w-16 h-16 rounded-2xl flex items-center justify-center bg-slate-50 group-hover:scale-110 transition-transform duration-500">
<q-icon :name="card.icon" size="35px" class="text-blue-600" /> <q-icon :name="card.icon" size="35px" class="text-blue-600" />
</div> </div>
<!-- Content --> <!-- เนอหาขอความ (Content) -->
<div class="flex-grow pr-2"> <div class="flex-grow pr-2">
<h3 class="text-lg md:text-xl font-bold text-slate-900 mb-1 group-hover:text-blue-600 transition-colors leading-tight"> <h3 class="text-lg md:text-xl font-bold text-slate-900 mb-1 group-hover:text-blue-600 transition-colors leading-tight">
{{ card.title }} {{ card.title }}
@ -231,7 +244,7 @@ onMounted(() => {
</p> </p>
</div> </div>
<!-- Arrow --> <!-- กศรชขวา (Arrow) -->
<div class="gt-xs flex-shrink-0 text-slate-300 group-hover:text-blue-600 transition-colors transform group-hover:translate-x-1 duration-300"> <div class="gt-xs flex-shrink-0 text-slate-300 group-hover:text-blue-600 transition-colors transform group-hover:translate-x-1 duration-300">
<q-icon name="chevron_right" size="24px" /> <q-icon name="chevron_right" size="24px" />
</div> </div>
@ -243,7 +256,7 @@ onMounted(() => {
<!-- Section 4: "คอร์สออนไลน์" --> <!-- Section 4: "คอร์สออนไลน์" -->
<section class="py-12 md:py-24 bg-slate-50"> <section class="py-12 md:py-24 bg-slate-50">
<div class="container mx-auto px-6 lg:px-12"> <div class="container mx-auto px-6 lg:px-12">
<!-- Heading --> <!-- วขอหลกและลงกเพมเต (Heading) -->
<div class="flex flex-col md:flex-row items-start md:items-end justify-between mb-5 gap-8"> <div class="flex flex-col md:flex-row items-start md:items-end justify-between mb-5 gap-8">
<div class="slide-up"> <div class="slide-up">
<h2 class="text-4xl md:text-[2.4rem] font-bold text-slate-900 mb-4">คอรสออนไลน</h2> <h2 class="text-4xl md:text-[2.4rem] font-bold text-slate-900 mb-4">คอรสออนไลน</h2>
@ -254,9 +267,9 @@ onMounted(() => {
</NuxtLink> </NuxtLink>
</div> </div>
<!-- Filters Row --> <!-- แถวตวกรองคอร (Filters Row) -->
<div class="flex items-center gap-2 mb-8 overflow-x-auto no-scrollbar slide-up justify-between"> <div class="flex items-center gap-2 mb-8 overflow-x-auto no-scrollbar slide-up justify-between">
<!-- Category Filters --> <!-- วกรองหมวดหมคอร (Category Filters) -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
class="py-2 px-5 rounded-full font-medium text-lg transition-all whitespace-nowrap border-2" class="py-2 px-5 rounded-full font-medium text-lg transition-all whitespace-nowrap border-2"
@ -274,7 +287,7 @@ onMounted(() => {
:class="selectedCategory === category.slug ? 'bg-blue-600 text-white border-blue-600 font-semibold' : 'bg-white border-slate-200 text-slate-700 hover:border-slate-300'" :class="selectedCategory === category.slug ? 'bg-blue-600 text-white border-blue-600 font-semibold' : 'bg-white border-slate-200 text-slate-700 hover:border-slate-300'"
@click="selectedCategory = category.slug" @click="selectedCategory = category.slug"
> >
<q-icon :name="category.icon || 'o_label'" size="20px" class="mr-1" /> <q-icon :name="getCategoryIcon(getLocalizedText(category.name))" size="20px" class="mr-1" />
{{ getLocalizedText(category.name) }} {{ getLocalizedText(category.name) }}
</button> </button>
</div> </div>
@ -296,7 +309,7 @@ onMounted(() => {
</div> --> </div> -->
</div> </div>
<!-- Courses Carousel --> <!-- ระบบเลอนสไลดคอร (Courses Carousel) -->
<div v-if="isLoading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div v-if="isLoading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div v-for="i in 4" :key="i" class="bg-white rounded-2xl h-[450px] animate-pulse" /> <div v-for="i in 4" :key="i" class="bg-white rounded-2xl h-[450px] animate-pulse" />
</div> </div>
@ -326,7 +339,7 @@ onMounted(() => {
class="flex flex-col flex-1 min-w-0 rounded-2xl border border-slate-100 bg-white shadow-sm overflow-hidden hover:shadow-lg hover:-translate-y-1 transition-all duration-300 cursor-pointer" class="flex flex-col flex-1 min-w-0 rounded-2xl border border-slate-100 bg-white shadow-sm overflow-hidden hover:shadow-lg hover:-translate-y-1 transition-all duration-300 cursor-pointer"
@click="navigateTo(`/course/${course.id}`)" @click="navigateTo(`/course/${course.id}`)"
> >
<!-- Image--> <!-- ปภาพหนาปกคอร (Image) -->
<div class="relative flex-shrink-0"> <div class="relative flex-shrink-0">
<img <img
v-if="course.thumbnail_url" v-if="course.thumbnail_url"
@ -339,23 +352,23 @@ onMounted(() => {
</div> </div>
</div> </div>
<!-- Content --> <!-- เนอหาของคอร (Content) -->
<div class="flex flex-col flex-1 p-6"> <div class="flex flex-col flex-1 p-6">
<!-- Title --> <!-- อคอร (Title) -->
<h3 class="text-[#0F172A] font-semibold text-lg leading-snug mb-2 line-clamp-2"> <h3 class="text-[#0F172A] font-semibold text-lg leading-snug mb-2 line-clamp-2">
{{ getLocalizedText(course.title) }} {{ getLocalizedText(course.title) }}
</h3> </h3>
<!-- Description --> <!-- รายละเอยดแบบย (Description) -->
<p class="text-slate-500 text-sm leading-relaxed mb-4 flex-1 line-clamp-2"> <p class="text-slate-500 text-sm leading-relaxed mb-4 flex-1 line-clamp-2">
{{ getLocalizedText(course.description) }} {{ getLocalizedText(course.description) }}
</p> </p>
<!-- Price + Button --> <!-- วนแถบราคาและปมกด (Price + Button) -->
<div class="flex items-center justify-between pt-6 border-t border-slate-100 gap-2"> <div class="flex items-center justify-between pt-6 border-t border-slate-100 gap-2">
<div class="flex flex-col"> <div class="flex flex-col">
<span v-if="course.price > 0" class="text-[#0F172A] font-bold text-xl"> <span v-if="course.price > 0" class="text-[#0F172A] font-bold text-xl">
@ -378,7 +391,7 @@ onMounted(() => {
</q-carousel-slide> </q-carousel-slide>
</q-carousel> </q-carousel>
<!-- Custom Carousel Navigation --> <!-- ระบบนำทางสไลดแบบกำหนดเอง (Custom Carousel Navigation) -->
<button <button
v-if="courseChunks.length > 1" v-if="courseChunks.length > 1"
class="absolute -left-4 md:-left-12 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full bg-white shadow-xl border border-slate-100 flex items-center justify-center text-slate-500 hover:text-blue-600 transition-all hover:scale-110" class="absolute -left-4 md:-left-12 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full bg-white shadow-xl border border-slate-100 flex items-center justify-center text-slate-500 hover:text-blue-600 transition-all hover:scale-110"

View file

@ -2,7 +2,7 @@
<div class="min-h-screen bg-gray-50 p-4 md:p-8"> <div class="min-h-screen bg-gray-50 p-4 md:p-8">
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<!-- Header / Title --> <!-- วนห / อเรอง (Header / Title) -->
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex items-center justify-between">
<div> <div>
<h1 class="text-2xl font-bold text-gray-800">Quiz Runner</h1> <h1 class="text-2xl font-bold text-gray-800">Quiz Runner</h1>
@ -16,7 +16,7 @@
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<!-- Sidebar: Question Navigator --> <!-- แถบดานขาง: วนำทางคำถาม (Sidebar: Question Navigator) -->
<div class="lg:col-span-3 order-2 lg:order-1"> <div class="lg:col-span-3 order-2 lg:order-1">
<QCard class="bg-white shadow-sm sticky top-4"> <QCard class="bg-white shadow-sm sticky top-4">
<QCardSection> <QCardSection>
@ -33,7 +33,7 @@
</button> </button>
</div> </div>
<!-- Legend --> <!-- คำอธบายสญลกษณ (Legend) -->
<div class="mt-6 space-y-2 text-xs text-gray-600"> <div class="mt-6 space-y-2 text-xs text-gray-600">
<div class="flex items-center gap-2"><div class="w-3 h-3 rounded-full bg-blue-500"></div> Current</div> <div class="flex items-center gap-2"><div class="w-3 h-3 rounded-full bg-blue-500"></div> Current</div>
<div class="flex items-center gap-2"><div class="w-3 h-3 rounded-full bg-green-500"></div> Completed</div> <div class="flex items-center gap-2"><div class="w-3 h-3 rounded-full bg-green-500"></div> Completed</div>
@ -44,11 +44,11 @@
</QCard> </QCard>
</div> </div>
<!-- Main Content: Question --> <!-- เนอหาหล: คำถาม (Main Content: Question) -->
<div class="lg:col-span-9 order-1 lg:order-2"> <div class="lg:col-span-9 order-1 lg:order-2">
<QCard v-if="store.currentQuestion" class="bg-white shadow-md min-h-[400px] flex flex-col"> <QCard v-if="store.currentQuestion" class="bg-white shadow-md min-h-[400px] flex flex-col">
<!-- Question Header --> <!-- วนหวคำถาม (Question Header) -->
<QCardSection class="bg-gray-50 border-b border-gray-100 py-4"> <QCardSection class="bg-gray-50 border-b border-gray-100 py-4">
<div class="flex justify-between items-start"> <div class="flex justify-between items-start">
<div> <div>
@ -57,18 +57,18 @@
</QBadge> </QBadge>
<h2 class="text-xl font-medium text-gray-800"> <h2 class="text-xl font-medium text-gray-800">
<span class="text-gray-400 mr-2">{{ store.currentQuestionIndex + 1 }}.</span> <span class="text-gray-400 mr-2">{{ store.currentQuestionIndex + 1 }}.</span>
{{ store.currentQuestion.title }} {{ getLocalizedString(store.currentQuestion.question) }}
</h2> </h2>
</div> </div>
</div> </div>
</QCardSection> </QCardSection>
<!-- Question Body --> <!-- วนเนอหาคำถาม (Question Body) -->
<QCardSection class="flex-grow py-8 px-6"> <QCardSection class="flex-grow py-8 px-6">
<!-- Single Choice --> <!-- เลอกคำตอบเดยว (Single Choice) -->
<div v-if="store.currentQuestion.type === 'single'"> <div v-if="store.currentQuestion.type === 'single'">
<div v-for="opt in store.currentQuestion.options" :key="opt.id" <div v-for="opt in store.currentQuestion.choices" :key="opt.id"
class="mb-3 p-3 border rounded-lg hover:bg-blue-50 cursor-pointer transition-colors" class="mb-3 p-3 border rounded-lg hover:bg-blue-50 cursor-pointer transition-colors"
:class="{ 'border-blue-500 bg-blue-50': currentVal === opt.id, 'border-gray-200': currentVal !== opt.id }" :class="{ 'border-blue-500 bg-blue-50': currentVal === opt.id, 'border-gray-200': currentVal !== opt.id }"
@click="handleInput(opt.id)"> @click="handleInput(opt.id)">
@ -77,14 +77,14 @@
:class="{ 'border-blue-500': currentVal === opt.id, 'border-gray-300': currentVal !== opt.id }"> :class="{ 'border-blue-500': currentVal === opt.id, 'border-gray-300': currentVal !== opt.id }">
<div v-if="currentVal === opt.id" class="w-2.5 h-2.5 rounded-full bg-blue-500"></div> <div v-if="currentVal === opt.id" class="w-2.5 h-2.5 rounded-full bg-blue-500"></div>
</div> </div>
<span class="text-gray-700">{{ opt.label }}</span> <span class="text-gray-700">{{ getLocalizedString(opt.text) }}</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Multiple Choice --> <!-- เลอกหลายคำตอบ (Multiple Choice) -->
<div v-else-if="store.currentQuestion.type === 'multiple'"> <div v-else-if="store.currentQuestion.type === 'multiple'">
<div v-for="opt in store.currentQuestion.options" :key="opt.id" <div v-for="opt in store.currentQuestion.choices" :key="opt.id"
class="mb-3 p-3 border rounded-lg hover:bg-blue-50 cursor-pointer transition-colors" class="mb-3 p-3 border rounded-lg hover:bg-blue-50 cursor-pointer transition-colors"
:class="{ 'border-blue-500 bg-blue-50': isSelected(opt.id), 'border-gray-200': !isSelected(opt.id) }" :class="{ 'border-blue-500 bg-blue-50': isSelected(opt.id), 'border-gray-200': !isSelected(opt.id) }"
@click="toggleSelection(opt.id)"> @click="toggleSelection(opt.id)">
@ -93,12 +93,12 @@
:class="{ 'border-blue-500 bg-blue-500': isSelected(opt.id), 'border-gray-300': !isSelected(opt.id) }"> :class="{ 'border-blue-500 bg-blue-500': isSelected(opt.id), 'border-gray-300': !isSelected(opt.id) }">
<QIcon v-if="isSelected(opt.id)" name="check" class="text-white text-xs" /> <QIcon v-if="isSelected(opt.id)" name="check" class="text-white text-xs" />
</div> </div>
<span class="text-gray-700">{{ opt.label }}</span> <span class="text-gray-700">{{ getLocalizedString(opt.text) }}</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Text Input --> <!-- มพอความ (Text Input) -->
<div v-else-if="store.currentQuestion.type === 'text'"> <div v-else-if="store.currentQuestion.type === 'text'">
<QInput <QInput
v-model="textModel" v-model="textModel"
@ -113,7 +113,7 @@
</QCardSection> </QCardSection>
<!-- Error Banner --> <!-- แบนเนอรแสดงขอผดพลาด (Error Banner) -->
<QBanner v-if="store.lastError" class="bg-red-50 text-red-600 px-6 py-2 border-t border-red-100"> <QBanner v-if="store.lastError" class="bg-red-50 text-red-600 px-6 py-2 border-t border-red-100">
<template v-slot:avatar> <template v-slot:avatar>
<QIcon name="warning" color="red" /> <QIcon name="warning" color="red" />
@ -121,7 +121,7 @@
{{ store.lastError }} {{ store.lastError }}
</QBanner> </QBanner>
<!-- Actions Footer --> <!-- วนทายปมกดตางๆ (Actions Footer) -->
<QCardSection class="border-t border-gray-100 bg-gray-50 p-4 flex flex-wrap gap-4 items-center justify-between"> <QCardSection class="border-t border-gray-100 bg-gray-50 p-4 flex flex-wrap gap-4 items-center justify-between">
<QBtn <QBtn
@ -140,7 +140,7 @@
flat flat
color="orange-8" color="orange-8"
label="Skip for now" label="Skip for now"
@click="store.skipQuestion()" @click="store.nextQuestion()"
no-caps no-caps
/> />
@ -178,11 +178,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, onMounted, watch, reactive } from 'vue'; import { computed, ref, onMounted, watch, reactive } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
// Composable is auto-imported in Nuxt // Nuxt (Composable is auto-imported in Nuxt)
// import { useQuizRunner } from '@/composables/useQuizRunner'; // import { useQuizRunner } from '@/composables/useQuizRunner';
const route = useRoute(); const route = useRoute();
// Wrap in reactive to unwrap refs, mimicking Pinia store behavior for template // reactive refs Pinia store template (Wrap in reactive to unwrap refs, mimicking Pinia store behavior for template)
const store = reactive(useQuizRunner()); const store = reactive(useQuizRunner());
onMounted(() => { onMounted(() => {
@ -190,7 +190,16 @@ onMounted(() => {
store.initQuiz(quizId); store.initQuiz(quizId);
}); });
// -- Helpers for Input Handling -- // -- (Helpers for Input Handling) --
// (Helper to safely format text)
const getLocalizedString = (val: any): string => {
if (typeof val === 'string') return val;
if (val && typeof val === 'object') {
return val.th || val.en || String(val);
}
return String(val || '');
}
const currentVal = computed(() => { const currentVal = computed(() => {
return store.currentAnswer?.value; return store.currentAnswer?.value;
@ -200,23 +209,23 @@ const isSaved = computed(() => {
return store.currentAnswer?.is_saved; return store.currentAnswer?.is_saved;
}); });
// Single Choice // (Single Choice)
function handleInput(val: string) { function handleInput(val: number) {
store.updateAnswer(val); store.updateAnswer(val);
} }
// Text Choice // (Text Choice)
const textModel = ref(''); const textModel = ref('');
// Watch for question changes to reset text model // (Watch for question changes to reset text model)
watch( watch(
() => store.currentQuestionIndex, () => store.currentQuestionIndex,
() => { () => {
if (store.currentQuestion?.type === 'text') { if (store.currentQuestion?.type === 'text') {
textModel.value = (store.currentAnswer?.value as string) || ''; textModel.value = (store.currentAnswer?.value as string) || '';
} }
// Clear error when changing question // (Clear error when changing question)
store.lastError = null; store.lastError = null;
// Scroll to top // (Scroll to top)
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
} }
@ -224,7 +233,7 @@ watch(
{ immediate: true } { immediate: true }
); );
// Watch for error to scroll to error/field // (Watch for error to scroll to error/field)
watch( watch(
() => store.lastError, () => store.lastError,
(newVal) => { (newVal) => {
@ -245,8 +254,8 @@ function handleTextInput(val: string | number | null) {
store.updateAnswer(val as string); store.updateAnswer(val as string);
} }
// Multiple Choice // (Multiple Choice)
function isSelected(id: string) { function isSelected(id: number) {
const val = store.currentAnswer?.value; const val = store.currentAnswer?.value;
if (Array.isArray(val)) { if (Array.isArray(val)) {
return val.includes(id); return val.includes(id);
@ -254,9 +263,9 @@ function isSelected(id: string) {
return false; return false;
} }
function toggleSelection(id: string) { function toggleSelection(id: number) {
const val = store.currentAnswer?.value; const val = store.currentAnswer?.value;
let currentArr: string[] = []; let currentArr: number[] = [];
if (Array.isArray(val)) { if (Array.isArray(val)) {
currentArr = [...val]; currentArr = [...val];
} }
@ -270,10 +279,10 @@ function toggleSelection(id: string) {
store.updateAnswer(currentArr); store.updateAnswer(currentArr);
} }
// -- Helpers for Styling -- // -- (Helpers for Styling) --
function getIndicatorClass(index: number, qId: number) { function getIndicatorClass(index: number, qId: number) {
// 1. Current = Blue // 1. = (Current = Blue)
if (index === store.currentQuestionIndex) { if (index === store.currentQuestionIndex) {
return 'bg-blue-500 text-white border-blue-600'; return 'bg-blue-500 text-white border-blue-600';
} }
@ -286,7 +295,8 @@ function getIndicatorClass(index: number, qId: number) {
case 'skipped': case 'skipped':
return 'bg-orange-500 text-white border-orange-600'; return 'bg-orange-500 text-white border-orange-600';
case 'in_progress': case 'in_progress':
// If it's in_progress but NOT current (should be rare/impossible with strict logic, but handled) // in_progress ( )
// (If it's in_progress but NOT current (should be rare/impossible with strict logic, but handled))
return 'bg-blue-200 text-blue-800 border-blue-300'; return 'bg-blue-200 text-blue-800 border-blue-300';
case 'not_started': case 'not_started':
default: default:
@ -297,5 +307,5 @@ function getIndicatorClass(index: number, qId: number) {
</script> </script>
<style scoped> <style scoped>
/* Optional: Transitions */ /* ส่วนเสริม: ทรานสิชั่น (Optional: Transitions) */
</style> </style>

View file

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file reset-password.vue * @file reset-password.vue
* @description Reset Password Page. * @description หนาตงรหสผานใหม (Reset Password Page.
* Allows user to set a new password after verifying their email link (simulated). * อนญาตใหใชงรหสผานใหมหลงจากยนยนลงกเมล)
*/ */
definePageMeta({ definePageMeta({
@ -44,9 +44,9 @@ const handlePasswordInput = (field: keyof typeof resetForm, val: string) => {
resetForm[field] = val resetForm[field] = val
if (/[\u0E00-\u0E7F]/.test(val)) { if (/[\u0E00-\u0E7F]/.test(val)) {
if (field === 'password') errors.value.password = 'ห้ามใส่ภาษาไทย' if (field === 'password') errors.value.password = 'ห้ามใส่ภาษาไทย'
// We don't necessarily need to flag confirmPassword individually if it just needs to match, but let's be consistent if we want // confirmPassword (We don't necessarily need to flag confirmPassword individually if it just needs to match, but let's be consistent if we want)
} else { } else {
// Clear error if it was "Thai characters" // "" (Clear error if it was "Thai characters")
if (field === 'password' && errors.value.password === 'ห้ามใส่ภาษาไทย') { if (field === 'password' && errors.value.password === 'ห้ามใส่ภาษาไทย') {
clearFieldError('password') clearFieldError('password')
} }
@ -63,7 +63,7 @@ onMounted(() => {
const resetPassword = async () => { const resetPassword = async () => {
if (!validate(resetForm, resetRules)) return if (!validate(resetForm, resetRules)) return
// Extract token from query // URL query (Extract token from query)
const token = route.query.token as string const token = route.query.token as string
if (!token) { if (!token) {
@ -92,7 +92,7 @@ const resetPassword = async () => {
<template> <template>
<div class="relative min-h-screen w-full flex items-center justify-center p-4 overflow-hidden bg-slate-50 transition-colors"> <div class="relative min-h-screen w-full flex items-center justify-center p-4 overflow-hidden bg-slate-50 transition-colors">
<!-- ========================================== <!-- ==========================================
BACKGROUND EFFECTS (Light Mode Only) เอฟเฟกตนหล (แสดงเฉพาะโหมดสวาง) (BACKGROUND EFFECTS (Light Mode Only))
========================================== --> ========================================== -->
<div class="fixed inset-0 overflow-hidden pointer-events-none -z-10"> <div class="fixed inset-0 overflow-hidden pointer-events-none -z-10">
<div class="absolute inset-0 bg-gradient-to-br from-white via-slate-50 to-blue-50/50"></div> <div class="absolute inset-0 bg-gradient-to-br from-white via-slate-50 to-blue-50/50"></div>
@ -101,11 +101,11 @@ const resetPassword = async () => {
</div> </div>
<!-- ========================================== <!-- ==========================================
RESET PASSWORD CARD การดตงรหสผานใหม (RESET PASSWORD CARD)
========================================== --> ========================================== -->
<div class="w-full max-w-[460px] relative z-10 slide-up"> <div class="w-full max-w-[460px] relative z-10 slide-up">
<!-- Header / Logo --> <!-- วข / โลโก (Header / Logo) -->
<div class="text-center mb-8"> <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"> <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> <span class="font-black text-2xl">E</span>
@ -116,10 +116,10 @@ const resetPassword = async () => {
<div class="bg-white rounded-[2rem] p-8 md:p-10 shadow-xl shadow-slate-200/50 border border-slate-100 relative overflow-hidden"> <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 --> <!-- ฟอร (Form) -->
<form @submit.prevent="resetPassword" class="flex flex-col gap-6"> <form @submit.prevent="resetPassword" class="flex flex-col gap-6">
<!-- New Password --> <!-- รหสผานใหม (New Password) -->
<div> <div>
<label class="block text-sm font-semibold text-slate-700 mb-2 ml-1">รหสผานใหม <span class="text-red-500">*</span></label> <label class="block text-sm font-semibold text-slate-700 mb-2 ml-1">รหสผานใหม <span class="text-red-500">*</span></label>
<div class="relative group"> <div class="relative group">
@ -145,7 +145,7 @@ const resetPassword = async () => {
<span v-if="errors.password" class="text-xs text-red-500 font-medium ml-1 mt-1 block slide-up-sm">{{ errors.password }}</span> <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> </div>
<!-- Confirm Password --> <!-- นยนรหสผานใหม (Confirm Password) -->
<div> <div>
<label class="block text-sm font-semibold text-slate-700 mb-2 ml-1">นยนรหสผานใหม <span class="text-red-500">*</span></label> <label class="block text-sm font-semibold text-slate-700 mb-2 ml-1">นยนรหสผานใหม <span class="text-red-500">*</span></label>
<div class="relative group"> <div class="relative group">
@ -171,7 +171,7 @@ const resetPassword = async () => {
<span v-if="errors.confirmPassword" class="text-xs text-red-500 font-medium ml-1 mt-1 block slide-up-sm">{{ errors.confirmPassword }}</span> <span v-if="errors.confirmPassword" class="text-xs text-red-500 font-medium ml-1 mt-1 block slide-up-sm">{{ errors.confirmPassword }}</span>
</div> </div>
<!-- Submit Button --> <!-- มยนย (Submit Button) -->
<button <button
type="submit" type="submit"
:disabled="isLoading" :disabled="isLoading"
@ -183,7 +183,7 @@ const resetPassword = async () => {
</form> </form>
</div> </div>
<!-- Back Link --> <!-- งกอนกล (Back Link) -->
<div class="mt-8 text-center text-slate-500"> <div class="mt-8 text-center text-slate-500">
<NuxtLink to="/auth/login" 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"> <NuxtLink to="/auth/login" 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> กลบไปหนาเขาสระบบ <span class="group-hover:-translate-x-1 transition-transform"></span> กลบไปหนาเขาสระบบ

View file

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* @file verify-email.vue * @file verify-email.vue
* @description Page for handling email verification process. * @description หนาสำหรบกระบวนการยนยนอเมล (Page for handling email verification process.
* Displays loading state while processing token, then shows success or error message. * แสดงสถานะกำลงโหลดระหวางประมวลผลโทเคน จากนนแสดงขอความสำเรจหรอขอผดพลาด)
*/ */
definePageMeta({ definePageMeta({
@ -28,7 +28,7 @@ onMounted(async () => {
return return
} }
// Call verify API // API (Call verify API)
const result = await verifyEmail(token) const result = await verifyEmail(token)
isLoading.value = false isLoading.value = false
@ -39,7 +39,7 @@ onMounted(async () => {
isSuccess.value = false isSuccess.value = false
if (result.code === 400) { if (result.code === 400) {
errorMessage.value = t('profile.emailAlreadyVerified') errorMessage.value = t('profile.emailAlreadyVerified')
// If already verified, show success state with specific message // (If already verified, show success state with specific message)
isSuccess.value = true isSuccess.value = true
} else if (result.code === 401) { } else if (result.code === 401) {
errorMessage.value = t('auth.tokenExpired') errorMessage.value = t('auth.tokenExpired')
@ -58,7 +58,7 @@ const navigateToHome = () => {
<div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-slate-50 dark:bg-[#0f172a]"> <div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-slate-50 dark:bg-[#0f172a]">
<div class="auth-card max-w-md w-full space-y-8 p-8 rounded-2xl text-center"> <div class="auth-card max-w-md w-full space-y-8 p-8 rounded-2xl text-center">
<!-- Loading State --> <!-- สถานะกำลงโหลด (Loading State) -->
<div v-if="isLoading" class="flex flex-col items-center justify-center py-8"> <div v-if="isLoading" class="flex flex-col items-center justify-center py-8">
<q-spinner-dots size="4rem" color="primary" /> <q-spinner-dots size="4rem" color="primary" />
<h2 class="mt-6 text-xl font-bold text-slate-900 dark:text-white animate-pulse"> <h2 class="mt-6 text-xl font-bold text-slate-900 dark:text-white animate-pulse">
@ -66,7 +66,7 @@ const navigateToHome = () => {
</h2> </h2>
</div> </div>
<!-- Success State --> <!-- สถานะสำเร (Success State) -->
<div v-else-if="isSuccess" class="flex flex-col items-center animate-bounce-in"> <div v-else-if="isSuccess" class="flex flex-col items-center animate-bounce-in">
<div class="w-24 h-24 rounded-full bg-green-500 flex items-center justify-center mb-10 shadow-lg shadow-green-500/20"> <div class="w-24 h-24 rounded-full bg-green-500 flex items-center justify-center mb-10 shadow-lg shadow-green-500/20">
<q-icon name="check" class="text-5xl text-white font-black" /> <q-icon name="check" class="text-5xl text-white font-black" />
@ -89,7 +89,7 @@ const navigateToHome = () => {
/> />
</div> </div>
<!-- Error State --> <!-- สถานะขอผดพลาด (Error State) -->
<div v-else class="flex flex-col items-center animate-shake"> <div v-else class="flex flex-col items-center animate-shake">
<div class="w-24 h-24 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center mb-6"> <div class="w-24 h-24 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center mb-6">
<q-icon name="error" class="text-6xl text-red-500" /> <q-icon name="error" class="text-6xl text-red-500" />
@ -126,11 +126,15 @@ const navigateToHome = () => {
} }
.auth-card { .auth-card {
@apply bg-white border-slate-100 shadow-xl; background-color: white;
border-color: #f1f5f9;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
border-width: 1px; border-width: 1px;
} }
.dark .auth-card { :global(.dark) .auth-card {
@apply bg-[#1e293b] border-white/5 shadow-none; background-color: #1e293b;
border-color: rgba(255, 255, 255, 0.05);
box-shadow: none;
} }
@keyframes bounceIn { @keyframes bounceIn {

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,57 @@
import { defineConfig, devices } from '@playwright/test';
/**
* @file playwright.config.ts
* @description Automated E2E Testing Playwright
*/
export default defineConfig({
// โฟลเดอร์ที่เก็บไฟล์เทส (ชี้ไปที่โฟลเดอร์ปลายทางที่เราสร้าง)
testDir: './tests/e2e',
// รันเทสแบบขนาน (พร้อมๆ กันหลายไฟล์) เพื่อให้เสร็จเร็วขึ้น
fullyParallel: true,
// หากการรันเทสบน CI/CD ล้มเหลว ให้ลองรันซ้ำ 2 ครั้ง
retries: process.env.CI ? 2 : 0,
// จำนวน Worker ที่ใช้รันเทส
workers: process.env.CI ? 1 : undefined,
// รูปแบบการแสดงผลลัพธ์ (Reporter)
reporter: 'html',
use: {
// กำหนดล่วงหน้าว่าเว็บที่เรากำลังจะพุ่งไปหาคือ URL อะไร (พอร์ต 3000 ของ Nuxt)
baseURL: 'http://localhost:3000',
// ตั้งค่าให้เก็บประวัติแบบติดตามผล (Trace) ถ้าระบบพัง จะได้กลับมาดูได้
trace: 'on-first-retry',
// ตั้งค่าเก็บรูปภาพหน้าจอเมื่อพัง (Screenshot on failure)
screenshot: 'only-on-failure'
},
// ตั้งค่าอุปกรณ์ที่ใช้ทดสอบ (Browsers)
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
// หากต้องการเทสบน Firefox หรือ Safari สามารถเปิดคอมเมนต์บรรทัดถัดไปได้เลย
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },
// },
// {
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },
],
// (Optional) หากต้องการให้เปิด Local Server อัตโนมัติก่อนรันเทส สามารถเอาคอมเมนต์ออกได้
// webServer: {
// command: 'npm run dev',
// url: 'http://localhost:3000',
// reuseExistingServer: !process.env.CI,
// },
});

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