feat: add API course, lesson, section
This commit is contained in:
Generated
+309
-2
@@ -14,6 +14,7 @@
|
||||
"@radix-ui/react-slot": "^1.3.3",
|
||||
"@radix-ui/react-switch": "^1.3.7",
|
||||
"@radix-ui/react-tabs": "^1.1.21",
|
||||
"axios": "^1.20.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.2.8",
|
||||
@@ -1934,6 +1935,18 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
@@ -1951,6 +1964,24 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.20.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
|
||||
"integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.6",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
@@ -2021,6 +2052,19 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001809",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
|
||||
@@ -2063,6 +2107,18 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -2109,7 +2165,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -2130,6 +2185,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -2140,6 +2204,20 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.405",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz",
|
||||
@@ -2161,6 +2239,51 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -2459,6 +2582,42 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -2474,6 +2633,15 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@@ -2484,6 +2652,43 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@@ -2510,6 +2715,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@@ -2517,6 +2734,45 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hermes-estree": {
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||
@@ -2534,6 +2790,19 @@
|
||||
"hermes-estree": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -2981,6 +3250,36 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||
@@ -3001,7 +3300,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
@@ -3169,6 +3467,15 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@radix-ui/react-slot": "^1.3.3",
|
||||
"@radix-ui/react-switch": "^1.3.7",
|
||||
"@radix-ui/react-tabs": "^1.1.21",
|
||||
"axios": "^1.20.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.2.8",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
import MainLayout from './layouts/MainLayout.jsx';
|
||||
import CoursesPage from './pages/Course/CoursesPage.jsx';
|
||||
import CourseDetail from './pages/Course/CourseDetails.jsx';
|
||||
import CourseFormPage from './pages/Course/CourseFormPage.jsx';
|
||||
import LessonPage from './pages/lesson/LessonPage.jsx';
|
||||
import LessonFormPage from './pages/lesson/LessonFormPage.jsx';
|
||||
import MCQPage from './pages/question-bank/MCQPage.jsx';
|
||||
@@ -20,7 +21,9 @@ function App() {
|
||||
<Route element={<MainLayout />}>
|
||||
<Route index element={<Navigate to="/courses" replace />} />
|
||||
<Route path="/courses" element={<CoursesPage />} />
|
||||
<Route path="/courses/new" element={<CourseFormPage />} />
|
||||
<Route path="/courses/:courseId" element={<CourseDetail />} />
|
||||
<Route path="/courses/:courseId/edit" element={<CourseFormPage />} />
|
||||
<Route path="/courses/:courseId/lessons/new" element={<LessonFormPage />} />
|
||||
<Route path="/courses/:courseId/lessons/:lessonId/edit" element={<LessonFormPage />} />
|
||||
<Route path="/lessons" element={<LessonPage />} />
|
||||
|
||||
@@ -7,7 +7,7 @@ const statusStyles = {
|
||||
Inactive: 'bg-app-neutral-badge text-app-neutral-badge-text'
|
||||
};
|
||||
|
||||
function CourseCard({ course, image, handleClick }) {
|
||||
function CourseCard({ course, image, handleClick, onEdit }) {
|
||||
const { code, title, description, status, lastUpdate } = course;
|
||||
|
||||
const formattedDate = new Date(lastUpdate).toLocaleDateString('en-GB', {
|
||||
@@ -65,7 +65,7 @@ function CourseCard({ course, image, handleClick }) {
|
||||
size="sm"
|
||||
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
|
||||
aria-label={`Edit ${title}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); onEdit && onEdit(); }}
|
||||
>
|
||||
<HiOutlinePencil className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
const message = error.response?.data?.message || error.message || 'Something went wrong';
|
||||
console.error('API Error:', message);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
+129
-121
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
@@ -23,116 +23,9 @@ import {
|
||||
HiOutlineArrowUpTray,
|
||||
HiOutlineFunnel,
|
||||
} from 'react-icons/hi2';
|
||||
|
||||
const coursesData = [
|
||||
{
|
||||
code: 'LDR-101',
|
||||
title: 'Foundations of Modern Leadership',
|
||||
description:
|
||||
'An introductory course covering essential leadership principles for new managers.',
|
||||
status: 'Active',
|
||||
lastUpdate: '2026-08-19',
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1556761175-b413da4baf72?auto=format&fit=crop&w=800&q=80',
|
||||
},
|
||||
{
|
||||
code: 'TEC-204',
|
||||
title: 'Advanced Cloud Architecture Patterns',
|
||||
description:
|
||||
'Deep dive into scalable cloud infrastructure design and implementation strategies.',
|
||||
status: 'Inactive',
|
||||
lastUpdate: '2026-08-19',
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1533090161767-e6ffed986c88?auto=format&fit=crop&w=800&q=80',
|
||||
},
|
||||
{
|
||||
code: 'CMP-099',
|
||||
title: '2022 Annual Compliance Review',
|
||||
description: 'Outdated compliance materials from the previous fiscal year.',
|
||||
status: 'Inactive',
|
||||
lastUpdate: '2026-08-19',
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1497366754035-f200968a6e72?auto=format&fit=crop&w=800&q=80',
|
||||
},
|
||||
];
|
||||
|
||||
const lessonsByCourse = {
|
||||
'LDR-101': [
|
||||
{
|
||||
id: 'les_001',
|
||||
title: '1.1 What is Leadership?',
|
||||
position: 1,
|
||||
type: 'video',
|
||||
duration: '12:30',
|
||||
status: 'Published',
|
||||
isFreePreview: true,
|
||||
updatedAt: '2026-08-15',
|
||||
},
|
||||
{
|
||||
id: 'les_002',
|
||||
title: '1.2 Leadership Styles Overview',
|
||||
position: 2,
|
||||
type: 'text',
|
||||
duration: '~8 min read',
|
||||
status: 'Published',
|
||||
isFreePreview: false,
|
||||
updatedAt: '2026-08-16',
|
||||
},
|
||||
{
|
||||
id: 'les_003',
|
||||
title: '1.3 Self-Assessment Quiz',
|
||||
position: 3,
|
||||
type: 'quiz',
|
||||
duration: '10:00',
|
||||
status: 'Draft',
|
||||
isFreePreview: false,
|
||||
updatedAt: '2026-08-17',
|
||||
},
|
||||
{
|
||||
id: 'les_004',
|
||||
title: '2.1 Communication Fundamentals',
|
||||
position: 4,
|
||||
type: 'video',
|
||||
duration: '15:20',
|
||||
status: 'Published',
|
||||
isFreePreview: false,
|
||||
updatedAt: '2026-08-18',
|
||||
},
|
||||
{
|
||||
id: 'les_005',
|
||||
title: '2.2 Active Listening Techniques',
|
||||
position: 5,
|
||||
type: 'text',
|
||||
duration: '~6 min read',
|
||||
status: 'Draft',
|
||||
isFreePreview: false,
|
||||
updatedAt: '2026-08-19',
|
||||
},
|
||||
],
|
||||
'TEC-204': [
|
||||
{
|
||||
id: 'les_006',
|
||||
title: '1.1 Cloud Fundamentals Recap',
|
||||
position: 1,
|
||||
type: 'video',
|
||||
duration: '18:45',
|
||||
status: 'Published',
|
||||
isFreePreview: true,
|
||||
updatedAt: '2026-08-10',
|
||||
},
|
||||
{
|
||||
id: 'les_007',
|
||||
title: '1.2 Microservices Architecture',
|
||||
position: 2,
|
||||
type: 'embed',
|
||||
duration: 'Figma iFrame',
|
||||
status: 'Draft',
|
||||
isFreePreview: false,
|
||||
updatedAt: '2026-08-12',
|
||||
},
|
||||
],
|
||||
'CMP-099': [],
|
||||
};
|
||||
import courseService from '@/services/courseService';
|
||||
import lessonService from '@/services/lessonService';
|
||||
import sectionService from '@/services/sectionService';
|
||||
|
||||
const typeConfig = {
|
||||
video: { label: 'Video', icon: HiOutlineVideoCamera, color: 'bg-blue-100 text-blue-700' },
|
||||
@@ -146,20 +39,120 @@ const statusStyles = {
|
||||
Draft: 'bg-app-neutral-badge text-app-neutral-badge-text',
|
||||
};
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (!seconds) return '00:00';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function mapCourse(c) {
|
||||
return {
|
||||
id: c.id,
|
||||
code: c.courseCode,
|
||||
title: c.title,
|
||||
description: c.description,
|
||||
status: c.isPublished ? 'Active' : 'Inactive',
|
||||
lastUpdate: c.updatedAt,
|
||||
image: c.image,
|
||||
isPublished: c.isPublished,
|
||||
};
|
||||
}
|
||||
|
||||
function CourseDetail() {
|
||||
const { courseId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const course = coursesData.find((e) => e.code === courseId);
|
||||
const lessons = lessonsByCourse[courseId] || [];
|
||||
const [course, setCourse] = useState(null);
|
||||
const [lessons, setLessons] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
|
||||
if (!course) {
|
||||
const handleTogglePublish = async () => {
|
||||
try {
|
||||
await courseService.update(courseId, {
|
||||
courseCode: course.code,
|
||||
title: course.title,
|
||||
description: course.description,
|
||||
image: course.image,
|
||||
isPublished: !course.isPublished,
|
||||
});
|
||||
setCourse((prev) => ({ ...prev, isPublished: !prev.isPublished, status: prev.isPublished ? 'Inactive' : 'Active' }));
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to update course');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLesson = async (lessonId) => {
|
||||
if (!window.confirm('Are you sure you want to delete this lesson?')) return;
|
||||
try {
|
||||
await lessonService.delete(lessonId);
|
||||
setLessons((prev) => prev.filter((l) => l.id !== lessonId));
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to delete lesson');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const courseData = await courseService.getById(courseId);
|
||||
setCourse(mapCourse(courseData));
|
||||
|
||||
const allLessons = await lessonService.getAll();
|
||||
const courseLessons = allLessons.filter((l) => l.courseId === Number(courseId));
|
||||
|
||||
const lessonsWithSections = await Promise.all(
|
||||
courseLessons.map(async (lesson) => {
|
||||
const sections = await sectionService.getByLessonId(lesson.id);
|
||||
const firstSection = sections[0];
|
||||
return {
|
||||
id: lesson.id,
|
||||
title: lesson.title,
|
||||
position: lesson.position,
|
||||
type: firstSection?.type || 'text',
|
||||
duration: firstSection?.type === 'video'
|
||||
? formatDuration(firstSection.durationSeconds)
|
||||
: firstSection?.type === 'text'
|
||||
? `~${Math.max(1, Math.ceil((firstSection.content || '').split(/\s+/).length / 200))} min read`
|
||||
: firstSection?.type === 'quiz'
|
||||
? `${firstSection.durationSeconds ? Math.floor(firstSection.durationSeconds / 60) : 10}:00`
|
||||
: firstSection?.type === 'embed'
|
||||
? 'iFrame'
|
||||
: '00:00',
|
||||
status: lesson.isPublished ? 'Published' : 'Draft',
|
||||
isFreePreview: firstSection?.isFreePreview || false,
|
||||
updatedAt: lesson.updatedAt,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
setLessons(lessonsWithSections);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load course');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [courseId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader />
|
||||
<div className="flex items-center justify-center py-20 text-app-text-muted">Loading course...</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !course) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader />
|
||||
<div className="flex flex-col items-center justify-center py-20 text-app-text-muted">
|
||||
<HiOutlineBookOpen className="w-16 h-16 mb-4 opacity-30" />
|
||||
<p className="text-lg font-medium">Course not found</p>
|
||||
<p className="text-lg font-medium">{error || 'Course not found'}</p>
|
||||
<Button
|
||||
variant="link"
|
||||
className="mt-2 text-app-primary"
|
||||
@@ -210,12 +203,24 @@ function CourseDetail() {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
|
||||
onClick={() => navigate(`/courses/${courseId}/edit`)}
|
||||
>
|
||||
<HiOutlinePencil className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
|
||||
onClick={handleTogglePublish}
|
||||
>
|
||||
<HiOutlineArrowUpTray className="w-4 h-4 mr-2" />
|
||||
Inactivate
|
||||
{course.isPublished ? 'Inactivate' : 'Activate'}
|
||||
</Button>
|
||||
<Button className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark">
|
||||
Publish
|
||||
<Button
|
||||
className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark"
|
||||
onClick={handleTogglePublish}
|
||||
>
|
||||
{course.isPublished ? 'Unpublish' : 'Publish'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,7 +288,9 @@ function CourseDetail() {
|
||||
<div className="flex items-center gap-6 text-sm text-app-text-muted">
|
||||
<span>
|
||||
Last updated:{' '}
|
||||
<span className="font-medium text-app-text">{course.lastUpdate}</span>
|
||||
<span className="font-medium text-app-text">
|
||||
{course.lastUpdate ? new Date(course.lastUpdate).toLocaleDateString('en-GB') : '-'}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
Total lessons:{' '}
|
||||
@@ -368,7 +375,7 @@ function CourseDetail() {
|
||||
|
||||
{/* Table Rows */}
|
||||
{lessons.map((lesson) => {
|
||||
const tc = typeConfig[lesson.type];
|
||||
const tc = typeConfig[lesson.type] || typeConfig.text;
|
||||
const TypeIcon = tc.icon;
|
||||
return (
|
||||
<div
|
||||
@@ -413,7 +420,7 @@ function CourseDetail() {
|
||||
<div className="col-span-1 flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() =>
|
||||
navigate(`/courses/${courseId}/lessons/new`)
|
||||
navigate(`/courses/${courseId}/lessons/${lesson.id}/edit`)
|
||||
}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-app-text-muted hover:text-app-primary hover:bg-app-primary-soft transition-colors"
|
||||
title="Edit"
|
||||
@@ -427,6 +434,7 @@ function CourseDetail() {
|
||||
<HiOutlineEye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteLesson(lesson.id)}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-app-text-muted hover:text-red-500 hover:bg-red-50 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { Textarea } from '../../components/ui/textarea';
|
||||
import { Switch } from '../../components/ui/switch';
|
||||
import { Separator } from '../../components/ui/separator';
|
||||
import {
|
||||
HiOutlineArrowLeft,
|
||||
HiOutlineBookOpen,
|
||||
HiOutlineChevronRight,
|
||||
} from 'react-icons/hi2';
|
||||
import courseService from '@/services/courseService';
|
||||
|
||||
function CourseFormPage() {
|
||||
const { courseId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const isEditing = Boolean(courseId);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
courseCode: '',
|
||||
title: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
image: '',
|
||||
isPublished: false,
|
||||
});
|
||||
const [loading, setLoading] = useState(isEditing);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) return;
|
||||
const fetchCourse = async () => {
|
||||
try {
|
||||
const data = await courseService.getById(courseId);
|
||||
setForm({
|
||||
courseCode: data.courseCode || '',
|
||||
title: data.title || '',
|
||||
slug: data.slug || '',
|
||||
description: data.description || '',
|
||||
image: data.image || '',
|
||||
isPublished: data.isPublished || false,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load course');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchCourse();
|
||||
}, [courseId, isEditing]);
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const generateSlug = () => {
|
||||
const slug = form.title
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/đ/g, 'd')
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.trim();
|
||||
handleChange('slug', slug);
|
||||
};
|
||||
|
||||
const handleSave = async (publish) => {
|
||||
if (!form.courseCode.trim() || !form.title.trim()) {
|
||||
setError('Course code and title are required');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payload = { ...form, isPublished: publish };
|
||||
if (isEditing) {
|
||||
await courseService.update(courseId, payload);
|
||||
} else {
|
||||
const created = await courseService.create(payload);
|
||||
navigate(`/courses/${created.id}`);
|
||||
return;
|
||||
}
|
||||
navigate(`/courses/${courseId}`);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to save course');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader />
|
||||
<div className="flex items-center justify-center py-20 text-app-text-muted">Loading course...</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader />
|
||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-app-text-muted mb-4">
|
||||
<button
|
||||
onClick={() => navigate('/courses')}
|
||||
className="hover:text-app-primary transition-colors flex items-center gap-1"
|
||||
>
|
||||
<HiOutlineBookOpen className="w-4 h-4" />
|
||||
Courses
|
||||
</button>
|
||||
<HiOutlineChevronRight className="w-3.5 h-3.5" />
|
||||
<span className="font-medium text-app-text">
|
||||
{isEditing ? 'Edit Course' : 'New Course'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-row items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-bold text-app-text">
|
||||
{isEditing ? 'Edit Course' : 'Create New Course'}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
|
||||
onClick={() => navigate(isEditing ? `/courses/${courseId}` : '/courses')}
|
||||
disabled={saving}
|
||||
>
|
||||
<HiOutlineArrowLeft className="w-4 h-4 mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
|
||||
onClick={() => handleSave(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Draft'}
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark"
|
||||
onClick={() => handleSave(true)}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving...' : isEditing ? 'Update & Publish' : 'Save & Publish'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="mb-6" />
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-xl bg-red-50 border border-red-200 text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card
|
||||
className="rounded-xl border border-app-border bg-white overflow-hidden"
|
||||
style={{ boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px' }}
|
||||
>
|
||||
<div className="p-5 border-b border-app-border">
|
||||
<h4 className="font-semibold text-app-text">Course Details</h4>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
|
||||
Course Code <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="e.g. LDR-101"
|
||||
value={form.courseCode}
|
||||
onChange={(e) => handleChange('courseCode', e.target.value)}
|
||||
className="rounded-xl bg-app-surface-muted border-0 focus:bg-white focus:ring-2 focus:ring-app-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
|
||||
Title <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="Course title"
|
||||
value={form.title}
|
||||
onChange={(e) => handleChange('title', e.target.value)}
|
||||
className="rounded-xl bg-app-surface-muted border-0 focus:bg-white focus:ring-2 focus:ring-app-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<Label className="block text-app-text text-xs font-semibold uppercase tracking-wider">
|
||||
Slug
|
||||
</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={generateSlug}
|
||||
className="text-app-primary hover:underline text-xs font-medium"
|
||||
>
|
||||
Auto-generate from title
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex rounded-xl overflow-hidden bg-app-surface-muted">
|
||||
<span className="px-4 py-2 text-app-text-muted font-mono text-sm bg-app-border/30 flex items-center select-none">
|
||||
/courses/
|
||||
</span>
|
||||
<Input
|
||||
value={form.slug}
|
||||
onChange={(e) => handleChange('slug', e.target.value)}
|
||||
className="border-0 bg-transparent font-mono focus:ring-0 focus:bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
|
||||
Description
|
||||
</Label>
|
||||
<Textarea
|
||||
placeholder="Course description..."
|
||||
rows={4}
|
||||
value={form.description}
|
||||
onChange={(e) => handleChange('description', e.target.value)}
|
||||
className="rounded-xl bg-app-surface-muted border-0 focus:bg-white focus:ring-2 focus:ring-app-primary resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
|
||||
Image URL
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="https://..."
|
||||
value={form.image}
|
||||
onChange={(e) => handleChange('image', e.target.value)}
|
||||
className="rounded-xl bg-app-surface-muted border-0 focus:bg-white focus:ring-2 focus:ring-app-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-app-surface-muted">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-sm text-app-text">Published</span>
|
||||
<span className="text-xs text-app-text-muted">Make this course visible to students</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.isPublished}
|
||||
onCheckedChange={(checked) => handleChange('isPublished', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default CourseFormPage;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import CourseCard from '../../components/CourseCard.jsx';
|
||||
import PageHeader from '../../components/PageHeader.jsx';
|
||||
import { Button } from '../../components/ui/button.jsx';
|
||||
@@ -6,45 +7,58 @@ import { Input } from '../../components/ui/input.jsx';
|
||||
import { Separator } from '../../components/ui/separator.jsx';
|
||||
import { HiOutlineFunnel, HiOutlinePlus, HiOutlineMagnifyingGlass } from 'react-icons/hi2';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import courseService from '@/services/courseService';
|
||||
|
||||
const stats = [
|
||||
{
|
||||
code: 'LDR-101',
|
||||
title: 'Foundations of Modern Leadership',
|
||||
description:
|
||||
'An introductory course covering essential leadership principles for new managers.',
|
||||
status: 'Active',
|
||||
lastUpdate: '2026-08-19',
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1556761175-b413da4baf72?auto=format&fit=crop&w=800&q=80'
|
||||
},
|
||||
{
|
||||
code: 'TEC-204',
|
||||
title: 'Advanced Cloud Architecture Patterns',
|
||||
description:
|
||||
'Deep dive into scalable cloud infrastructure design and implementation strategies.',
|
||||
status: 'Inactive',
|
||||
lastUpdate: '2026-08-19',
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1533090161767-e6ffed986c88?auto=format&fit=crop&w=800&q=80'
|
||||
},
|
||||
{
|
||||
code: 'CMP-099',
|
||||
title: '2022 Annual Compliance Review',
|
||||
description: 'Outdated compliance materials from the previous fiscal year.',
|
||||
status: 'Inactive',
|
||||
lastUpdate: '2026-08-19',
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1497366754035-f200968a6e72?auto=format&fit=crop&w=800&q=80'
|
||||
function mapCourse(c) {
|
||||
return {
|
||||
id: c.id,
|
||||
code: c.courseCode,
|
||||
title: c.title,
|
||||
description: c.description,
|
||||
status: c.isPublished ? 'Active' : 'Inactive',
|
||||
lastUpdate: c.updatedAt,
|
||||
image: c.image,
|
||||
};
|
||||
}
|
||||
];
|
||||
|
||||
function CoursePage() {
|
||||
const navigate = useNavigate();
|
||||
const [courses, setCourses] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCourses = async () => {
|
||||
try {
|
||||
const data = await courseService.getAll();
|
||||
setCourses(data.map(mapCourse));
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load courses');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchCourses();
|
||||
}, []);
|
||||
|
||||
const filtered = courses.filter((c) => {
|
||||
const matchTab =
|
||||
activeTab === 'all' ||
|
||||
(activeTab === 'active' && c.status === 'Active') ||
|
||||
(activeTab === 'inactive' && c.status === 'Inactive');
|
||||
const matchSearch =
|
||||
!search ||
|
||||
c.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.code.toLowerCase().includes(search.toLowerCase());
|
||||
return matchTab && matchSearch;
|
||||
});
|
||||
|
||||
const handleClick = (course) => {
|
||||
navigate(`/courses/${course.code}`);
|
||||
navigate(`/courses/${course.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader />
|
||||
@@ -65,7 +79,10 @@ function CoursePage() {
|
||||
<HiOutlineFunnel className="mr-2 h-5 w-5" />
|
||||
Filter
|
||||
</Button>
|
||||
<Button className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark">
|
||||
<Button
|
||||
className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark"
|
||||
onClick={() => navigate('/courses/new')}
|
||||
>
|
||||
<HiOutlinePlus className="mr-2 h-5 w-5" />
|
||||
New Course
|
||||
</Button>
|
||||
@@ -73,7 +90,7 @@ function CoursePage() {
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<div className="my-3 flex flex-row items-center justify-between rounded-xl border border-app-border bg-white p-3">
|
||||
<Tabs defaultValue="all" className="w-fit">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-fit">
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">All</TabsTrigger>
|
||||
<TabsTrigger value="active">Active</TabsTrigger>
|
||||
@@ -85,19 +102,36 @@ function CoursePage() {
|
||||
<Input
|
||||
placeholder="Search course by title or code..."
|
||||
className="pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20 text-app-text-muted">Loading courses...</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-app-text-muted">
|
||||
<p className="text-red-500 mb-2">{error}</p>
|
||||
<Button variant="link" className="text-app-primary" onClick={() => window.location.reload()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-4">
|
||||
{stats.map((course) => (
|
||||
{filtered.map((course) => (
|
||||
<CourseCard
|
||||
key={course.code}
|
||||
key={course.id}
|
||||
course={course}
|
||||
image={course.image}
|
||||
handleClick={() => handleClick(course)}
|
||||
onEdit={() => navigate(`/courses/${course.id}/edit`)}
|
||||
/>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="col-span-full text-center py-20 text-app-text-muted">No courses found.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,60 +1,183 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import ActionBar from './components/ActionBar';
|
||||
import LessonMetadataForm from './components/LessonMetadataForm';
|
||||
import SectionManager from './components/SectionManager';
|
||||
import LessonSidebar from './components/LessonSidebar';
|
||||
import lessonService from '@/services/lessonService';
|
||||
import sectionService from '@/services/sectionService';
|
||||
|
||||
const initialMetadata = {
|
||||
const emptyMetadata = {
|
||||
title: '',
|
||||
position: 1,
|
||||
slug: '',
|
||||
description: '',
|
||||
};
|
||||
|
||||
const initialSections = [
|
||||
{
|
||||
id: 'section_1',
|
||||
type: 'video',
|
||||
title: 'Introduction to Grid 8pt System',
|
||||
url: 'https://player.vimeo.com/video/847291039',
|
||||
duration: '08:45',
|
||||
function mapSectionFromBE(s) {
|
||||
const base = {
|
||||
id: `section_${s.id}`,
|
||||
serverId: s.id,
|
||||
type: s.type,
|
||||
title: s.title,
|
||||
isFreePreview: s.isFreePreview,
|
||||
position: s.position,
|
||||
};
|
||||
|
||||
switch (s.type) {
|
||||
case 'video':
|
||||
return {
|
||||
...base,
|
||||
url: s.content || '',
|
||||
duration: s.durationSeconds
|
||||
? `${String(Math.floor(s.durationSeconds / 60)).padStart(2, '0')}:${String(s.durationSeconds % 60).padStart(2, '0')}`
|
||||
: '',
|
||||
thumbnail: '',
|
||||
isFreePreview: true,
|
||||
},
|
||||
{
|
||||
id: 'section_2',
|
||||
type: 'text',
|
||||
title: 'Breakpoint Standards & Responsive Guide',
|
||||
content:
|
||||
'## 1. Standard Breakpoints\n\nIn modern responsive design, the grid must follow the soft 8-pt grid structure.\n\n```\n$screens: ( sm: 640px, md: 768px, lg: 1024px, xl: 1280px, 2xl: 1536px );\n```\n\nAll spacing (gutter, margin) must be a multiple of 8: 8px, 16px, 24px, 32px...',
|
||||
isFreePreview: false,
|
||||
},
|
||||
{
|
||||
id: 'section_3',
|
||||
type: 'quiz',
|
||||
title: 'Knowledge Check: Breakpoints & Fluid Grids',
|
||||
questionCount: 5,
|
||||
timeLimit: 10,
|
||||
passScore: 80,
|
||||
maxAttempts: 3,
|
||||
isFreePreview: false,
|
||||
},
|
||||
{
|
||||
id: 'section_4',
|
||||
type: 'embed',
|
||||
title: 'Figma Prototype Interaction',
|
||||
embedUrl: 'https://www.figma.com/embed?embed_host=share&url=https://www.figma.com/file/rN7X3d/Responsive-Design-Library',
|
||||
isFreePreview: false,
|
||||
},
|
||||
];
|
||||
};
|
||||
case 'text':
|
||||
return { ...base, content: s.content || '' };
|
||||
case 'quiz': {
|
||||
let parsed = {};
|
||||
try { parsed = JSON.parse(s.content || '{}'); } catch { /* ignore */ }
|
||||
return {
|
||||
...base,
|
||||
questionCount: parsed.questionCount || 5,
|
||||
timeLimit: parsed.timeLimit || 10,
|
||||
passScore: parsed.passScore || 80,
|
||||
maxAttempts: parsed.maxAttempts || 3,
|
||||
};
|
||||
}
|
||||
case 'embed':
|
||||
return { ...base, embedUrl: s.content || '' };
|
||||
default:
|
||||
return { ...base, content: s.content || '' };
|
||||
}
|
||||
}
|
||||
|
||||
function mapSectionToBE(section, lessonId) {
|
||||
const base = {
|
||||
lessonId,
|
||||
title: section.title,
|
||||
type: section.type,
|
||||
position: section.position,
|
||||
isFreePreview: section.isFreePreview,
|
||||
};
|
||||
|
||||
switch (section.type) {
|
||||
case 'video': {
|
||||
const parts = (section.duration || '00:00').split(':').map(Number);
|
||||
return {
|
||||
...base,
|
||||
content: section.url || '',
|
||||
durationSeconds: (parts[0] || 0) * 60 + (parts[1] || 0),
|
||||
};
|
||||
}
|
||||
case 'text':
|
||||
return { ...base, content: section.content || '' };
|
||||
case 'quiz':
|
||||
return {
|
||||
...base,
|
||||
content: JSON.stringify({
|
||||
questionCount: section.questionCount,
|
||||
timeLimit: section.timeLimit,
|
||||
passScore: section.passScore,
|
||||
maxAttempts: section.maxAttempts,
|
||||
}),
|
||||
durationSeconds: (section.timeLimit || 10) * 60,
|
||||
};
|
||||
case 'embed':
|
||||
return { ...base, content: section.embedUrl || '' };
|
||||
default:
|
||||
return { ...base, content: section.content || '' };
|
||||
}
|
||||
}
|
||||
|
||||
function LessonFormPage() {
|
||||
const { courseId } = useParams();
|
||||
const [metadata, setMetadata] = useState(initialMetadata);
|
||||
const [sections, setSections] = useState(initialSections);
|
||||
const { courseId, lessonId } = useParams();
|
||||
const isEditing = Boolean(lessonId);
|
||||
|
||||
const [metadata, setMetadata] = useState(emptyMetadata);
|
||||
const [sections, setSections] = useState([]);
|
||||
const [isPublished, setIsPublished] = useState(false);
|
||||
const [loading, setLoading] = useState(isEditing);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) return;
|
||||
|
||||
const fetchLesson = async () => {
|
||||
try {
|
||||
const lesson = await lessonService.getById(lessonId);
|
||||
setMetadata({
|
||||
title: lesson.title,
|
||||
position: lesson.position,
|
||||
slug: lesson.slug || '',
|
||||
description: lesson.description || '',
|
||||
});
|
||||
setIsPublished(lesson.isPublished);
|
||||
|
||||
const sectionData = await sectionService.getByLessonId(lessonId);
|
||||
setSections(sectionData.sort((a, b) => a.position - b.position).map(mapSectionFromBE));
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load lesson');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchLesson();
|
||||
}, [lessonId, isEditing]);
|
||||
|
||||
const handleSave = async (publish) => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const lessonPayload = {
|
||||
courseId: Number(courseId),
|
||||
title: metadata.title,
|
||||
slug: metadata.slug,
|
||||
description: metadata.description,
|
||||
position: Number(metadata.position) || 1,
|
||||
isPublished: publish,
|
||||
};
|
||||
|
||||
let savedLesson;
|
||||
if (isEditing) {
|
||||
savedLesson = await lessonService.update(lessonId, lessonPayload);
|
||||
} else {
|
||||
savedLesson = await lessonService.create(lessonPayload);
|
||||
}
|
||||
|
||||
const savedLessonId = savedLesson.id;
|
||||
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
const section = sections[i];
|
||||
const sectionPayload = mapSectionToBE({ ...section, position: i + 1 }, savedLessonId);
|
||||
|
||||
if (section.serverId) {
|
||||
await sectionService.update(section.serverId, sectionPayload);
|
||||
} else {
|
||||
await sectionService.create(sectionPayload);
|
||||
}
|
||||
}
|
||||
|
||||
alert(isEditing ? 'Lesson updated successfully!' : 'Lesson created successfully!');
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to save lesson');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader />
|
||||
<div className="flex items-center justify-center py-20 text-app-text-muted">Loading lesson...</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -64,9 +187,17 @@ function LessonFormPage() {
|
||||
<ActionBar
|
||||
courseId={courseId}
|
||||
isPublished={isPublished}
|
||||
onTogglePublish={() => setIsPublished(!isPublished)}
|
||||
onTogglePublish={() => handleSave(true)}
|
||||
onSaveDraft={() => handleSave(false)}
|
||||
saving={saving}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-xl bg-red-50 border border-red-200 text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
|
||||
<div className="lg:col-span-8 flex flex-col gap-8">
|
||||
<LessonMetadataForm metadata={metadata} onChange={setMetadata} />
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import { DataTable } from '../../components/ui/data-table';
|
||||
import lessonService from '@/services/lessonService';
|
||||
|
||||
const mockLessons = [
|
||||
{ id: 1, code: 'LES001', name: 'Introduction to Programming', description: 'Basic concepts of programming', createdBy: 'Admin', createdAt: '2026-08-01' },
|
||||
{ id: 2, code: 'LES002', name: 'Data Types and Variables', description: 'Understanding data types', createdBy: 'Admin', createdAt: '2026-08-03' },
|
||||
{ id: 3, code: 'LES003', name: 'Control Flow', description: 'If/else and loops', createdBy: 'Teacher1', createdAt: '2026-08-05' },
|
||||
{ id: 4, code: 'LES004', name: 'Functions', description: 'Function declaration and usage', createdBy: 'Teacher1', createdAt: '2026-08-07' },
|
||||
{ id: 5, code: 'LES005', name: 'Arrays', description: 'Working with arrays', createdBy: 'Admin', createdAt: '2026-08-10' },
|
||||
{ id: 6, code: 'LES006', name: 'Introduction to Programming', description: 'Basic concepts of programming', createdBy: 'Admin', createdAt: '2026-08-01' },
|
||||
{ id: 7, code: 'LES007', name: 'Data Types and Variables', description: 'Understanding data types', createdBy: 'Admin', createdAt: '2026-08-03' },
|
||||
{ id: 8, code: 'LES008', name: 'Control Flow', description: 'If/else and loops', createdBy: 'Teacher1', createdAt: '2026-08-05' },
|
||||
{ id: 9, code: 'LES009', name: 'Functions', description: 'Function declaration and usage', createdBy: 'Teacher1', createdAt: '2026-08-07' },
|
||||
{ id: 10, code: 'LES010', name: 'Arrays', description: 'Working with arrays', createdBy: 'Admin', createdAt: '2026-08-10' },
|
||||
];
|
||||
function mapLesson(l) {
|
||||
return {
|
||||
id: l.id,
|
||||
code: `LES${String(l.id).padStart(3, '0')}`,
|
||||
name: l.title,
|
||||
description: l.description || '',
|
||||
courseId: l.courseId,
|
||||
position: l.position,
|
||||
isPublished: l.isPublished,
|
||||
createdAt: l.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
const actionBtn = {
|
||||
padding: '6px 12px',
|
||||
@@ -39,6 +42,36 @@ const actionBtnDelete = {
|
||||
transition: 'background 160ms ease, color 160ms ease',
|
||||
};
|
||||
|
||||
function LessonPage() {
|
||||
const navigate = useNavigate();
|
||||
const [lessons, setLessons] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLessons = async () => {
|
||||
try {
|
||||
const data = await lessonService.getAll();
|
||||
setLessons(data.map(mapLesson));
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load lessons');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchLessons();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!window.confirm('Are you sure you want to delete this lesson?')) return;
|
||||
try {
|
||||
await lessonService.delete(id);
|
||||
setLessons((prev) => prev.filter((l) => l.id !== id));
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to delete lesson');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ key: '#', label: '#', render: (_row, idx) => idx },
|
||||
{
|
||||
@@ -48,16 +81,37 @@ const columns = [
|
||||
},
|
||||
{ key: 'name', label: 'LessonName', tdStyle: { fontWeight: 500 } },
|
||||
{ key: 'description', label: 'Description', tdStyle: { color: '#686b82' } },
|
||||
{ key: 'createdBy', label: 'Created By' },
|
||||
{ key: 'createdAt', label: 'Created At', tdStyle: { color: '#9497a9', fontSize: 13 } },
|
||||
{
|
||||
key: 'isPublished',
|
||||
label: 'Status',
|
||||
render: (row) => (
|
||||
<span style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
background: row.isPublished ? 'rgba(20,158,97,0.12)' : 'rgba(148,151,169,0.12)',
|
||||
color: row.isPublished ? '#026b3f' : '#686b82',
|
||||
}}>
|
||||
{row.isPublished ? 'Published' : 'Draft'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: 'Created At',
|
||||
render: (row) => row.createdAt ? new Date(row.createdAt).toLocaleDateString('en-GB') : '-',
|
||||
tdStyle: { color: '#9497a9', fontSize: 13 },
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'Action',
|
||||
render: () => (
|
||||
render: (row) => (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
style={actionBtn}
|
||||
onClick={() => navigate(`/courses/${row.courseId}/lessons/${row.id}/edit`)}
|
||||
onMouseEnter={(e) => { e.target.style.background = '#7132f5'; e.target.style.color = '#ffffff'; }}
|
||||
onMouseLeave={(e) => { e.target.style.background = '#ffffff'; e.target.style.color = '#5741d8'; }}
|
||||
>
|
||||
@@ -66,6 +120,7 @@ const columns = [
|
||||
<button
|
||||
type="button"
|
||||
style={actionBtnDelete}
|
||||
onClick={() => handleDelete(row.id)}
|
||||
onMouseEnter={(e) => { e.target.style.background = 'rgba(20,158,97,0.16)'; e.target.style.color = '#026b3f'; e.target.style.borderColor = '#149e61'; }}
|
||||
onMouseLeave={(e) => { e.target.style.background = 'transparent'; e.target.style.color = '#9497a9'; e.target.style.borderColor = '#dedee5'; }}
|
||||
>
|
||||
@@ -76,7 +131,6 @@ const columns = [
|
||||
},
|
||||
];
|
||||
|
||||
function LessonPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader />
|
||||
@@ -95,18 +149,32 @@ function LessonPage() {
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
onClick={() => {
|
||||
const firstCourseId = lessons.length > 0 ? lessons[0].courseId : null;
|
||||
if (firstCourseId) {
|
||||
navigate(`/courses/${firstCourseId}/lessons/new`);
|
||||
} else {
|
||||
alert('Please create a course first before adding lessons.');
|
||||
}
|
||||
}}
|
||||
>
|
||||
+ Add Lesson
|
||||
</button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#9497a9' }}>Loading lessons...</div>
|
||||
) : error ? (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#ef4444' }}>{error}</div>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={mockLessons}
|
||||
searchKeys={['code', 'name', 'description', 'createdBy']}
|
||||
data={lessons}
|
||||
searchKeys={['code', 'name', 'description']}
|
||||
searchPlaceholder="Search lessons..."
|
||||
perPage={10}
|
||||
emptyText="No lessons found."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import { Button } from '../../../components/ui/button';
|
||||
import { HiOutlineArrowLeft, HiOutlineBookmark, HiOutlineRocketLaunch } from 'react-icons/hi2';
|
||||
import {
|
||||
HiOutlineArrowLeft,
|
||||
HiOutlineBookmark,
|
||||
HiOutlineRocketLaunch
|
||||
} from 'react-icons/hi2';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
function ActionBar({ courseId, isPublished, onTogglePublish }) {
|
||||
function ActionBar({ courseId, isPublished, onTogglePublish, onSaveDraft, saving }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 mb-6">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link to="/courses" className="hover:text-primary transition-colors flex items-center gap-1">
|
||||
<Link
|
||||
to="/courses"
|
||||
className="hover:text-primary transition-colors flex items-center gap-1"
|
||||
>
|
||||
<HiOutlineArrowLeft className="w-4 h-4" />
|
||||
<span>Courses</span>
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link to={`/courses/${courseId}`} className="hover:text-primary transition-colors truncate max-w-[200px]">
|
||||
<Link
|
||||
to={`/courses/${courseId}`}
|
||||
className="hover:text-primary transition-colors truncate max-w-[200px]"
|
||||
>
|
||||
{courseId}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
@@ -26,6 +36,7 @@ function ActionBar({ courseId, isPublished, onTogglePublish }) {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
|
||||
disabled={saving}
|
||||
>
|
||||
<HiOutlineArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
@@ -33,16 +44,19 @@ function ActionBar({ courseId, isPublished, onTogglePublish }) {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
|
||||
onClick={onSaveDraft}
|
||||
disabled={saving}
|
||||
>
|
||||
<HiOutlineBookmark className="w-4 h-4 mr-2" />
|
||||
Save Draft
|
||||
{saving ? 'Saving...' : 'Save Draft'}
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark shadow-sm"
|
||||
onClick={onTogglePublish}
|
||||
disabled={saving}
|
||||
>
|
||||
<HiOutlineRocketLaunch className="w-4 h-4 mr-2" />
|
||||
{isPublished ? 'Update & Publish' : 'Save & Publish'}
|
||||
{saving ? 'Saving...' : isPublished ? 'Update & Publish' : 'Save & Publish'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import api from '@/lib/api';
|
||||
|
||||
const BASE_PATH = '/api/courses';
|
||||
|
||||
const courseService = {
|
||||
getAll: async () => {
|
||||
const response = await api.get(BASE_PATH);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id) => {
|
||||
const response = await api.get(`${BASE_PATH}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data) => {
|
||||
const response = await api.post(BASE_PATH, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id, data) => {
|
||||
const response = await api.put(`${BASE_PATH}/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id) => {
|
||||
await api.delete(`${BASE_PATH}/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
export default courseService;
|
||||
@@ -0,0 +1,31 @@
|
||||
import api from '@/lib/api';
|
||||
|
||||
const BASE_PATH = '/api/lessons';
|
||||
|
||||
const lessonService = {
|
||||
getAll: async () => {
|
||||
const response = await api.get(BASE_PATH);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id) => {
|
||||
const response = await api.get(`${BASE_PATH}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data) => {
|
||||
const response = await api.post(BASE_PATH, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id, data) => {
|
||||
const response = await api.put(`${BASE_PATH}/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id) => {
|
||||
await api.delete(`${BASE_PATH}/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
export default lessonService;
|
||||
@@ -0,0 +1,36 @@
|
||||
import api from '@/lib/api';
|
||||
|
||||
const BASE_PATH = '/api/sections';
|
||||
|
||||
const sectionService = {
|
||||
getAll: async () => {
|
||||
const response = await api.get(BASE_PATH);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id) => {
|
||||
const response = await api.get(`${BASE_PATH}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getByLessonId: async (lessonId) => {
|
||||
const response = await api.get(`${BASE_PATH}/by-lesson/${lessonId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data) => {
|
||||
const response = await api.post(BASE_PATH, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id, data) => {
|
||||
const response = await api.put(`${BASE_PATH}/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id) => {
|
||||
await api.delete(`${BASE_PATH}/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
export default sectionService;
|
||||
Reference in New Issue
Block a user