diff --git a/package-lock.json b/package-lock.json index a1a1eca..aa20bd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,14 @@ "name": "japanlearn", "version": "0.1.0", "dependencies": { + "@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-tabs": "^1.1.12", + "antd": "^5.26.1", "axios": "^1.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.0.0", @@ -67,6 +70,103 @@ "node": ">=6.0.0" } }, + "node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-1.23.0.tgz", + "integrity": "sha512-7GAg9bD/iC9ikWatU9ym+P9ugJhi/WbsTWzcKN6T4gU0aehsprtke1UAaaSxxkjjmkJb3llet/rbUSLPgwlY4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "classnames": "^2.3.1", + "csstype": "^3.1.3", + "rc-util": "^5.35.0", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz", + "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.0", + "@babel/runtime": "^7.23.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", + "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", + "license": "MIT" + }, + "node_modules/@ant-design/react-slick": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz", + "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.4", + "classnames": "^2.2.5", + "json2mq": "^0.2.0", + "resize-observer-polyfill": "^1.5.1", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": ">=16.9.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -291,6 +391,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -373,6 +482,18 @@ "tslib": "^2.4.0" } }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", @@ -1170,6 +1291,36 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.11.tgz", + "integrity": "sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", @@ -1395,6 +1546,43 @@ } } }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.14.tgz", + "integrity": "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz", @@ -1840,6 +2028,155 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@rc-component/async-validator": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.0.4.tgz", + "integrity": "sha512-qgGdcVIF604M9EqjNF0hbUTz42bz/RDtxWdWuU5EQe3hi7M8ob54B6B35rOsvX5eSvIHIzT9iH1R3n+hk3CGfg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-2.0.1.tgz", + "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6", + "@babel/runtime": "^7.23.6", + "classnames": "^2.2.6", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz", + "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.0.tgz", + "integrity": "sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", + "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz", + "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.0.0.tgz", + "integrity": "sha512-L+rZ4HXP2sJ1gHMGHjsg9jlYBX/SLN2D6OxP9Zn3qgtpMWtO2vUfxVFwiogHpAIqs54FnALxraUy/BCO1yRIgg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.1.tgz", + "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/portal": "^1.0.0-9", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/trigger": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.2.7.tgz", + "integrity": "sha512-Qggj4Z0AA2i5dJhzlfFSmg1Qrziu8dsdHOihROL5Kl18seO2Eh/ZaTYt2c8a/CyGaTChnFry7BEYew1+/fhSbA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@rc-component/portal": "^1.1.0", + "classnames": "^2.3.2", + "rc-motion": "^2.0.0", + "rc-resize-observer": "^1.3.1", + "rc-util": "^5.44.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.9", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz", @@ -2771,6 +3108,71 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/antd": { + "version": "5.26.1", + "resolved": "https://registry.npmjs.org/antd/-/antd-5.26.1.tgz", + "integrity": "sha512-CiLGZ2Ftld+fuoj+U3OL8uouuqUppqFJnW4O/4bOgSWzM9XsJGibpNtUa9QArhrZ5ndfnzlPP/4RVXUK/xfSvQ==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.2.1", + "@ant-design/cssinjs": "^1.23.0", + "@ant-design/cssinjs-utils": "^1.1.3", + "@ant-design/fast-color": "^2.0.6", + "@ant-design/icons": "^5.6.1", + "@ant-design/react-slick": "~1.1.2", + "@babel/runtime": "^7.26.0", + "@rc-component/color-picker": "~2.0.1", + "@rc-component/mutate-observer": "^1.1.0", + "@rc-component/qrcode": "~1.0.0", + "@rc-component/tour": "~1.15.1", + "@rc-component/trigger": "^2.2.6", + "classnames": "^2.5.1", + "copy-to-clipboard": "^3.3.3", + "dayjs": "^1.11.11", + "rc-cascader": "~3.34.0", + "rc-checkbox": "~3.5.0", + "rc-collapse": "~3.9.0", + "rc-dialog": "~9.6.0", + "rc-drawer": "~7.3.0", + "rc-dropdown": "~4.2.1", + "rc-field-form": "~2.7.0", + "rc-image": "~7.12.0", + "rc-input": "~1.8.0", + "rc-input-number": "~9.5.0", + "rc-mentions": "~2.20.0", + "rc-menu": "~9.16.1", + "rc-motion": "^2.9.5", + "rc-notification": "~5.6.4", + "rc-pagination": "~5.1.0", + "rc-picker": "~4.11.3", + "rc-progress": "~4.0.0", + "rc-rate": "~2.13.1", + "rc-resize-observer": "^1.4.3", + "rc-segmented": "~2.7.0", + "rc-select": "~14.16.8", + "rc-slider": "~11.1.8", + "rc-steps": "~6.0.1", + "rc-switch": "~4.1.0", + "rc-table": "~7.51.0", + "rc-tabs": "~15.6.1", + "rc-textarea": "~1.10.0", + "rc-tooltip": "~6.4.0", + "rc-tree": "~5.13.1", + "rc-tree-select": "~5.27.0", + "rc-upload": "~4.9.2", + "rc-util": "^5.44.4", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -3340,6 +3742,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3391,6 +3799,12 @@ "node": ">= 6" } }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3414,6 +3828,15 @@ "node": ">=18" } }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "license": "MIT", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3446,7 +3869,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -3510,6 +3932,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -5520,6 +5948,15 @@ "dev": true, "license": "MIT" }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -6401,6 +6838,618 @@ ], "license": "MIT" }, + "node_modules/rc-cascader": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz", + "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "^2.3.1", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-checkbox": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.5.0.tgz", + "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.25.2" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-collapse": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.9.0.tgz", + "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.3.4", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dialog": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.6.0.tgz", + "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/portal": "^1.0.0-8", + "classnames": "^2.2.6", + "rc-motion": "^2.3.0", + "rc-util": "^5.21.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-drawer": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.3.0.tgz", + "integrity": "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@rc-component/portal": "^1.1.1", + "classnames": "^2.2.6", + "rc-motion": "^2.6.1", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dropdown": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.2.1.tgz", + "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-util": "^5.44.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/rc-field-form": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-2.7.0.tgz", + "integrity": "sha512-hgKsCay2taxzVnBPZl+1n4ZondsV78G++XVsMIJCAoioMjlMQR9YwAp7JZDIECzIu2Z66R+f4SFIRrO2DjDNAA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/async-validator": "^5.0.3", + "rc-util": "^5.32.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-image": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz", + "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/portal": "^1.0.2", + "classnames": "^2.2.6", + "rc-dialog": "~9.6.0", + "rc-motion": "^2.6.2", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-input": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.8.0.tgz", + "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.18.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-input-number": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.5.0.tgz", + "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/mini-decimal": "^1.0.1", + "classnames": "^2.2.5", + "rc-input": "~1.8.0", + "rc-util": "^5.40.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-mentions": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.20.0.tgz", + "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.5", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-input": "~1.8.0", + "rc-menu": "~9.16.0", + "rc-textarea": "~1.10.0", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-menu": { + "version": "9.16.1", + "resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.16.1.tgz", + "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.0.0", + "classnames": "2.x", + "rc-motion": "^2.4.3", + "rc-overflow": "^1.3.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-motion": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz", + "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.44.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-notification": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.4.tgz", + "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.9.0", + "rc-util": "^5.20.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-overflow": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.4.1.tgz", + "integrity": "sha512-3MoPQQPV1uKyOMVNd6SZfONi+f3st0r8PksexIdBTeIYbMX0Jr+k7pHEDvsXtR4BpCv90/Pv2MovVNhktKrwvw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.37.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-pagination": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-5.1.0.tgz", + "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-picker": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.11.3.tgz", + "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.1", + "rc-overflow": "^1.3.2", + "rc-resize-observer": "^1.4.0", + "rc-util": "^5.43.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/rc-progress": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-4.0.0.tgz", + "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.6", + "rc-util": "^5.16.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-rate": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.1.tgz", + "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.0.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-resize-observer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz", + "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.7", + "classnames": "^2.2.1", + "rc-util": "^5.44.1", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-segmented": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.7.0.tgz", + "integrity": "sha512-liijAjXz+KnTRVnxxXG2sYDGd6iLL7VpGGdR8gwoxAXy2KglviKCxLWZdjKYJzYzGSUwKDSTdYk8brj54Bn5BA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-motion": "^2.4.4", + "rc-util": "^5.17.0" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-select": { + "version": "14.16.8", + "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz", + "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.1.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-overflow": "^1.3.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-slider": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.8.tgz", + "integrity": "sha512-2gg/72YFSpKP+Ja5AjC5DPL1YnV8DEITDQrcc1eASrUYjl0esptaBVJBh5nLTXCCp15eD8EuGjwezVGSHhs9tQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-steps": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rc-steps/-/rc-steps-6.0.1.tgz", + "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.16.7", + "classnames": "^2.2.3", + "rc-util": "^5.16.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-switch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-4.1.0.tgz", + "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0", + "classnames": "^2.2.1", + "rc-util": "^5.30.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-table": { + "version": "7.51.1", + "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.51.1.tgz", + "integrity": "sha512-5iq15mTHhvC42TlBLRCoCBLoCmGlbRZAlyF21FonFnS/DIC8DeRqnmdyVREwt2CFbPceM0zSNdEeVfiGaqYsKw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/context": "^1.4.0", + "classnames": "^2.2.5", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.44.3", + "rc-virtual-list": "^3.14.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tabs": { + "version": "15.6.1", + "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.6.1.tgz", + "integrity": "sha512-/HzDV1VqOsUWyuC0c6AkxVYFjvx9+rFPKZ32ejxX0Uc7QCzcEjTA9/xMgv4HemPKwzBNX8KhGVbbumDjnj92aA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "classnames": "2.x", + "rc-dropdown": "~4.2.0", + "rc-menu": "~9.16.0", + "rc-motion": "^2.6.2", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.34.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-textarea": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.10.0.tgz", + "integrity": "sha512-ai9IkanNuyBS4x6sOL8qu/Ld40e6cEs6pgk93R+XLYg0mDSjNBGey6/ZpDs5+gNLD7urQ14po3V6Ck2dJLt9SA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "rc-input": "~1.8.0", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tooltip": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.4.0.tgz", + "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.1", + "rc-util": "^5.44.3" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tree": { + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.13.1.tgz", + "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-tree-select": { + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.27.0.tgz", + "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "2.x", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-upload": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.9.2.tgz", + "integrity": "sha512-nHx+9rbd1FKMiMRYsqQ3NkXUv7COHPBo3X1Obwq9SWS6/diF/A0aJ5OHubvwUAIDs+4RMleljV0pcrNUc823GQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.5", + "rc-util": "^5.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.44.4", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/rc-virtual-list": { + "version": "3.19.1", + "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.19.1.tgz", + "integrity": "sha512-DCapO2oyPqmooGhxBuXHM4lFuX+sshQwWqqkuyFA+4rShLe//+GEPVwiDgO+jKtKHtbeYwZoNvetwfHdOf+iUQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "classnames": "^2.2.6", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -6646,6 +7695,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -6843,6 +7898,15 @@ "loose-envify": "^1.1.0" } }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -7061,6 +8125,12 @@ "node": ">= 0.4" } }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -7294,6 +8364,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -7478,6 +8554,15 @@ "node": ">=0.8" } }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -7536,6 +8621,12 @@ "node": ">=8.0" } }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", diff --git a/package.json b/package.json index d41f6c1..4860358 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,14 @@ "preview": "vite preview" }, "dependencies": { + "@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-tabs": "^1.1.12", + "antd": "^5.26.1", "axios": "^1.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.0.0", diff --git a/src/App.tsx b/src/App.tsx index 9da61d3..4056940 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,24 @@ -import { Routes, Route, Navigate } from 'react-router-dom' +import { Routes, Route} from 'react-router-dom' import LoginPage from './app/Authentication/LoginPage'; import RegisterPage from './app/Authentication/RegisterPage'; import CourseListPage from './app/Common/CourseListPage' -import CourseDetail from './components/sections/course-detail'; +import CourseDetail from './components/sections/common/course-detail'; import HomePage from './app/page'; import ChatbotPage from './app/Common/ChatBot'; -import SlotSkills from './components/sections/slot-skills'; -import Vocabulary from './components/sections/vocabulary'; +import SlotSkills from './components/sections/common/slot-skills'; +import Vocabulary from './components/sections/common/vocabulary'; +import JapaneseLearningPage from './components/sections/common/japanese-learning-page'; +import Dashboard from './app/Admin/Dashboard'; +import StaffViewListCourse from './app/Staff/ViewListCourse'; +import AddNewCourse from './app/Staff/AddNewCourse'; +import ViewCourseDetail from './app/Staff/CourseDetail'; +import AddNewChapter from './app/Staff/AddNewChapter'; +import AddNewUnit from './app/Staff/AddNewUnit'; +import { FeedbackManagerPage } from './components/sections/staff/feedback-manager-page'; +import SimpleVerify from './components/auth/Verify'; +import ChangePassPage from './app/Authentication/ChangePassPage'; +import FogotPassPage from './app/Authentication/FogotPassPage'; +import ResetPassPage from './app/Authentication/ResetPassPage'; function App() { const isAuthenticated = false; @@ -17,11 +29,23 @@ function App() { {/* Thay vì AuthPage, bạn có thể render LoginPage hoặc một component khác */} } /> } /> + } /> } /> } /> } /> } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> ) diff --git a/src/app/Admin/Dashboard.tsx b/src/app/Admin/Dashboard.tsx new file mode 100644 index 0000000..7d6e0bc --- /dev/null +++ b/src/app/Admin/Dashboard.tsx @@ -0,0 +1,65 @@ +import AutoLayout from "@/components/layout/AutoLayout"; +import { CourseListPage } from "@/components/sections/staff/course-list"; +import { Subject } from "../../components/sections/entity"; + +// Dữ liệu mock để test +const mockCourses: Subject[] = [ + { + id: 1, + title: "Tiếng Nhật sơ cấp N5", + topic: "Ngôn ngữ", + description: "Học Hiragana, Katakana và ngữ pháp cơ bản.", + level: "Sơ cấp", + estimatedDuration: "6 tuần", + creatorId: "admin123", + image: "https://example.com/japanese-n5.jpg", + createdAt: "2025-06-01T10:00:00Z", + updatedAt: "2025-06-20T15:30:00Z", + status: "Published", + orderNumber: 1, + studentCount: 1200, + lessonCount: 24, + rating: 4.7, + chapters: [], + }, + { + id: 2, + title: "Luyện giao tiếp tiếng Nhật N5", + topic: "Giao tiếp", + description: "Rèn luyện kỹ năng hội thoại đơn giản trong cuộc sống hàng ngày.", + level: "Sơ cấp", + estimatedDuration: "4 tuần", + creatorId: "teacher001", + image: "https://example.com/conversation-n5.jpg", + createdAt: "2025-06-10T12:00:00Z", + updatedAt: "2025-06-21T08:30:00Z", + status: "Draft", + orderNumber: 2, + studentCount: 850, + lessonCount: 16, + rating: 4.5, + chapters: [], + } +]; + +const Dashboard: React.FC = () => { + const handleViewDetails = (course: Subject) => { + console.log("Chi tiết khóa học:", course.title); + }; + + const handleAddCourse = () => { + console.log("Thêm khóa học mới"); + }; + + return ( + + + + ); +}; + +export default Dashboard; diff --git a/src/app/Authentication/ChangePassPage.tsx b/src/app/Authentication/ChangePassPage.tsx new file mode 100644 index 0000000..9bfe74c --- /dev/null +++ b/src/app/Authentication/ChangePassPage.tsx @@ -0,0 +1,11 @@ +import { Header } from "@/components/layout/header" +import { ChangePasswordForm } from "@/components/auth/ChangePass-form" + +export default function ChangePassPage() { + return ( + <> +
+ + + ) +} diff --git a/src/app/Authentication/FogotPassPage.tsx b/src/app/Authentication/FogotPassPage.tsx new file mode 100644 index 0000000..1eb6209 --- /dev/null +++ b/src/app/Authentication/FogotPassPage.tsx @@ -0,0 +1,11 @@ +import { Header } from "@/components/layout/header" +import { ForgotPasswordForm } from "@/components/auth/FogotPass-form" + +export default function FogotPassPage() { + return ( + <> +
+ + + ) +} diff --git a/src/app/Authentication/ResetPassPage.tsx b/src/app/Authentication/ResetPassPage.tsx new file mode 100644 index 0000000..d77ca77 --- /dev/null +++ b/src/app/Authentication/ResetPassPage.tsx @@ -0,0 +1,11 @@ +import { Header } from "@/components/layout/header" +import { ResetPasswordForm } from "@/components/auth/ResetPass-form" + +export default function ResetPassPage() { + return ( + <> +
+ + + ) +} diff --git a/src/app/Common/ChatBot.tsx b/src/app/Common/ChatBot.tsx index a92d27b..1c9ab18 100644 --- a/src/app/Common/ChatBot.tsx +++ b/src/app/Common/ChatBot.tsx @@ -1,5 +1,5 @@ import { Header } from "@/components/layout/header" -import Chatbot from "@/components/sections/chatbot" +import Chatbot from "@/components/sections/common/chatbot" export default function ChatbotPage() { return ( diff --git a/src/app/Common/CourseListPage.tsx b/src/app/Common/CourseListPage.tsx index 21d4440..f7c7f7c 100644 --- a/src/app/Common/CourseListPage.tsx +++ b/src/app/Common/CourseListPage.tsx @@ -1,4 +1,4 @@ -import JapaneseCourseList from "@/components/sections/japanese-course-list" +import JapaneseCourseList from "@/components/sections/common/japanese-course-list" import { Header } from "@/components/layout/header" export default function Page() { diff --git a/src/app/Common/DynamicBreadcrumb.tsx b/src/app/Common/DynamicBreadcrumb.tsx new file mode 100644 index 0000000..4f15c05 --- /dev/null +++ b/src/app/Common/DynamicBreadcrumb.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { Breadcrumb } from "antd"; +import { Link, useLocation } from "react-router-dom"; + +// Helper function to capitalize and transform path segments +const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); + +const DynamicBreadcrumb: React.FC = () => { + const location = useLocation(); + const pathSnippets = location.pathname.split("/").filter((i) => i); + + // Build the breadcrumb items dynamically based on the URL + const breadcrumbItems = [ + ...pathSnippets + .filter((segment) => segment.toLowerCase() !== "admin" && segment.toLowerCase() !== "organization") + .map((segment, index) => { + const url = `/${pathSnippets.slice(0, index + 1).join("/")}`; + const isLast = index === pathSnippets.length - 1; + + const name = isNaN(Number(segment)) + ? capitalize(segment) + : `${segment}`; + + return { + key: url, + title: isLast ? name : {name}, + }; + }), + ]; + + return ; +}; + +export default DynamicBreadcrumb; diff --git a/src/app/Staff/AddNewChapter.tsx b/src/app/Staff/AddNewChapter.tsx new file mode 100644 index 0000000..b12f65f --- /dev/null +++ b/src/app/Staff/AddNewChapter.tsx @@ -0,0 +1,64 @@ +import AutoLayout from "@/components/layout/AutoLayout"; +import { AddChapterPage } from "@/components/sections/staff/add-chapter-page"; +import { CreateChapterDTO, Subject } from "@/components/sections/entity"; +import { useAPI } from "@/hooks"; +import { useNavigate, useParams } from "react-router-dom"; +import { useEffect, useState } from "react"; +import URLMapping from "@/utils/URLMapping"; + +const AddNewChapter: React.FC = () => { + const { API } = useAPI(); + const navigate = useNavigate(); + const { courseId } = useParams(); + const [course, setCourse] = useState(null); + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + const loadData = async () => { + try { + if (!course && courseId) { + const response = await API.get(URLMapping.SUBJECT_DETAIL + `/${courseId}`); + setCourse(response); // Gán course lấy từ API + } + } catch (err) { + setError("Không thể tải dữ liệu khóa học."); + } finally { + setLoading(false); + } + }; + loadData(); + }, [course, courseId]); + + const handleBack = () => { + navigate(-1); // hoặc navigate(`/admin/subjects/${subjectId}`); + }; + + const handleCreateChapter = async (chapterData: CreateChapterDTO) => { + try { + console.log("Dữ liệu tạo chapter:", chapterData); + const response = await API.post(URLMapping.CHAPTER_CREATE, chapterData); + navigate(`/detail/${courseId}`, { state: { course } }); // quay lại chi tiết subject + } catch (error) { + console.error("Tạo chương thất bại:", error); + alert("Tạo chương thất bại."); + } + }; + + if (!course) { + return

Đang tải thông tin môn học...

; + } else { + return ( + + + + ); + } + +}; + +export default AddNewChapter; diff --git a/src/app/Staff/AddNewCourse.tsx b/src/app/Staff/AddNewCourse.tsx new file mode 100644 index 0000000..ab2d70d --- /dev/null +++ b/src/app/Staff/AddNewCourse.tsx @@ -0,0 +1,39 @@ +import AutoLayout from "@/components/layout/AutoLayout"; +import { CreateCoursePage } from "@/components/sections/staff/create-course-page"; +import { Subject } from "@/components/sections/entity"; +import { useNavigate } from "react-router-dom"; +import { useAPI } from "@/hooks"; // Giả sử bạn đã có useAPI hook +import URLMapping from "@/utils/URLMapping"; + +const AddNewCourse: React.FC = () => { + const navigate = useNavigate(); + const { API } = useAPI(); + + const handleBack = () => { + navigate(-1); // hoặc navigate("/admin/subjects"); + }; + + const handleCreateCourse = async ( + courseData: Omit + ) => { + try { + const response = await API.post(URLMapping.SUBJECT_CREATE, courseData); + console.log("Tạo môn học thành công:", response.data); + navigate("/viewlistcourse"); // hoặc tới trang chi tiết + } catch (error) { + console.error("Tạo môn học thất bại:", error); + alert("Đã xảy ra lỗi khi tạo môn học."); + } + }; + + return ( + + + + ); +}; + +export default AddNewCourse; diff --git a/src/app/Staff/AddNewUnit.tsx b/src/app/Staff/AddNewUnit.tsx new file mode 100644 index 0000000..be6bac7 --- /dev/null +++ b/src/app/Staff/AddNewUnit.tsx @@ -0,0 +1,102 @@ +import AutoLayout from "@/components/layout/AutoLayout"; +import { AddLessonPage } from "@/components/sections/staff/add-unit-page"; +import { Chapter, Subject } from "@/components/sections/entity"; +import { useAPI } from "@/hooks"; +import { useNavigate, useParams } from "react-router-dom"; +import { useEffect, useState } from "react"; +import URLMapping from "@/utils/URLMapping"; + +const AddNewUnit: React.FC = () => { + const { API } = useAPI(); + const navigate = useNavigate(); + const { courseId, chapterId } = useParams(); + const [course, setCourse] = useState(null); + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + const loadData = async () => { + try { + if (!course && courseId) { + const response = await API.get(URLMapping.SUBJECT_DETAIL + `/${courseId}`); + setCourse(response); // Gán course lấy từ API + + } + } catch (err) { + setError("Không thể tải dữ liệu khóa học."); + } finally { + setLoading(false); + } + }; + loadData(); + }, [course, courseId]); + + const selectedChapter = course?.chapters.find( + (ch) => ch.id === Number(chapterId) + ); + + + const handleCreateSlotWithMaterials = async (slotData: { + title: string; + description: string; + orderNumber: number; + chapterId: number; + materials: Array<{ + type: string; + name: string; + description: string; + file?: File; + }>; + }) => { + try { + // B1: Tạo slot + const slotRes = await API.post(URLMapping.UNIT_CREATE, { + title: slotData.title, + description: slotData.description, + orderNumber: slotData.orderNumber, + chapterId: slotData.chapterId, + }); + const slotId = slotRes.data.id; + + // B2: Tạo material cho slot đó + for (const material of slotData.materials) { + const formData = new FormData(); + formData.append("type", material.type); + formData.append("name", material.name); + formData.append("description", material.description); + formData.append("slotId", slotId.toString()); + + if (material.file) { + formData.append("file", material.file); + } + + await API.post("/materials/create", formData); + } + + // B3: Refresh dữ liệu chương + const updatedChapterRes = await API.get(`/chapters/${slotData.chapterId}`); + setChapter(updatedChapterRes.data); // setChapter là hàm cập nhật lại UI + + } catch (error) { + console.error("Error creating slot with materials:", error); + } + }; + + + if (!course) { + return

Đang tải thông tin môn học...

; + } else { + return ( + + navigate(-1)} + onCreateLesson={handleCreateSlotWithMaterials} + /> + + ); + } +}; + +export default AddNewUnit; diff --git a/src/app/Staff/CourseDetail.tsx b/src/app/Staff/CourseDetail.tsx new file mode 100644 index 0000000..b0e8869 --- /dev/null +++ b/src/app/Staff/CourseDetail.tsx @@ -0,0 +1,66 @@ +import { useLocation, useNavigate, useParams } from "react-router-dom"; +import AutoLayout from "@/components/layout/AutoLayout"; +import type { Subject } from "@/components/sections/entity"; +import { CourseDetailLayoutPage } from "@/components/sections/staff/course-detail-layout-page"; +import { useEffect, useState } from "react"; +import URLMapping from "@/utils/URLMapping"; +import { useAPI } from "@/hooks"; + +const ViewCourseDetail: React.FC = () => { + const location = useLocation(); + const navigate = useNavigate(); + const { courseId } = useParams(); + const { API } = useAPI(); + + // State để lưu trữ course + const [course, setCourse] = useState( + location.state?.course as Subject | undefined + ); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadData = async () => { + try { + if (!course && courseId) { + const response = await API.get(URLMapping.SUBJECT_DETAIL + `${courseId}`); + setCourse(response); // Gán course lấy từ API + } + } catch (err) { + setError("Không thể tải dữ liệu khóa học."); + } finally { + setLoading(false); + } + }; + loadData(); + }, [course, courseId]); + + if (loading) { + return ( + +
Đang tải dữ liệu...
+
+ ); + } + + if (error || !course) { + return ( + +
+ {error || "Không tìm thấy dữ liệu khóa học."} + +
+
+ ); + } + + return ( + + navigate(-1)} /> + + ); +}; + +export default ViewCourseDetail; diff --git a/src/app/Staff/ViewListCourse.tsx b/src/app/Staff/ViewListCourse.tsx new file mode 100644 index 0000000..39c3e06 --- /dev/null +++ b/src/app/Staff/ViewListCourse.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from "react" +import { Subject } from "@/components/sections/entity" +import { useNavigate } from "react-router-dom" +import AutoLayout from "@/components/layout/AutoLayout" +import { CourseListPage } from "@/components/sections/staff/course-list" +import { useAPI } from "@/hooks" +import URLMapping from "@/utils/URLMapping" + +const Dashboard = () => { + const { API } = useAPI(); + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const navigate = useNavigate() + const [japaneseCourses, setJapaneseCourses] = useState([]); + + const handleViewDetails = (course: Subject) => { + navigate(`/coursedetail`, { state: { course } }) + } + + useEffect(() => { + const loadData = async () => { + try { + const response = await API.get(URLMapping.LIST_SUBJECT); + setJapaneseCourses(response.content); + setLoading(false); + } catch (err: any) { + setError("Không thể tải dữ liệu khóa học."); + setLoading(false); + } + }; + loadData(); + }, [API]); + + return ( + + {loading ? ( +
Đang tải dữ liệu...
+ ) : error ? ( +
Lỗi: {error}
+ ) : ( + {}} + /> + )} +
+ ) +} + +export default Dashboard diff --git a/src/app/page.tsx b/src/app/page.tsx index e9a7ce4..15c2eb3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,9 +1,6 @@ -import { Hero } from "@/components/sections/hero" -import { Features } from "@/components/sections/features" -import { Courses } from "@/components/sections/courses" -import { Testimonials } from "@/components/sections/testimonials" -import { Stats } from "@/components/sections/stats" -import { CTA } from "@/components/sections/cta" +import { Hero } from "@/components/sections/common/hero" +import { Features } from "@/components/sections/common/features" +import { Courses } from "@/components/sections/common/courses" import { Header } from "@/components/layout/header" import { Footer } from "@/components/layout/Footer" @@ -14,10 +11,7 @@ export default function HomePage() {
- - -
) diff --git a/src/components/auth/ChangePass-form.tsx b/src/components/auth/ChangePass-form.tsx new file mode 100644 index 0000000..8c8b940 --- /dev/null +++ b/src/components/auth/ChangePass-form.tsx @@ -0,0 +1,338 @@ +"use client" + +import type React from "react" +import { useState } from "react" +import { Link } from "react-router-dom" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Label } from "@/components/ui/label" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Lock, Eye, EyeOff, AlertCircle, CheckCircle, ArrowLeft, BookOpen } from "lucide-react" +import { useAPI } from "@/hooks/useAPI" +import URLMapping from "@/utils/URLMapping" +import { useNavigate } from "react-router-dom" + +interface ChangePasswordData { + currentPassword: string + newPassword: string + confirmPassword: string +} + +interface PasswordError { + field: string + message: string +} + +export function ChangePasswordForm() { + const [formData, setFormData] = useState({ + currentPassword: "", + newPassword: "", + confirmPassword: "", + }) + const [showPasswords, setShowPasswords] = useState({ + current: false, + new: false, + confirm: false, + }) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(false) + const [message, setMessage] = useState("") + + const { API } = useAPI() + const navigate = useNavigate() + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target + setFormData((prev) => ({ ...prev, [name]: value })) + // Clear error when user starts typing + if (error) setError(null) + if (message) setMessage("") + } + + const togglePasswordVisibility = (field: keyof typeof showPasswords) => { + setShowPasswords((prev) => ({ ...prev, [field]: !prev[field] })) + } + + const validateForm = (): boolean => { + if (!formData.currentPassword) { + setError({ field: "currentPassword", message: "Vui lòng nhập mật khẩu hiện tại" }) + return false + } + + if (!formData.newPassword) { + setError({ field: "newPassword", message: "Vui lòng nhập mật khẩu mới" }) + return false + } + + if (formData.newPassword.length < 6) { + setError({ field: "newPassword", message: "Mật khẩu mới phải có ít nhất 6 ký tự" }) + return false + } + + if (!formData.confirmPassword) { + setError({ field: "confirmPassword", message: "Vui lòng xác nhận mật khẩu mới" }) + return false + } + + if (formData.newPassword !== formData.confirmPassword) { + setError({ field: "confirmPassword", message: "Mật khẩu xác nhận không khớp" }) + return false + } + + if (formData.currentPassword === formData.newPassword) { + setError({ field: "newPassword", message: "Mật khẩu mới phải khác mật khẩu hiện tại" }) + return false + } + + return true + } + + const handleChangePassword = async () => { + if (!validateForm()) return + + setIsLoading(true) + setError(null) + setMessage("") + + try { + const payload = { + currentPassword: formData.currentPassword, + newPassword: formData.newPassword, + } + + const response = await API.post(URLMapping.RESET_PASSWORD, payload) + + if (response.success) { + setSuccess(true) + // Reset form + setFormData({ + currentPassword: "", + newPassword: "", + confirmPassword: "", + }) + } else { + const message = response.message + setMessage(message) + } + } catch (err) { + setError({ + field: "general", + message: "Có lỗi xảy ra. Vui lòng thử lại.", + }) + } finally { + setIsLoading(false) + } + } + + if (success) { + return ( +
+ + +
+
+ +
+

Đổi Mật Khẩu Thành Công!

+

Mật khẩu của bạn đã được cập nhật thành công.

+
+ +
+
+
+ ) + } + + return ( +
+
+ {/* Header */} +
+
+ +

日本語学習

+
+

Học Tiếng Nhật Online

+
+ + + +
+
+ +
+
+
+ Đổi Mật Khẩu + + Cập nhật mật khẩu để bảo mật tài khoản của bạn + +
+
+ + + {/* Error Alert */} + {error && error.field === "general" && ( + + + {error.message} + + )} + +
+ {/* Current Password */} +
+ +
+ + + +
+ {error?.field === "currentPassword" && ( +

+ + {error.message} +

+ )} +
+ + {/* New Password */} +
+ +
+ + + +
+ {error?.field === "newPassword" && ( +

+ + {error.message} +

+ )} +
+ + {/* Confirm Password */} +
+ +
+ + + +
+ {error?.field === "confirmPassword" && ( +

+ + {error.message} +

+ )} +
+ + {/* Error Message */} + {message && ( +
+ {message} +
+ )} + + {/* Change Password Button */} + +
+ + {/* Japanese Learning Motivation */} +
+
🔒
+

+ 安全第一! (Anzen daiichi!) +
+ An toàn là trên hết! Bảo vệ tài khoản của bạn. +

+
+
+
+
+
+ ) +} diff --git a/src/components/auth/FogotPass-form.tsx b/src/components/auth/FogotPass-form.tsx new file mode 100644 index 0000000..440b4c2 --- /dev/null +++ b/src/components/auth/FogotPass-form.tsx @@ -0,0 +1,193 @@ +"use client" + +import type React from "react" +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Label } from "@/components/ui/label" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Mail, AlertCircle, CheckCircle, ArrowLeft, Send, BookOpen } from "lucide-react" +import { useAPI } from "@/hooks/useAPI" +import URLMapping from "@/utils/URLMapping" +import { useNavigate } from "react-router-dom" + +interface ForgotPasswordError { + field: string + message: string +} + +export function ForgotPasswordForm() { + const [email, setEmail] = useState("") + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(false) + const [message, setMessage] = useState("") + const navigate= useNavigate(); + + const { API } = useAPI() + + const handleInputChange = (e: React.ChangeEvent) => { + setEmail(e.target.value) + // Clear error when user starts typing + if (error) setError(null) + if (message) setMessage("") + } + + const validateForm = (): boolean => { + if (!email) { + setError({ field: "email", message: "Vui lòng nhập email" }) + return false + } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + setError({ field: "email", message: "Vui lòng nhập địa chỉ email hợp lệ" }) + return false + } + return true + } + + const handleForgotPassword = async () => { + if (!validateForm()) return + + setIsLoading(true) + setError(null) + setMessage("") + + try { + const response = await API.post(URLMapping.FORGOT_PASSWORD+`?email=${email}`) + + if (response.success) { + setSuccess(true); + localStorage.setItem("email", email); + navigate(`/resetpass`); + } else { + const message = response.message + setMessage(message) + } + } catch (err) { + setError({ + field: "general", + message: "Có lỗi xảy ra. Vui lòng thử lại.", + }) + } finally { + setIsLoading(false) + } + } + + return ( +
+ {/* Header */} +
+
+ +

日本語学習

+
+

Học Tiếng Nhật Online

+
+ + + +
+
+ +
+
+
+ Quên Mật Khẩu? + + Đừng lo lắng! Nhập email và chúng tôi sẽ gửi link đặt lại cho bạn + +
+
+ + + {/* Error Alert */} + {error && error.field === "general" && ( + + + {error.message} + + )} + +
+ {/* Email Input */} +
+ +
+ + +
+ {error?.field === "email" && ( +

+ + {error.message} +

+ )} +
+ + {/* Error Message */} + {message && ( +
+ {message} +
+ )} + + {/* Send Reset Link Button */} + +
+ + {/* Help Text */} +
+

Cần trợ giúp?

+
    +
  • • Kiểm tra thư mục spam/rác nếu không thấy email
  • +
  • • Link đặt lại sẽ hết hạn sau 24 giờ
  • +
  • • Liên hệ hỗ trợ nếu vẫn gặp vấn đề
  • +
+
+ + {/* Japanese Learning Motivation */} +
+
🔑
+

+ 大丈夫! (Daijoubu!) +
+ Đừng lo! Chúng tôi sẽ giúp bạn quay lại học tập. +

+
+
+
+
+
+ ) +} diff --git a/src/components/auth/Login-form.tsx b/src/components/auth/Login-form.tsx index 8d17ef7..718368e 100644 --- a/src/components/auth/Login-form.tsx +++ b/src/components/auth/Login-form.tsx @@ -3,7 +3,7 @@ import type React from "react" import { useState } from "react" -import { Link } from "react-router-dom" +import { Link, useNavigate} from "react-router-dom" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" @@ -11,9 +11,11 @@ import { Label } from "@/components/ui/label" import { Separator } from "@/components/ui/separator" import { Alert, AlertDescription } from "@/components/ui/alert" import { BookOpen, Mail, Lock, Eye, EyeOff, AlertCircle } from "lucide-react" -import { loginWithEmail, loginWithGoogle } from "@/lib/auth" +import {loginWithGoogle } from "@/lib/auth" import type { LoginCredentials, LoginError } from "@/types/auth" import { GoogleIcon } from "../icons/google-icon" +import { useAPI } from "@/hooks/useAPI" +import URLMapping from "@/utils/URLMapping" export function LoginForm() { const [formData, setFormData] = useState({ @@ -23,6 +25,9 @@ export function LoginForm() { const [showPassword, setShowPassword] = useState(false) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) + const { API } = useAPI(); + const navigate = useNavigate(); + const [message, setMessage] = useState(""); const handleInputChange = (e: React.ChangeEvent) => { const { name, value } = e.target @@ -51,23 +56,24 @@ export function LoginForm() { return true } - const handleEmailLogin = async (e: React.FormEvent) => { - e.preventDefault() - if (!validateForm()) return - - setIsLoading(true) - setError(null) + const handleEmailLogin = async () => { + const payload = { + email: formData.email, + password: formData.password, + } - try { - await loginWithEmail(formData.email, formData.password) - // Redirect will be handled by the auth function - } catch (err) { - setError({ - field: "general", - message: err instanceof Error ? err.message : "Login failed. Please try again.", - }) - } finally { - setIsLoading(false) + const response = await API.post(URLMapping.LOGIN, payload); + if (response.success) { + const { jwtToken, username } = response.data; + + // Lưu token và user info + localStorage.setItem("token", jwtToken); + localStorage.setItem("user", JSON.stringify({ username })); + navigate(`/home`); + }else{ + const message= response.message; + console.log(message); + setMessage(message); } } @@ -96,9 +102,9 @@ export function LoginForm() {
- Welcome Back! + Chào mừng! - Sign in to continue your Japanese learning journey + Đăng nhập để có thể trải nghiệm các khóa học của chúng tôi
@@ -112,16 +118,14 @@ export function LoginForm() { disabled={isLoading} > - Continue with Google + Đăng nhập với Google
-
- Or continue with email -
+
{/* Error Alert */} @@ -133,10 +137,10 @@ export function LoginForm() { )} {/* Email Login Form */} -
+
@@ -144,12 +148,11 @@ export function LoginForm() { id="email" name="email" type="email" - placeholder="Enter your email" + placeholder="Nhập email" value={formData.email} onChange={handleInputChange} - className={`pl-10 h-12 ${ - error?.field === "email" ? "border-red-500 focus:border-red-500 focus:ring-red-500" : "" - }`} + className={`pl-10 h-12 ${error?.field === "email" ? "border-red-500 focus:border-red-500 focus:ring-red-500" : "" + }`} disabled={isLoading} />
@@ -163,7 +166,7 @@ export function LoginForm() {
@@ -171,12 +174,11 @@ export function LoginForm() { id="password" name="password" type={showPassword ? "text" : "password"} - placeholder="Enter your password" + placeholder="Nhập mật khẩu" value={formData.password} onChange={handleInputChange} - className={`pl-10 pr-10 h-12 ${ - error?.field === "password" ? "border-red-500 focus:border-red-500 focus:ring-red-500" : "" - }`} + className={`pl-10 pr-10 h-12 ${error?.field === "password" ? "border-red-500 focus:border-red-500 focus:ring-red-500" : "" + }`} disabled={isLoading} />
- - Forgot password? + + Quên Mật Khẩu?
- +
+ {message} +

- Don't have an account?{" "} + Không có tài khoản?{" "} - Sign up here + Đăng ký ngay

@@ -242,8 +246,6 @@ export function LoginForm() {
🎌

頑張って! (Ganbatte!) -
- Let's continue learning Japanese together!

diff --git a/src/components/auth/Register-form.tsx b/src/components/auth/Register-form.tsx index 60f99bd..9b090d7 100644 --- a/src/components/auth/Register-form.tsx +++ b/src/components/auth/Register-form.tsx @@ -14,10 +14,13 @@ import { BookOpen, Mail, Lock, Eye, EyeOff, AlertCircle, User } from "lucide-rea import { GoogleIcon } from "@/components/icons/google-icon" import { registerWithEmail, loginWithGoogle } from "@/lib/auth" import type { RegisterData, RegisterError } from "@/types/auth" +import URLMapping from "@/utils/URLMapping" +import { useAPI } from "@/hooks/useAPI" +import { useNavigate } from "react-router-dom" + export function RegisterForm() { const [formData, setFormData] = useState({ - name: "", email: "", password: "", confirmPassword: "", @@ -26,6 +29,8 @@ export function RegisterForm() { const [showConfirmPassword, setShowConfirmPassword] = useState(false) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) + const { API } = useAPI(); + const navigate = useNavigate(); const handleInputChange = (e: React.ChangeEvent) => { const { name, value } = e.target @@ -34,10 +39,6 @@ export function RegisterForm() { } const validateForm = (): boolean => { - if (!formData.name.trim()) { - setError({ field: "name", message: "Name is required" }) - return false - } if (!formData.email) { setError({ field: "email", message: "Email is required" }) return false @@ -60,41 +61,34 @@ export function RegisterForm() { } return true } - - const handleEmailRegister = async (e: React.FormEvent) => { - e.preventDefault() - if (!validateForm()) return - - setIsLoading(true) - setError(null) + const registerWithEmail = async (): Promise => { + const payload = { + email: formData.email, + password: formData.password, + }; try { - await registerWithEmail(formData) - } catch (err) { - setError({ - field: "general", - message: err instanceof Error ? err.message : "Registration failed. Please try again.", - }) - } finally { - setIsLoading(false) - } - } - - const handleGoogleRegister = async () => { - setIsLoading(true) - setError(null) + setIsLoading(true); + const response = await API.post(URLMapping.REGISTER, payload); - try { - await loginWithGoogle() - } catch (err) { + if (response.success) { + localStorage.setItem("email", formData.email); // <-- bạn nên set từ formData + navigate(`/verify`); + } else { + setError({ + field: "general", + message: response.message || "Registration failed", + }); + } + } catch (err: any) { setError({ field: "general", - message: err instanceof Error ? err.message : "Google registration failed. Please try again.", - }) + message: err?.message || "Registration failed", + }); } finally { - setIsLoading(false) + setIsLoading(false); } - } + }; return ( @@ -105,8 +99,7 @@ export function RegisterForm() {
- Create Account - Start your Japanese learning journey today + Tạo tài khoản của bạn
@@ -114,11 +107,10 @@ export function RegisterForm() {
@@ -126,7 +118,7 @@ export function RegisterForm() {
- Or create with email + Hoặc đăng ký với email
@@ -137,10 +129,18 @@ export function RegisterForm() { )} -
+ { + e.preventDefault(); + if (validateForm()) { + registerWithEmail(); + } + }} + className="space-y-4" + >
@@ -148,12 +148,11 @@ export function RegisterForm() { id="email" name="email" type="email" - placeholder="Enter your email" + placeholder="Nhập email của bạn" value={formData.email} onChange={handleInputChange} - className={`pl-10 h-12 ${ - error?.field === "email" ? "border-red-500 focus:border-red-500 focus:ring-red-500" : "" - }`} + className={`pl-10 h-12 ${error?.field === "email" ? "border-red-500 focus:border-red-500 focus:ring-red-500" : "" + }`} disabled={isLoading} />
@@ -167,7 +166,7 @@ export function RegisterForm() {
@@ -175,12 +174,11 @@ export function RegisterForm() { id="password" name="password" type={showPassword ? "text" : "password"} - placeholder="Create a password" + placeholder="Nhập mật khẩu" value={formData.password} onChange={handleInputChange} - className={`pl-10 pr-10 h-12 ${ - error?.field === "password" ? "border-red-500 focus:border-red-500 focus:ring-red-500" : "" - }`} + className={`pl-10 pr-10 h-12 ${error?.field === "password" ? "border-red-500 focus:border-red-500 focus:ring-red-500" : "" + }`} disabled={isLoading} />

- Already have an account?{" "} + Bạn đã có tài khoản?{" "} - Sign in here + Đăng nhập ngay

@@ -283,11 +261,17 @@ export function RegisterForm() {
🎌

ようこそ! (Youkoso!) -
- Welcome to your Japanese learning adventure!

) } +function setIsLoading(arg0: boolean) { + throw new Error("Function not implemented.") +} + +function setError(arg0: null) { + throw new Error("Function not implemented.") +} + diff --git a/src/components/auth/ResetPass-form.tsx b/src/components/auth/ResetPass-form.tsx new file mode 100644 index 0000000..58b8d52 --- /dev/null +++ b/src/components/auth/ResetPass-form.tsx @@ -0,0 +1,348 @@ +"use client" + +import type React from "react" +import { useState } from "react" +import { Link } from "react-router-dom" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Label } from "@/components/ui/label" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Mail, Lock, Eye, EyeOff, AlertCircle, CheckCircle, ArrowLeft, BookOpen, Shield } from "lucide-react" +import { useAPI } from "@/hooks/useAPI" +import URLMapping from "@/utils/URLMapping" +import { useNavigate } from "react-router-dom" + +interface ResetPasswordData { + otp: string + newPassword: string + confirmPassword: string +} + +interface ResetPasswordError { + field: string + message: string +} + +export function ResetPasswordForm() { + const [formData, setFormData] = useState({ + otp: "", + newPassword: "", + confirmPassword: "", + }) + const [showPasswords, setShowPasswords] = useState({ + new: false, + confirm: false, + }) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(false) + const [message, setMessage] = useState("") + + const { API } = useAPI() + const navigate = useNavigate() + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target + + // Chỉ cho phép nhập số cho OTP + if (name === "otp") { + const numericValue = value.replace(/\D/g, "").slice(0, 6) + setFormData((prev) => ({ ...prev, [name]: numericValue })) + } else { + setFormData((prev) => ({ ...prev, [name]: value })) + } + + // Clear error when user starts typing + if (error) setError(null) + if (message) setMessage("") + } + + const togglePasswordVisibility = (field: keyof typeof showPasswords) => { + setShowPasswords((prev) => ({ ...prev, [field]: !prev[field] })) + } + + const validateForm = (): boolean => { + + if (!formData.otp) { + setError({ field: "otp", message: "Vui lòng nhập mã OTP" }) + return false + } + + if (formData.otp.length !== 6) { + setError({ field: "otp", message: "Mã OTP phải có 6 số" }) + return false + } + + if (!formData.newPassword) { + setError({ field: "newPassword", message: "Vui lòng nhập mật khẩu mới" }) + return false + } + + if (formData.newPassword.length < 6) { + setError({ field: "newPassword", message: "Mật khẩu mới phải có ít nhất 6 ký tự" }) + return false + } + + if (!formData.confirmPassword) { + setError({ field: "confirmPassword", message: "Vui lòng xác nhận mật khẩu mới" }) + return false + } + + if (formData.newPassword !== formData.confirmPassword) { + setError({ field: "confirmPassword", message: "Mật khẩu xác nhận không khớp" }) + return false + } + + return true + } + + const handleResetPassword = async () => { + if (!validateForm()) return + + setIsLoading(true) + setError(null) + setMessage("") + const email= localStorage.getItem("email"); + try { + const response = await API.post(URLMapping.RESET_PASSWORD + `?email=${email}&otp=${formData.otp}&newPassword=${formData.newPassword}`) + + if (response.success) { + setSuccess(true) + // Reset form + setFormData({ + otp: "", + newPassword: "", + confirmPassword: "", + }) + } else { + const message = response.message + setMessage(message) + } + } catch (err) { + setError({ + field: "general", + message: "Có lỗi xảy ra. Vui lòng thử lại.", + }) + } finally { + setIsLoading(false) + } + } + + if (success) { + return ( +
+ + +
+
+ +
+

Đặt Lại Mật Khẩu Thành Công!

+

Mật khẩu của bạn đã được đặt lại thành công.

+
+ + +
+
+
+ ) + } + + return ( +
+
+ {/* Header */} +
+
+ +

日本語学習

+
+

Học Tiếng Nhật Online

+
+ + + +
+
+ +
+
+
+ Đặt Lại Mật Khẩu + + Nhập email, mã OTP và mật khẩu mới để đặt lại mật khẩu + +
+
+ + + {/* Error Alert */} + {error && error.field === "general" && ( + + + {error.message} + + )} + +
+ + {/* OTP */} +
+ +
+ + +
+ {error?.field === "otp" && ( +

+ + {error.message} +

+ )} +

Nhập mã OTP 6 số đã được gửi đến email của bạn

+
+ + {/* New Password */} +
+ +
+ + + +
+ {error?.field === "newPassword" && ( +

+ + {error.message} +

+ )} +

Mật khẩu phải có ít nhất 6 ký tự

+
+ + {/* Confirm Password */} +
+ +
+ + + +
+ {error?.field === "confirmPassword" && ( +

+ + {error.message} +

+ )} +
+ + {/* Error Message */} + {message && ( +
+ {message} +
+ )} + + {/* Reset Password Button */} + +
+ + {/* Help Text */} +
+

Lưu ý:

+
    +
  • • Mã OTP có hiệu lực trong 10 phút
  • +
  • • Mật khẩu mới phải có ít nhất 6 ký tự
  • +
  • • Mật khẩu xác nhận phải trùng với mật khẩu mới
  • +
  • • Kiểm tra email nếu chưa nhận được mã OTP
  • +
+
+ + {/* Japanese Learning Motivation */} +
+
🔄
+

+ 新しい始まり! (Atarashii hajimari!) +
+ Khởi đầu mới! Hãy tiếp tục hành trình học tập. +

+
+
+
+
+
+ ) +} diff --git a/src/components/auth/Verify.tsx b/src/components/auth/Verify.tsx new file mode 100644 index 0000000..ec52d5a --- /dev/null +++ b/src/components/auth/Verify.tsx @@ -0,0 +1,90 @@ +"use client" + +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { BookOpen, Shield } from "lucide-react" +import { useNavigate } from "react-router-dom" +import URLMapping from "@/utils/URLMapping" +import { useAPI } from "@/hooks/useAPI" +import { message } from "antd" + +export default function SimpleVerify() { + const [otp, setOtp] = useState(""); + const navigate = useNavigate(); + const { API } = useAPI(); + const message = localStorage.getItem("message"); + + const handleVerify = async () => { + const email = localStorage.getItem("email"); + + try { + const response = await API.post(URLMapping.VEFIRY + `?email=${email}&otp=${otp}`); + + if (!response.success) { + localStorage.setItem("message", response.message); + navigate(`/verify`) + } else { + navigate(`/login`); + } + } catch (error) { + console.error("Lỗi xác thực:", error); + } + }; + + + + return ( +
+
+ {/* Header */} +
+
+ +

日本語学習

+
+

Học Tiếng Nhật Online

+
+ + + +
+
+ +
+
+ Xác Thực OTP + Nhập mã xác thực để tiếp tục +
+ + + {/* OTP Input */} +
+ + setOtp(e.target.value)} + className="text-center text-lg border-red-300 focus:border-red-500 focus:ring-red-500" + /> +
+
+ {message} +
+ {/* Verify Button */} + +
+
+
+
+ ) +} diff --git a/src/components/layout/AuthSection.tsx b/src/components/layout/AuthSection.tsx new file mode 100644 index 0000000..ccf6517 --- /dev/null +++ b/src/components/layout/AuthSection.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { User, Settings, LogOut } from "lucide-react"; + +interface UserInfo { + username: string; + avatarUrl?: string; +} + +export function AuthSection() { + const [user, setUser] = useState(null); + const [isOpen, setIsOpen] = useState(false); + const navigate = useNavigate(); + + useEffect(() => { + const token = localStorage.getItem("token"); + const userData = localStorage.getItem("user"); + + if (token && userData) { + try { + const parsed = JSON.parse(userData); + setUser({ + username: parsed.username, + avatarUrl: parsed.avatarUrl, + }); + } catch { + setUser(null); + } + } + }, []); + + const handleLogout = () => { + localStorage.removeItem("token"); + localStorage.removeItem("user"); + localStorage.removeItem("avatar"); + setUser(null); + navigate("/login"); + }; + + if (!user) { + return ( + + ); + } + + return ( + +
setIsOpen(true)} + onMouseLeave={() => setIsOpen(false)} + > + +
+ + + {user.username.charAt(0).toUpperCase()} + + {user.username} +
+
+ + +
+ + + + +
+
+
+
+ + ); +} diff --git a/src/components/layout/AutoLayout.tsx b/src/components/layout/AutoLayout.tsx new file mode 100644 index 0000000..883472a --- /dev/null +++ b/src/components/layout/AutoLayout.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import SidebarLayout from "./SidebarLayout"; +import HeaderLayout from "./HeaderLayout"; +import { Grid } from "antd"; + +interface ProtectedRouteProps { + children: React.ReactNode; +} + +const { useBreakpoint } = Grid; + +const AutoLayout: React.FC = ({ children }) => { + const screens = useBreakpoint(); + const LayoutComponent = screens.lg ? SidebarLayout : HeaderLayout; + + return {children}; +}; + +export default AutoLayout; diff --git a/src/components/layout/HeaderLayout.tsx b/src/components/layout/HeaderLayout.tsx new file mode 100644 index 0000000..44fb543 --- /dev/null +++ b/src/components/layout/HeaderLayout.tsx @@ -0,0 +1,177 @@ +import React, { useState } from "react"; +import { + UserOutlined, + LogoutOutlined, + GlobalOutlined, + ArrowLeftOutlined, + LoginOutlined, + SunOutlined, + MoonOutlined, + EllipsisOutlined, + BankOutlined, + DownOutlined, + PlusOutlined, +} from "@ant-design/icons"; +import { + Layout, + Menu, + Breadcrumb, + theme, + Button, + Space, + Dropdown, + Grid, +} from "antd"; +import { Link, useLocation, useNavigate } from "react-router-dom"; +import DynamicBreadcrumb from "@/app/Common/DynamicBreadcrumb"; +import { Constant } from "@/utils/constant"; +import { MenuItemsStaff, MenuItemsHeader } from "./MenuItems"; +import { useTheme } from "@/hooks"; + +const { Header, Content, Footer } = Layout; +const { useBreakpoint } = Grid; + +const HeaderLayout: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const location = useLocation(); + const navigate = useNavigate(); + const { + token: { colorBgContainer, borderRadiusLG }, + } = theme.useToken(); + // const { toggleTheme, isDarkMode } = useTheme(); + const screens = useBreakpoint(); + const MenuItems = MenuItemsHeader + + + return ( + +
+
+ {screens.md ? ( + + Logo + + ) : ( +
+ +
+ + {( + <> +
+
+ + + +
+ {children} +
+
+
+ ); +}; + +export default HeaderLayout; diff --git a/src/components/layout/MenuItems.tsx b/src/components/layout/MenuItems.tsx new file mode 100644 index 0000000..020ed98 --- /dev/null +++ b/src/components/layout/MenuItems.tsx @@ -0,0 +1,95 @@ +import { + RobotOutlined, + SettingOutlined, + DashboardOutlined, + MessageOutlined, + DollarOutlined, + BankOutlined, + TeamOutlined, + FundViewOutlined, + BulbOutlined, + FileAddOutlined, + LockOutlined, + ShopOutlined, +} from "@ant-design/icons"; + +export const MenuItemsStaff= [ + { + label: "Danh Sách khóa học", + key: "course-list", + icon: , + path: "/viewlistcourse", + }, + { + label: "Thêm khóa học", + key: "addnew", + icon: , + path: "/addnew", + }, + { + label: "Phản hồi từ Manager", + key: "live-chat", + icon: , + path: "/feedback", + + }, + { + label: "Evaluation", + key: "evaluation", + icon: , + path: "/evaluation", + + }, + { + label: "Marketplace", + key: "marketplace", + icon: , + path: "/marketplace", + + }, + { + label: "Invoices", + key: "invoice", + icon: , + path: "/invoice", + + }, +]; +export const MenuItemsHeader = [ + { + label: "Dashboard", + key: "dashboard", + icon: , + path: "/dashboard", + + }, + + { + label: "Chat Tracking", + key: "live-chat", + icon: , + path: "/live-chat", + + }, + { + label: "Evaluation", + key: "evaluation", + icon: , + path: "/evaluation", + + }, + { + label: "Marketplace", + key: "marketplace", + icon: , + path: "/marketplace", + + }, + { + label: "Invoices", + key: "invoice", + icon: , + path: "/invoice", + + }, +]; diff --git a/src/components/layout/SidebarLayout.tsx b/src/components/layout/SidebarLayout.tsx new file mode 100644 index 0000000..82f77b1 --- /dev/null +++ b/src/components/layout/SidebarLayout.tsx @@ -0,0 +1,241 @@ +import React, { useState } from "react"; +import { + HomeOutlined, + UserOutlined, + SettingOutlined, + TrophyOutlined, + LogoutOutlined, + MenuUnfoldOutlined, + MenuFoldOutlined, + GlobalOutlined, + ArrowLeftOutlined, + StarOutlined, + LoginOutlined, + AppstoreOutlined, + MoonOutlined, + SunOutlined, + DownOutlined, + BankOutlined, + PlusOutlined, +} from "@ant-design/icons"; +import { + Layout, + Menu, + Breadcrumb, + theme, + Button, + Space, + Dropdown, + Modal, + Form, + Input, + message, + notification, +} from "antd"; +import { Link, useLocation, useNavigate } from "react-router-dom"; +import DynamicBreadcrumb from "@/app/Common/DynamicBreadcrumb"; +import { Constant } from "@/utils/constant"; +import { MenuItemsStaff } from "./MenuItems"; +import { useTheme } from "@/hooks"; + +const { Header, Content, Footer, Sider } = Layout; + +const SidebarLayout: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const location = useLocation(); + const [collapsed, setCollapsed] = useState( + localStorage.getItem("collapsed") === "true" + ); + const { + token: { colorBgContainer, borderRadiusLG }, + } = theme.useToken(); + const navigate = useNavigate(); + // const { toggleTheme, isDarkMode } = useTheme(); + + const [isAddOrgModalVisible, setIsAddOrgModalVisible] = useState(false); + const [addOrgForm] = Form.useForm(); + const [isCreatingOrg, setIsCreatingOrg] = useState(false); + const MenuItems = MenuItemsStaff + + + const handleModalCancel = () => { + setIsAddOrgModalVisible(false); + addOrgForm.resetFields(); + }; + + return ( + + +
+ + + +
+ item.key === location.pathname.split("/").pop() + )?.key || + MenuItems.find((item) => location.pathname.includes(item.key)) + ?.key || + "", + ]} + mode="inline" + style={{ backgroundColor: Constant.THEME.DARK_BG }} + > + {MenuItems.map( + (item) => + ( + + {item.label} + + ) + )} + + +
+
+
+ + +
+
+
+ + + + +
+ {children} +
+
+ + + ); +}; + +export default SidebarLayout; diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx index 04a2766..e77f736 100644 --- a/src/components/layout/header.tsx +++ b/src/components/layout/header.tsx @@ -4,6 +4,7 @@ import { useState } from "react" import { Form, Link } from 'react-router-dom'; import { Button } from "@/components/ui/button" import { Menu, X, BookOpen } from "lucide-react" +import { AuthSection } from "./AuthSection"; export function Header() { const [isMenuOpen, setIsMenuOpen] = useState(false) @@ -36,17 +37,8 @@ export function Header() { ))} - {/* Mobile Menu Button */} @@ -69,16 +61,8 @@ export function Header() { {item.label} ))} - diff --git a/src/components/sections/chatbot.tsx b/src/components/sections/chatbot.tsx deleted file mode 100644 index 3a8e4e0..0000000 --- a/src/components/sections/chatbot.tsx +++ /dev/null @@ -1,452 +0,0 @@ -"use client" - -import type React from "react" - -import { useState, useRef, useEffect } from "react" -import { - Send, - Bot, - User, - Loader2, - Plus, - MessageSquare, - Trash2, - MoreHorizontal, - Search, - Settings, - Menu, - X, -} from "lucide-react" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Badge } from "@/components/ui/badge" -import { ScrollArea } from "@/components/ui/scroll-area" -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" - -interface Message { - id: string - content: string - role: "user" | "assistant" - timestamp: Date - isTyping?: boolean -} - -interface ChatSession { - id: string - title: string - lastMessage: string - timestamp: Date - messages: Message[] -} - -const initialMessage: Message = { - id: "1", - content: - "Xin chào! Tôi là AI Assistant của trung tâm tiếng Nhật. Tôi có thể giúp bạn tìm hiểu về các khóa học, lịch học, và trả lời mọi câu hỏi về việc học tiếng Nhật. Bạn cần hỗ trợ gì hôm nay? 🇯🇵", - role: "assistant", - timestamp: new Date(), -} - -const sampleChatSessions: ChatSession[] = [ - { - id: "1", - title: "Tư vấn khóa học N5", - lastMessage: "Cảm ơn bạn đã tư vấn!", - timestamp: new Date(Date.now() - 1000 * 60 * 30), // 30 minutes ago - messages: [initialMessage], - }, - { - id: "2", - title: "Lịch học và giá cả", - lastMessage: "Tôi sẽ cân nhắc và liên hệ lại", - timestamp: new Date(Date.now() - 1000 * 60 * 60 * 2), // 2 hours ago - messages: [initialMessage], - }, - { - id: "3", - title: "Phương pháp học hiệu quả", - lastMessage: "Có thể giải thích thêm về Kanji không?", - timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24), // 1 day ago - messages: [initialMessage], - }, -] - -export default function Chatbot() { - const [chatSessions, setChatSessions] = useState(sampleChatSessions) - const [currentChatId, setCurrentChatId] = useState("1") - const [inputValue, setInputValue] = useState("") - const [isLoading, setIsLoading] = useState(false) - const [sidebarOpen, setSidebarOpen] = useState(true) - const [searchQuery, setSearchQuery] = useState("") - const messagesEndRef = useRef(null) - const inputRef = useRef(null) - - const currentChat = chatSessions.find((chat) => chat.id === currentChatId) - const currentMessages = currentChat?.messages || [initialMessage] - - const filteredChatSessions = chatSessions.filter( - (chat) => - chat.title.toLowerCase().includes(searchQuery.toLowerCase()) || - chat.lastMessage.toLowerCase().includes(searchQuery.toLowerCase()), - ) - - const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) - } - - useEffect(() => { - scrollToBottom() - }, [currentMessages]) - - const generateAIResponse = async (userMessage: string): Promise => { - await new Promise((resolve) => setTimeout(resolve, 1000 + Math.random() * 2000)) - - const responses = { - greeting: [ - "Chào bạn! Tôi rất vui được hỗ trợ bạn về việc học tiếng Nhật. 🎌", - "Xin chào! Tôi có thể giúp bạn tìm hiểu về các khóa học tiếng Nhật của chúng tôi. ✨", - ], - course: [ - "Chúng tôi có các khóa học từ N5 đến N1, phù hợp với mọi trình độ. Khóa N5 dành cho người mới bắt đầu với 800 từ vựng cơ bản và 45 ngữ pháp. Bạn muốn tìm hiểu khóa nào cụ thể? 📚", - "Các khóa học của chúng tôi được thiết kế theo chuẩn JLPT với giáo viên bản ngữ. Có khóa cơ bản (N5-N4), trung cấp (N3-N2) và nâng cao (N1). Bạn đang ở trình độ nào? 🎯", - ], - default: [ - "Đó là một câu hỏi hay! Tôi sẽ cố gắng trả lời tốt nhất có thể. Bạn có thể cung cấp thêm chi tiết để tôi hỗ trợ chính xác hơn không? 🤔", - "Cảm ơn bạn đã hỏi. Tôi hiểu bạn muốn biết về điều này. Bạn có thể liên hệ với tư vấn viên để được hỗ trợ chi tiết hơn. 📞", - ], - } - - const lowerMessage = userMessage.toLowerCase() - - if (lowerMessage.includes("chào") || lowerMessage.includes("hello") || lowerMessage.includes("hi")) { - return responses.greeting[Math.floor(Math.random() * responses.greeting.length)] - } else if ( - lowerMessage.includes("khóa học") || - lowerMessage.includes("course") || - lowerMessage.includes("n5") || - lowerMessage.includes("n4") || - lowerMessage.includes("jlpt") - ) { - return responses.course[Math.floor(Math.random() * responses.course.length)] - } else { - return responses.default[Math.floor(Math.random() * responses.default.length)] - } - } - - const handleSendMessage = async (content: string) => { - if (!content.trim() || isLoading) return - - const userMessage: Message = { - id: Date.now().toString(), - content: content.trim(), - role: "user", - timestamp: new Date(), - } - - // Update current chat with new message - setChatSessions((prev) => - prev.map((chat) => - chat.id === currentChatId - ? { - ...chat, - messages: [...chat.messages, userMessage], - lastMessage: content.trim(), - timestamp: new Date(), - } - : chat, - ), - ) - - setInputValue("") - setIsLoading(true) - - // Add typing indicator - const typingMessage: Message = { - id: "typing", - content: "", - role: "assistant", - timestamp: new Date(), - isTyping: true, - } - - setChatSessions((prev) => - prev.map((chat) => (chat.id === currentChatId ? { ...chat, messages: [...chat.messages, typingMessage] } : chat)), - ) - - try { - const aiResponse = await generateAIResponse(content) - - // Remove typing indicator and add real response - setChatSessions((prev) => - prev.map((chat) => - chat.id === currentChatId - ? { - ...chat, - messages: chat.messages - .filter((msg) => msg.id !== "typing") - .concat({ - id: Date.now().toString(), - content: aiResponse, - role: "assistant", - timestamp: new Date(), - }), - lastMessage: aiResponse.slice(0, 50) + "...", - timestamp: new Date(), - } - : chat, - ), - ) - } catch (error) { - setChatSessions((prev) => - prev.map((chat) => - chat.id === currentChatId - ? { - ...chat, - messages: chat.messages - .filter((msg) => msg.id !== "typing") - .concat({ - id: Date.now().toString(), - content: "Xin lỗi, tôi gặp sự cố kỹ thuật. Vui lòng thử lại sau. 😅", - role: "assistant", - timestamp: new Date(), - }), - } - : chat, - ), - ) - } finally { - setIsLoading(false) - } - } - - const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault() - handleSendMessage(inputValue) - } - } - - const createNewChat = () => { - const newChat: ChatSession = { - id: Date.now().toString(), - title: "Cuộc trò chuyện mới", - lastMessage: "Bắt đầu cuộc trò chuyện...", - timestamp: new Date(), - messages: [initialMessage], - } - setChatSessions((prev) => [newChat, ...prev]) - setCurrentChatId(newChat.id) - } - - const deleteChat = (chatId: string) => { - setChatSessions((prev) => prev.filter((chat) => chat.id !== chatId)) - if (currentChatId === chatId) { - const remainingChats = chatSessions.filter((chat) => chat.id !== chatId) - if (remainingChats.length > 0) { - setCurrentChatId(remainingChats[0].id) - } else { - createNewChat() - } - } - } - - const formatTime = (date: Date) => { - const now = new Date() - const diff = now.getTime() - date.getTime() - const minutes = Math.floor(diff / (1000 * 60)) - const hours = Math.floor(diff / (1000 * 60 * 60)) - const days = Math.floor(diff / (1000 * 60 * 60 * 24)) - - if (minutes < 1) return "Vừa xong" - if (minutes < 60) return `${minutes} phút trước` - if (hours < 24) return `${hours} giờ trước` - if (days < 7) return `${days} ngày trước` - return date.toLocaleDateString("vi-VN") - } - - return ( -
- {/* Sidebar */} -
- {/* Sidebar Header */} -
-
-

Lịch sử chat

- -
- - - -
- - setSearchQuery(e.target.value)} - className="pl-10 text-sm" - /> -
-
- - {/* Chat Sessions List */} - -
- {filteredChatSessions.map((chat) => ( -
setCurrentChatId(chat.id)} - > -
-
-
- -

{chat.title}

-
-

{chat.lastMessage}

-

{formatTime(chat.timestamp)}

-
- - - - - - -
-
- ))} -
-
-
- - {/* Main Chat Area */} -
- {/* Chat Header */} -
-
-
- {!sidebarOpen && ( - - )} -
-
- -
-
-

AI Assistant

-
-
- Đang hoạt động -
-
-
-
- -
- 🇯🇵 Tiếng Nhật - -
-
-
- - {/* Messages */} -
- -
- {currentMessages.map((message) => ( -
- {message.role === "assistant" && ( -
- -
- )} - -
- {message.isTyping ? ( -
- - Đang trả lời... -
- ) : ( - <> -

{message.content}

-

- {message.timestamp.toLocaleTimeString("vi-VN", { - hour: "2-digit", - minute: "2-digit", - })} -

- - )} -
- - {message.role === "user" && ( -
- -
- )} -
- ))} -
-
- -
- - {/* Input */} -
-
-
-
- setInputValue(e.target.value)} - onKeyPress={handleKeyPress} - placeholder="Nhập tin nhắn của bạn..." - disabled={isLoading} - className="pr-12 py-3 rounded-2xl border-gray-300 focus:border-red-400 focus:ring-red-400" - /> - -
-
-

- AI có thể mắc lỗi. Vui lòng kiểm tra thông tin quan trọng. -

-
-
-
-
- ) -} diff --git a/src/components/sections/common/chatbot.tsx b/src/components/sections/common/chatbot.tsx new file mode 100644 index 0000000..942065f --- /dev/null +++ b/src/components/sections/common/chatbot.tsx @@ -0,0 +1,643 @@ +"use client" + +import type React from "react" +import { useState, useRef, useEffect } from "react" +import { + Send, + Bot, + User, + Loader2, + Plus, + MessageSquare, + Trash2, + MoreHorizontal, + Settings, + Menu, + X, + Edit3, + Check, + XCircle, + RefreshCw, +} from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Textarea } from "@/components/ui/textarea" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { API_URL_AI } from "@/utils/URLMapping" +import { useAPI } from "@/hooks" + +interface Message { + id: number + content: string + type: "human" | "ai" + timestamp: Date + isTyping?: boolean + isEditing?: boolean +} + +interface ChatSession { + id: number + session_name: string + updated_at: string + messages: Message[] +} + +export default function ChatbotLocalhost() { + const [chatSessions, setChatSessions] = useState([]) + const [currentChatId, setCurrentChatId] = useState(null) + const [inputValue, setInputValue] = useState("") + const [isLoading, setIsLoading] = useState(false) + const [sidebarOpen, setSidebarOpen] = useState(true) + const [editingMessageId, setEditingMessageId] = useState(null) + const [editingContent, setEditingContent] = useState("") + const [editingSessionId, setEditingSessionId] = useState(null) + const [editingSessionName, setEditingSessionName] = useState("") + const [error, setError] = useState("") + const { API } = useAPI() + const [currentMessages, setCurrentMessages] = useState([]) + + const messagesEndRef = useRef(null) + const inputRef = useRef(null) + + const currentChat = chatSessions.find((chat) => chat.id === currentChatId) + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) + } + + useEffect(() => { + scrollToBottom() + console.log(currentMessages, chatSessions) + }, [currentMessages]) + + useEffect(() => { + setCurrentMessages(currentChat?.messages || []) + }, [currentChat]) + + // Load chat sessions on component mount + useEffect(() => { + loadChatSessions() + }, []) + + const loadChatSessions = async () => { + setError("") + try { + const response = await API.get(`/sessions/user/1`, API_URL_AI) + if (response.success && response.sessions) { + const sessions = response.sessions.map((session: any) => ({ + ...session, + messages: [], + })) + setChatSessions(sessions) + if (sessions.length > 0) { + setCurrentChatId(sessions[0].id) + loadChatMessages(sessions[0].id) + } + } else { + setError("Không thể tải danh sách chat") + } + } catch (error) { + setError("Lỗi kết nối đến server") + console.error("Error loading chat sessions:", error) + } + } + + const loadChatMessages = async (sessionId: number) => { + try { + const response = await API.get(`/sessions/${sessionId}/history`, API_URL_AI) + if (response.success && response.messages) { + const messages = response.messages.map((msg: any, index: number) => ({ + id: `${sessionId}-${index}`, + content: msg.content, + type: msg.type, + timestamp: new Date(), + })) + setChatSessions((prev) => prev.map((chat) => (chat.id === sessionId ? { ...chat, messages } : chat))) + } + } catch (error) { + console.error("Error loading messages:", error) + } + setCurrentChatId(sessionId) + } + + const createNewChat = async () => { + setError("") + try { + const payload = { + user_id: "1", + session_name: "Cuộc trò chuyện mới", + } + const response = await API.post("sessions/", payload, API_URL_AI) + + if (response.success) { + await loadChatSessions() + } else { + setError("Không thể tạo chat mới") + } + } catch (error) { + setError("Lỗi khi tạo chat mới") + console.error("Error creating new chat:", error) + } + } + + const deleteChat = async (sessionId: number) => { + const confirmDelete = window.confirm("Bạn có chắc muốn xóa cuộc trò chuyện này?"); + if (!confirmDelete) return; + + setError(""); + try { + await API.delete(`sessions/${sessionId}`, sessionId, API_URL_AI); // không kiểm tra response.success + window.location.reload(); // ✅ đơn giản và hiệu quả + } catch (error) { + setError("Lỗi khi xóa chat"); + console.error("Error deleting chat:", error); + } + }; + + const handleEditSessionName = async (sessionId: number, newName: string) => { + if (!newName.trim()) return + + try { + const response = await API.put(`sessions/${sessionId}/rename`, { new_name: newName.trim() }, API_URL_AI) + if (response.success) { + loadChatMessages(sessionId); + setChatSessions((prev) => + prev.map((chat) => (chat.id === sessionId ? { ...chat, session_name: response.session_name } : chat)), + ) + setEditingSessionId(null) + setEditingSessionName(response.session_name) + } else { + setError("Không thể cập nhật tên session") + } + } catch (error) { + setError("Lỗi khi cập nhật tên session") + console.error("Error updating session name:", error) + } + } + + const handleSendMessage = async (content: string) => { + if (!content.trim() || isLoading || !currentChatId) return + + const userMessage: Message = { + id: currentChatId, + content: content.trim(), + type: "human", + timestamp: new Date(), + } + + // Add user message to current chat + setChatSessions((prev) => + prev.map((chat) => (chat.id === currentChatId ? { ...chat, messages: [...chat.messages, userMessage] } : chat)), + ) + + setInputValue("") + setIsLoading(true) + setError("") + + // Add typing indicator + const typingMessage: Message = { + id: -1, + content: "", + type: "ai", + timestamp: new Date(), + isTyping: true, + } + + setChatSessions((prev) => + prev.map((chat) => (chat.id === currentChatId ? { ...chat, messages: [...chat.messages, typingMessage] } : chat)), + ) + + try { + const payload = { + session_id: currentChatId + "", + user_input: content.trim(), + } + + const response = await API.post("/chat/invoke", payload, API_URL_AI) + + if (response.success) { + const aiMessage: Message = { + id: response.session_id, + content: response.ai_response, + type: "ai", + timestamp: new Date(), + } + + // Remove typing indicator and add AI response + setChatSessions((prev) => + prev.map((chat) => + chat.id === currentChatId + ? { + ...chat, + messages: chat.messages.filter((message) => message.id != -1).concat(aiMessage), + } + : chat, + ), + ) + } else { + throw new Error(response.error || "API call failed") + } + } catch (error) { + setError("Lỗi khi gửi tin nhắn") + console.error("Error sending message:", error) + + const errorMessage: Message = { + id: currentChatId, + content: "Xin lỗi, tôi gặp sự cố kỹ thuật. Vui lòng thử lại sau. 😅", + type: "ai", + timestamp: new Date(), + } + + setChatSessions((prev) => + prev.map((chat) => + chat.id === currentChatId + ? { + ...chat, + messages: chat.messages.filter((message) => message.id != -1).concat(errorMessage), + } + : chat, + ), + ) + } finally { + setIsLoading(false) + } + } + + const handleEditAndResubmit = async (newContent: string) => { + if (!currentChatId || !newContent.trim()) return + + setIsLoading(true) + setError("") + + try { + const payload = { + session_id: currentChatId, + corrected_input: newContent.trim(), + } + + const response = await API.post("/chat/edit_and_resubmit", payload, API_URL_AI) + + if (response.success) { + // Reload messages to get updated conversation + await loadChatMessages(currentChatId) + } else { + setError("Không thể chỉnh sửa và gửi lại tin nhắn") + } + } catch (error) { + setError("Lỗi khi chỉnh sửa tin nhắn") + console.error("Error editing and resubmitting:", error) + } finally { + setIsLoading(false) + setEditingMessageId(null) + setEditingContent("") + } + } + + const handleEditMessage = (messageId: number, content: string) => { + // Chỉ cho phép edit tin nhắn cuối cùng của human + const humanMessages = currentMessages.filter((msg) => msg.type === "human") + const lastHumanMessage = humanMessages[humanMessages.length - 1] + + if (messageId === lastHumanMessage?.id) { + setEditingMessageId(messageId) + setEditingContent(content) + } + } + + const handleSaveEdit = async () => { + if (!editingMessageId || !currentChatId) return + + // Check if this is the last human message + const humanMessages = currentMessages.filter((msg) => msg.type === "human") + const lastHumanMessage = humanMessages[humanMessages.length - 1] + + if (editingMessageId === lastHumanMessage?.id) { + // Use edit_and_resubmit API for the last message + await handleEditAndResubmit(editingContent) + } else { + // For other messages, just update locally + setChatSessions((prev) => + prev.map((chat) => + chat.id === currentChatId + ? { + ...chat, + messages: chat.messages.map((msg) => + msg.id === editingMessageId ? { ...msg, content: editingContent } : msg, + ), + } + : chat, + ), + ) + setEditingMessageId(null) + setEditingContent("") + } + } + + const handleCancelEdit = () => { + setEditingMessageId(null) + setEditingContent("") + } + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + handleSendMessage(inputValue) + } + } + + const formatTime = (date: Date) => { + const now = new Date() + const diff = now.getTime() - date.getTime() + const minutes = Math.floor(diff / (1000 * 60)) + const hours = Math.floor(diff / (1000 * 60 * 60)) + const days = Math.floor(diff / (1000 * 60 * 60 * 24)) + + if (minutes < 1) return "Vừa xong" + if (minutes < 60) return `${minutes} phút trước` + if (hours < 24) return `${hours} giờ trước` + if (days < 7) return `${days} ngày trước` + return date.toLocaleDateString("vi-VN") + } + + return ( +
+ {/* Sidebar */} +
+ {/* Sidebar Header */} +
+
+
+
+ +
+
+

Trợ lý AI

+
+
+ +
+ + +
+ + {/* Chat Sessions List */} + +
+ {chatSessions.map((chat) => ( +
{ + if (editingSessionId !== chat.id) { + loadChatMessages(chat.id) + } + }} + > +
+
+
+ + {editingSessionId === chat.id ? ( +
+ setEditingSessionName(e.target.value)} + className="text-sm h-6 flex-1" + onKeyPress={(e) => { + if (e.key === "Enter") { + handleEditSessionName(chat.id, editingSessionName) + } + if (e.key === "Escape") { + setEditingSessionId(null) + setEditingSessionName("") + } + }} + onBlur={() => setEditingSessionId(null)} + autoFocus + /> + +
+ ) : ( +

{chat.session_name}

+ )} +
+

{formatTime(new Date(chat.updated_at))}

+
+ + + + + + { + e.stopPropagation() + setEditingSessionId(chat.id) + setEditingSessionName(chat.session_name) + }} + > + + Sửa tên + + { + e.stopPropagation() + deleteChat(chat.id) + }} + className="text-red-600" + > + + Xóa + + + +
+
+ ))} +
+
+
+ + {/* Main Chat Area */} +
+ {/* Chat Header */} +
+
+
+ {!sidebarOpen && ( + + )} +
+
+ 🇯🇵 Tiếng Nhật + +
+
+
+ + {/* Error Alert */} + {error && ( +
+ + {error} + +
+ )} + + {/* Messages */} +
+ +
+ {currentMessages.map((message, index) => { + const lastHumanIndex = + currentMessages.length - + 1 - + currentMessages + .slice() + .reverse() + .findIndex((m) => m.type === "human") + const isLastHumanMessage = message.type === "human" && index === lastHumanIndex + + return ( +
+ {message.type === "ai" && ( +
+ +
+ )} +
+ {message.isTyping ? ( +
+ + Đang trả lời... +
+ ) : editingMessageId === message.id ? ( +
+