diff --git a/.env b/.env index 4ca20c7..76d77ec 100644 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ -VITE_API_BASE_URL=http://127.0.0.1:1338 +VITE_API_BASE_URL=http://localhost:1338 # VITE_API_BASE_URL=https://giving-cabbage-d8e17e469b.strapiapp.com VITE_API_REGISTER=/api/auth/local/register diff --git a/package-lock.json b/package-lock.json index 385a5d4..761c4f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "axe-code", "version": "0.0.0", "dependencies": { + "@ant-design/icons": "^6.2.3", "@fortawesome/fontawesome-svg-core": "^6.6.0", "@fortawesome/free-regular-svg-icons": "^7.1.0", "@fortawesome/free-solid-svg-icons": "^6.6.0", @@ -22,6 +23,7 @@ "@tiptap/starter-kit": "^3.20.0", "@xyflow/react": "^12.10.0", "agora-rtc-sdk-ng": "^4.24.0", + "antd": "^6.4.3", "clsx": "^2.1.1", "cross-env": "^7.0.3", "date-fns": "^4.1.0", @@ -133,6 +135,99 @@ "node": ">=6.0.0" } }, + "node_modules/@ant-design/colors": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-8.0.1.tgz", + "integrity": "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.0" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz", + "integrity": "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-2.1.2.tgz", + "integrity": "sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^2.1.2", + "@babel/runtime": "^7.23.2", + "@rc-component/util": "^1.4.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-3.0.1.tgz", + "integrity": "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==", + "license": "MIT", + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.2.3.tgz", + "integrity": "sha512-Pl3aoAtxQeKryYnt6VvDJtOxMOtA8wrRSACe/pTjOAIG3fdHrWm6Ivb4ku9tsFjYroSXBKirvuxG4QkwBXD9gg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/icons-svg": "^4.4.2", + "@rc-component/util": "^1.10.1", + "clsx": "^2.1.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": "2.0.0", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-2.0.0.tgz", + "integrity": "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "clsx": "^2.1.1", + "json2mq": "^0.2.0", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", @@ -423,9 +518,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", - "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -611,6 +706,18 @@ "node": ">=20.19.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/win32-x64": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", @@ -1029,6 +1136,709 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@rc-component/async-validator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.0.tgz", + "integrity": "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/cascader": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@rc-component/cascader/-/cascader-1.15.0.tgz", + "integrity": "sha512-ZzpMtwFCRo3fbXHuDnncARJMZQjdqA2w7aDuPofNQt+aDx39st1hgfIpEwTBLhe2Hqsvs/zOr8RTtgxTkCPySw==", + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.6.0", + "@rc-component/tree": "~1.3.0", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/checkbox": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/checkbox/-/checkbox-2.0.0.tgz", + "integrity": "sha512-3CXGPpAR9gsPKeO2N78HAPOzU30UdemD6HGJoWVJOpa6WleaGB5kzZj3v6bdTZab31YuWgY/RxV3VKPctn0DwQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/collapse": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/collapse/-/collapse-1.2.0.tgz", + "integrity": "sha512-ZRYSKSS39qsFx93p26bde7JUZJshsUBEQRlRXPuJYlAiNX0vyYlF5TsAm8JZN3LcF8XvKikdzPbgAtXSbkLUkw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-3.1.1.tgz", + "integrity": "sha512-OHaCHLHszCegdXmIq2ZRIZBN/EtpT6Wm8SG/gpzLATHbVKc/avvuKi+zlOuk05FTWvgaMmpxAko44uRJ3M+2pg==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.1", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-2.0.1.tgz", + "integrity": "sha512-HyZbYm47s/YqtP6pKXNMjPEMaukyg7P0qVfgMLzr7YiFNMHbK2fKTAGzms9ykfGHSfyf75nBbgWw+hHkp+VImw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/dialog": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@rc-component/dialog/-/dialog-1.9.0.tgz", + "integrity": "sha512-zbAAogkg4kkKum79sLE6M+vq1jSAW25zdkafrahgcTP9t9S//SD634Znd1A4c8F2Gc12ZKnehGLsVaaOvZzD2A==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.3", + "@rc-component/portal": "^2.1.0", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/drawer": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@rc-component/drawer/-/drawer-1.4.2.tgz", + "integrity": "sha512-1ib+fZEp6FBu+YvcIktm+nCQ+Q+qIpwpoaJH6opGr4ofh2QMq+qdr5DLC4oCf5qf3pcWX9lUWPYX652k4ini8Q==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.1.3", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/dropdown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rc-component/dropdown/-/dropdown-1.0.2.tgz", + "integrity": "sha512-6PY2ecUSYhDPhkNHHb4wfeAya04WhpmUSKzdR60G+kMNVUCX2vjT/AgTS0Lz0I/K6xrPMJ3enQbwVpeN3sHCgg==", + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/@rc-component/form": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.1.tgz", + "integrity": "sha512-8O7TB55Fi2mWIGvSnwZjk8jFqVNYyKDAswglwGShcbndxqzKz4cHwNtNaLjZlAeRge9wcB0LL8IWsC/Bl18raQ==", + "license": "MIT", + "dependencies": { + "@rc-component/async-validator": "^5.1.0", + "@rc-component/util": "^1.6.2", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/image": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@rc-component/image/-/image-1.9.0.tgz", + "integrity": "sha512-khF7w7xkBH5B1bsBcI1FSUZdkyd1aqpl2eYyILCqCzzQH3XdfehGUaZTnptyaJJfs09/R5hv9jXWyazOMFIClQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/portal": "^2.1.2", + "@rc-component/util": "^1.10.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/input": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@rc-component/input/-/input-1.3.0.tgz", + "integrity": "sha512-IUUNOdAuWuEvDEFFgfmwQl818tiDbvXwLgon4HL1q2hJeYkqrRrYwYhJN0zfPHGTDxs3gvyVC/C02D4hWFoIcA==", + "license": "MIT", + "dependencies": { + "@rc-component/resize-observer": "^1.1.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/input-number": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@rc-component/input-number/-/input-number-1.6.2.tgz", + "integrity": "sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w==", + "license": "MIT", + "dependencies": { + "@rc-component/mini-decimal": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mentions": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@rc-component/mentions/-/mentions-1.9.0.tgz", + "integrity": "sha512-WUwfFKDSOF5S9UPsNsXcLYtzjTxBGsftTXWRbZuxX6BYrsySISTnujfJNgaaQ6qVzaCDJ35QUkZKvsYxip1C5g==", + "license": "MIT", + "dependencies": { + "@rc-component/input": "~1.3.0", + "@rc-component/menu": "~1.3.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/menu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@rc-component/menu/-/menu-1.3.0.tgz", + "integrity": "sha512-u3NfiwpiEgT177qa5Yxm5QsI8i/93EBGpWj8HYZQDnh2pCZ2xtQCe/+w3pSR2NlwKOZDTCKzEhEyD09mGphssA==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.3.tgz", + "integrity": "sha512-bk/FJ09fLf+NLODMAFll6CfYrHPBioTedhW6lxDBuuWucJEqFUd4l/D/5JgIi3dina6sYahB8iuPAZTNz2pMxw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/motion": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@rc-component/motion/-/motion-1.3.2.tgz", + "integrity": "sha512-itfd+GztzJYAb04Z4RkEub1TbJAfZc2Iuy8p44U44xD1F5+fNYFKI3897ijlbIyfvXkTmMm+KGcjkQQGMHywEQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-2.0.1.tgz", + "integrity": "sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/notification": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@rc-component/notification/-/notification-2.0.7.tgz", + "integrity": "sha512-nqZzpf6BPdaj+3ILx7si79LLmqPKyUmQoXa+/9gg0SkH0v1DbD66oJgRMSBEVnd/zUT3D4gwxWIHUKebYf2ZXQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/overflow": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/overflow/-/overflow-1.0.1.tgz", + "integrity": "sha512-syfmgAABaHCnCDzPwHZ/2tuvIcpOO3jefYZMmfkN+pmo8HKTzsfhS57vxo4ksPdN0By+uWVJhJWNFozNBxi2eA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/pagination": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/pagination/-/pagination-1.2.0.tgz", + "integrity": "sha512-YcpUFE8dMLfSo6OARJlK6DbHHvrxz7pMGPGmC/caZSJJz6HRKHC1RPP001PRHCvG9Z/veD039uOQmazVuLJzlw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/picker": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@rc-component/picker/-/picker-1.10.0.tgz", + "integrity": "sha512-vVOXP2RVWozwpERGUFAehVH1Jz6o/uRrAb9qSZm1LC+iJs8rvEwFo1bzz2jlOYV+uWwu0dIuG86tnDui14Ea0w==", + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/trigger": "^3.6.15", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.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-component/portal": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-2.2.0.tgz", + "integrity": "sha512-oc6FlA+uXCMiwArHsJyHcIkX4q6uKyndrPol2eWX8YPkAnztHOPsFIRtmWG4BMlGE5h7YIRE3NiaJ5VS8Lb1QQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/progress": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rc-component/progress/-/progress-1.0.2.tgz", + "integrity": "sha512-WZUnH9eGxH1+xodZKqdrHke59uyGZSWgj5HBM5Kwk5BrTMuAORO7VJ2IP5Qbm9aH3n9x3IcesqHHR0NWPBC7fQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.1.tgz", + "integrity": "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/rate": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/rate/-/rate-1.0.1.tgz", + "integrity": "sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/resize-observer": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/resize-observer/-/resize-observer-1.1.2.tgz", + "integrity": "sha512-t/Bb0W8uvL4PYKAB3YcChC+DlHh0Wt5kM7q/J+0qpVEUMLe7Hk5zuvc9km0hMnTFPSx5Z7Wu/fzCLN6erVLE8Q==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/segmented": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@rc-component/segmented/-/segmented-1.3.0.tgz", + "integrity": "sha512-5J/bJ01mbDnoA6P/FW8SxUvKn+OgUSTZJPzCNnTBntG50tzoP7DydGhqxp7ggZXZls7me3mc2EQDXakU3iTVFg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/select": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.6.15.tgz", + "integrity": "sha512-SyVCWnqxCQZZcQvQJ/CxSjx2bGma6ds/HtnpkIfZVnt6RoEgbqUmHgD6vrzNarNXwbLXerwVzWwq8F3d1sst7g==", + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.3.0", + "@rc-component/virtual-list": "^1.0.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/slider": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/slider/-/slider-1.0.1.tgz", + "integrity": "sha512-uDhEPU1z3WDfCJhaL9jfd2ha/Eqpdfxsn0Zb0Xcq1NGQAman0TWaR37OWp2vVXEOdV2y0njSILTMpTfPV1454g==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/steps": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rc-component/steps/-/steps-1.2.2.tgz", + "integrity": "sha512-/yVIZ00gDYYPHSY0JP+M+s3ZvuXLu2f9rEjQqiUDs7EcYsUYrpJ/1bLj9aI9R7MBR3fu/NGh6RM9u2qGfqp+Nw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/switch": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rc-component/switch/-/switch-1.0.3.tgz", + "integrity": "sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/table": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@rc-component/table/-/table-1.10.0.tgz", + "integrity": "sha512-SjtpcCf+rL7dDc62GKT3rXTdERjVuJvRiqjpU7g0Jc/ewCifXynHc7Nm3Em1XsD+WhGrgQtxNDScI/0+Lpfr0w==", + "license": "MIT", + "dependencies": { + "@rc-component/context": "^2.0.1", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.1.0", + "@rc-component/virtual-list": "^1.0.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tabs": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@rc-component/tabs/-/tabs-1.9.0.tgz", + "integrity": "sha512-tn1slmbbaTyt8mgwyWJcT8jo/qNiYUs6u1H7OgGQt9faYO06BJIkU5cTmMqORzIrNmSEeeUY6pD5i+JlqSHYhg==", + "license": "MIT", + "dependencies": { + "@rc-component/dropdown": "~1.0.0", + "@rc-component/menu": "~1.3.0", + "@rc-component/motion": "^1.1.3", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tooltip": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/tooltip/-/tooltip-1.4.0.tgz", + "integrity": "sha512-8Rx5DCctIlLI4raR0I0xHjVTf1aF48+gKCNeAAo5bmF5VoR5YED+A/XEqzXv9KKqrJDRcd3Wndpxh2hyzrTtSg==", + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.7.1", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-2.4.0.tgz", + "integrity": "sha512-aui4r4TqmTzwaBgcQxHYep8kM8PTjZFufjokObpy35KfFeZ0k9ArquWFZqegQlH24P14t+F0qO0mGTgzlav1yg==", + "license": "MIT", + "dependencies": { + "@rc-component/portal": "^2.2.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.7.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tree": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@rc-component/tree/-/tree-1.3.1.tgz", + "integrity": "sha512-zlL0PW0bTFlveTtLcA01VD/yMWKK73EywItFMgIZUY5sb6tMOAw7zV6qGzqldufqrV93ZWQB4H3NBNoTMCueJA==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/util": "^1.8.1", + "@rc-component/virtual-list": "^1.0.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/tree-select": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@rc-component/tree-select/-/tree-select-1.9.0.tgz", + "integrity": "sha512-GXcFe15a+trUl1/J3OHWQhsVWFpwFpGFK2cqYWZ1sK22Zs3KZTvMwDpzr75PIo1s6QVioVxpE/pRwRopkeDQ6w==", + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.6.0", + "@rc-component/tree": "~1.3.0", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/trigger": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-3.9.0.tgz", + "integrity": "sha512-X8btpwfrT27AgrZVOz4swclhEHTZcqaHeQMXXBgveagOiakTa36uObXbdwerXffgV8G9dH1fAAE0DHtVQs8EHg==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.2.0", + "@rc-component/resize-observer": "^1.1.1", + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/upload": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/upload/-/upload-1.1.0.tgz", + "integrity": "sha512-LIBV90mAnUE6VK5N4QvForoxZc4XqEYZimcp7fk+lkE4XwHHyJWxpIXQQwMU8hJM+YwBbsoZkGksL1sISWHQxw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/util": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.11.1.tgz", + "integrity": "sha512-awVlI3ub2vqfqkYxOBc/uQ0efm3jw0wcrhtO/YWLyZfxiKXczKwNbVuhlnyxytDt7H9pbbVQiqr+O6MLATtRYg==", + "license": "MIT", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/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-component/virtual-list": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.1.0.tgz", + "integrity": "sha512-fSzVg8xFDCK+xKJJ9lljBpaGDmur4REZwXHJjsNFRi4UbfOXuH/FSbGC2oliCE5krFqM3lSZqB4Ly4vc3xQHOQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz", @@ -2475,6 +3285,69 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/antd": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/antd/-/antd-6.4.3.tgz", + "integrity": "sha512-6H2avkxCGfxcF67r3J2mwm9Ck50el1pks/73vfM1wDsPL/tPtj5vHuauMgJFnrqmq7CH3g8aoZ0VBQbt+jpAsw==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/cssinjs": "^2.1.2", + "@ant-design/cssinjs-utils": "^2.1.2", + "@ant-design/fast-color": "^3.0.1", + "@ant-design/icons": "^6.2.3", + "@ant-design/react-slick": "~2.0.0", + "@babel/runtime": "^7.29.2", + "@rc-component/cascader": "~1.15.0", + "@rc-component/checkbox": "~2.0.0", + "@rc-component/collapse": "~1.2.0", + "@rc-component/color-picker": "~3.1.1", + "@rc-component/dialog": "~1.9.0", + "@rc-component/drawer": "~1.4.2", + "@rc-component/dropdown": "~1.0.2", + "@rc-component/form": "~1.8.1", + "@rc-component/image": "~1.9.0", + "@rc-component/input": "~1.3.0", + "@rc-component/input-number": "~1.6.2", + "@rc-component/mentions": "~1.9.0", + "@rc-component/menu": "~1.3.0", + "@rc-component/motion": "^1.3.2", + "@rc-component/mutate-observer": "^2.0.1", + "@rc-component/notification": "~2.0.7", + "@rc-component/pagination": "~1.2.0", + "@rc-component/picker": "~1.10.0", + "@rc-component/progress": "~1.0.2", + "@rc-component/qrcode": "~1.1.1", + "@rc-component/rate": "~1.0.1", + "@rc-component/resize-observer": "^1.1.2", + "@rc-component/segmented": "~1.3.0", + "@rc-component/select": "~1.6.15", + "@rc-component/slider": "~1.0.1", + "@rc-component/steps": "~1.2.2", + "@rc-component/switch": "~1.0.3", + "@rc-component/table": "~1.10.0", + "@rc-component/tabs": "~1.9.0", + "@rc-component/tooltip": "~1.4.0", + "@rc-component/tour": "~2.4.0", + "@rc-component/tree": "~1.3.1", + "@rc-component/tree-select": "~1.9.0", + "@rc-component/trigger": "^3.9.0", + "@rc-component/upload": "~1.1.0", + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1", + "dayjs": "^1.11.11", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2718,6 +3591,12 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, + "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", @@ -2957,6 +3836,12 @@ "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -4056,6 +4941,12 @@ "node": ">=0.10.0" } }, + "node_modules/is-mobile": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz", + "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", + "license": "MIT" + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -4171,6 +5062,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", @@ -5573,6 +6473,15 @@ "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "license": "MIT" }, + "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/sdp": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz", @@ -5780,6 +6689,12 @@ "dev": true, "license": "MIT" }, + "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/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -5823,6 +6738,12 @@ "inline-style-parser": "0.2.4" } }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -5872,6 +6793,15 @@ "url": "https://opencollective.com/webpack" } }, + "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/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/package.json b/package.json index b1e31a7..b7ed7eb 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "node": ">=20.0.0" }, "dependencies": { + "@ant-design/icons": "^6.2.3", "@fortawesome/fontawesome-svg-core": "^6.6.0", "@fortawesome/free-regular-svg-icons": "^7.1.0", "@fortawesome/free-solid-svg-icons": "^6.6.0", @@ -28,6 +29,7 @@ "@tiptap/starter-kit": "^3.20.0", "@xyflow/react": "^12.10.0", "agora-rtc-sdk-ng": "^4.24.0", + "antd": "^6.4.3", "clsx": "^2.1.1", "cross-env": "^7.0.3", "date-fns": "^4.1.0", diff --git a/src/core/hooks/useProjectRole.js b/src/core/hooks/useProjectRole.js new file mode 100644 index 0000000..f52372b --- /dev/null +++ b/src/core/hooks/useProjectRole.js @@ -0,0 +1,52 @@ +import { useMemo } from 'react'; +import { useRole } from './useRole'; + +/** + * Hook to evaluate the current user's granular permissions within a specific project. + * @param {Object} project - The project object containing publisher and members arrays. + */ +export const useProjectRole = (project) => { + const { user, isAuthenticated } = useRole(); + + return useMemo(() => { + if (!isAuthenticated || !user || !project) { + return { + isPublisher: false, + isMember: false, + permissions: {}, + canWriteTasks: false, + canWriteSprints: false, + canWriteCheckpoints: false, + canManageMembers: false, + isAdmin: false, + }; + } + + // Check if user is publisher + // In Strapi, relations might expose id or documentId or both. + const isPublisher = project.publisher?.id === user.id || project.publisher?.documentId === user.documentId; + + // Check membership + const memberRecord = project.members?.find( + m => m.user?.id === user.id || m.user?.documentId === user.documentId + ); + const isMember = !!memberRecord; + + // Extract granular permissions from the member's project_role + const permissions = memberRecord?.projectRole?.permissions || {}; + + // Admins and publishers have all rights implicitly + const isAdmin = isPublisher || permissions.admin === true; + + return { + isPublisher, + isMember, + permissions, + canWriteTasks: isAdmin || permissions.write_tasks === true, + canWriteSprints: isAdmin || permissions.write_sprints === true, + canWriteCheckpoints: isAdmin || permissions.write_checkpoints === true, + canManageMembers: isAdmin || permissions.manage_members === true, + isAdmin, + }; + }, [project, user, isAuthenticated]); +}; diff --git a/src/domain/entity/JobAdEntity.js b/src/domain/entity/JobAdEntity.js new file mode 100644 index 0000000..32982c0 --- /dev/null +++ b/src/domain/entity/JobAdEntity.js @@ -0,0 +1,25 @@ +export class JobAdEntity { + constructor(data = {}) { + this.type = 'job_ad'; + this.roleId = data.roleId; + this.roleUid = data.roleUid; + this.jobTitle = data.jobTitle; // { slug, labelAr, labelEn } + this.customLabel = data.customLabel; + this.requiredLevel = data.requiredLevel; + this.slots = data.slots; + this.isMatched = data.isMatched; + this.hasApplied = data.hasApplied; + + // Embedded Project summary + this.project = data.project; + /* + { + uid, + title, + methodology, + status, + publisher: { username, uid } + } + */ + } +} diff --git a/src/domain/entity/LessonEntity.js b/src/domain/entity/LessonEntity.js index 4f0e7ef..3174ec0 100644 --- a/src/domain/entity/LessonEntity.js +++ b/src/domain/entity/LessonEntity.js @@ -23,6 +23,19 @@ export class LessonEntity extends ContentEntity { this.description = props.description; this.isPublic = props.isPublic || false; this.instructor = props.instructor; + + // Embedded video support + this.embedSource = props.embedSource || null; + this.embedUrl = props.embedUrl || null; + this.embedMetadata = props.embedMetadata || null; + } + + /** + * Checks if the lesson is an embedded external video. + * @returns {boolean} + */ + get isEmbedded() { + return this.type === 'embedded'; } /** @@ -40,4 +53,21 @@ export class LessonEntity extends ContentEntity { get isArticle() { return this.type === 'article'; } + + /** + * Returns the provider-specific embed URL for iframe rendering. + * Falls back to raw embedUrl if metadata is missing. + * @returns {string|null} + */ + get embedPlayerUrl() { + return this.embedMetadata?.embedUrl || this.embedUrl || null; + } + + /** + * Returns the cached thumbnail from the provider. + * @returns {string|null} + */ + get embedThumbnail() { + return this.embedMetadata?.thumbnail || null; + } } diff --git a/src/domain/entity/ProjectEntity.js b/src/domain/entity/ProjectEntity.js new file mode 100644 index 0000000..28e578c --- /dev/null +++ b/src/domain/entity/ProjectEntity.js @@ -0,0 +1,73 @@ +export class ProjectEntity { + constructor(data = {}) { + this.id = data.id; + this.uid = data.uid; + this.title = data.title; + this.description = data.description; + this.methodology = data.methodology; + this.status = data.status; + this.visibility = data.visibility; + this.githubRepoUrl = data.githubRepoUrl; + this.githubRepoId = data.githubRepoId; + this.engagementScore = data.engagementScore; + this.createdAt = data.createdAt; + this.updatedAt = data.updatedAt; + + this.publisher = data.publisher; // UserEntity + this.roles = data.roles || []; // Array of ProjectRoleEntity + this.members = data.members || []; // Array of ProjectMemberEntity + this.sprints = data.sprints || []; // Array of SprintEntity + this.wikiDocs = data.wikiDocs; + this.architectureDiagram = data.architectureDiagram; + } +} + +export class ProjectRoleEntity { + constructor(data = {}) { + this.id = data.id; + this.uid = data.uid; + this.customLabel = data.customLabel; + this.permissions = data.permissions || {}; + this.isOpen = data.isOpen; + this.requiredLevel = data.requiredLevel; + this.slots = data.slots; + this.jobTitleTag = data.jobTitleTag; // JobTitleTagEntity + } +} + +export class ProjectMemberEntity { + constructor(data = {}) { + this.id = data.id; + this.uid = data.uid; + this.user = data.user; // UserEntity + this.projectRole = data.projectRole; // ProjectRoleEntity + this.githubUsername = data.githubUsername; + this.isActive = data.isActive; + this.joinedAt = data.joinedAt; + } +} + +export class ProjectApplicationEntity { + constructor(data = {}) { + this.id = data.id; + this.uid = data.uid; + this.type = data.type; + this.status = data.status; + this.message = data.message; + this.createdAt = data.createdAt; + this.user = data.user; // UserEntity + this.project = data.project; // ProjectEntity + this.projectRole = data.projectRole; // ProjectRoleEntity + } +} + +export class JobTitleTagEntity { + constructor(data = {}) { + this.id = data.id; + this.uid = data.uid; + this.slug = data.slug; + this.labelAr = data.labelAr; + this.labelEn = data.labelEn; + this.category = data.category; + } +} diff --git a/src/domain/entity/ReviewRequestEntity.js b/src/domain/entity/ReviewRequestEntity.js new file mode 100644 index 0000000..b577609 --- /dev/null +++ b/src/domain/entity/ReviewRequestEntity.js @@ -0,0 +1,21 @@ +export class ReviewRequestEntity { + constructor(data = {}) { + this.id = data.id; + this.uid = data.uid; + this.projectId = data.projectId; + this.taskId = data.taskId; + this.task = data.task; // TaskEntity + this.workspaceItemId = data.workspaceItemId; + this.workspaceItem = data.workspaceItem; // WorkspaceItemEntity + this.externalLink = data.externalLink; + this.requesterId = data.requesterId; + this.requester = data.requester; // ProjectMemberEntity + this.reviewerId = data.reviewerId; + this.reviewer = data.reviewer; // ProjectMemberEntity + this.status = data.status; // pending, approved, changes_requested, rejected + this.message = data.message; + this.feedback = data.feedback; + this.createdAt = data.createdAt; + this.updatedAt = data.updatedAt; + } +} diff --git a/src/domain/entity/TaskEntity.js b/src/domain/entity/TaskEntity.js new file mode 100644 index 0000000..e2d4e3d --- /dev/null +++ b/src/domain/entity/TaskEntity.js @@ -0,0 +1,58 @@ +export class TaskEntity { + constructor(data = {}) { + this.id = data.id; + this.uid = data.uid; + this.title = data.title; + this.description = data.description; + this.status = data.status; // todo, in_progress, in_review, done, blocked + this.priority = data.priority; + this.branchPattern = data.branchPattern; + this.githubPrId = data.githubPrId; + this.ciStatus = data.ciStatus; + this.lastCommitSha = data.lastCommitSha; // latest commit SHA (short) + this.prUrl = data.prUrl; // GitHub PR URL + this.prTitle = data.prTitle; // GitHub PR title + this.order = data.order; + this.createdAt = data.createdAt; + this.updatedAt = data.updatedAt; + + this.assignee = data.assignee; // ProjectMemberEntity + this.sprint = data.sprint; // SprintEntity + this.projectId = data.projectId; + + this.taskType = data.taskType || 'general'; + this.layerId = data.layerId; + this.stage = data.stage || 'planning'; + this.checkpointId = data.checkpointId; // documentId of parent checkpoint + this.checkpoint = data.checkpoint; // parent CheckpointEntity + } +} + +export class SprintEntity { + constructor(data = {}) { + this.id = data.id; + this.uid = data.uid; + this.number = data.number; + this.goal = data.goal; + this.status = data.status; // planned, active, completed + this.startDate = data.startDate; + this.endDate = data.endDate; + this.retrospectiveNotes = data.retrospectiveNotes; + this.projectId = data.projectId; + } +} + +export class CheckpointEntity { + constructor(data = {}) { + this.id = data.id; + this.uid = data.uid; + this.name = data.name; + this.stage = data.stage; + this.createdAt = data.createdAt; + this.updatedAt = data.updatedAt; + this.sprint = data.sprint; // SprintEntity + this.tasks = data.tasks || []; // TaskEntity[] + this.projectId = data.projectId; + } +} + diff --git a/src/domain/entity/WorkspaceItemEntity.js b/src/domain/entity/WorkspaceItemEntity.js new file mode 100644 index 0000000..7e7bf84 --- /dev/null +++ b/src/domain/entity/WorkspaceItemEntity.js @@ -0,0 +1,14 @@ +export class WorkspaceItemEntity { + constructor(data = {}) { + this.id = data.id; + this.uid = data.uid; + this.projectId = data.projectId; + this.projectMemberId = data.projectMemberId; + this.type = data.type; // document, flowchart + this.title = data.title; + this.content = data.content; // Array of blocks or { nodes, edges } + this.status = data.status; // draft, submitted, merged, rejected + this.linkedTaskId = data.linkedTaskId; + this.linkedTask = data.linkedTask; // TaskEntity + } +} diff --git a/src/domain/interface/IProjectAccess.js b/src/domain/interface/IProjectAccess.js new file mode 100644 index 0000000..70133c0 --- /dev/null +++ b/src/domain/interface/IProjectAccess.js @@ -0,0 +1,7 @@ +export class IProjectAccess { + async getAll(params = {}) { throw new Error("Method not implemented."); } + async getById(id, params = {}) { throw new Error("Method not implemented."); } + async create(data) { throw new Error("Method not implemented."); } + async update(id, data) { throw new Error("Method not implemented."); } + async delete(id) { throw new Error("Method not implemented."); } +} diff --git a/src/domain/interface/IReviewRequestAccess.js b/src/domain/interface/IReviewRequestAccess.js new file mode 100644 index 0000000..9b19a2f --- /dev/null +++ b/src/domain/interface/IReviewRequestAccess.js @@ -0,0 +1,5 @@ +export class IReviewRequestAccess { + async getReviewRequests(projectId) { throw new Error("Method not implemented."); } + async createReviewRequest(projectId, data) { throw new Error("Method not implemented."); } + async resolveReviewRequest(projectId, requestId, data) { throw new Error("Method not implemented."); } +} diff --git a/src/domain/interface/ITaskAccess.js b/src/domain/interface/ITaskAccess.js new file mode 100644 index 0000000..e24a843 --- /dev/null +++ b/src/domain/interface/ITaskAccess.js @@ -0,0 +1,4 @@ +export class ITaskAccess { + async getAll(params = {}) { throw new Error("Method not implemented."); } + async getById(id, params = {}) { throw new Error("Method not implemented."); } +} diff --git a/src/domain/interface/IWorkspaceAccess.js b/src/domain/interface/IWorkspaceAccess.js new file mode 100644 index 0000000..fa61c9f --- /dev/null +++ b/src/domain/interface/IWorkspaceAccess.js @@ -0,0 +1,6 @@ +export class IWorkspaceAccess { + async getWorkspace(projectId) { throw new Error("Method not implemented."); } + async createWorkspaceItem(projectId, data) { throw new Error("Method not implemented."); } + async updateWorkspaceItem(projectId, itemId, data) { throw new Error("Method not implemented."); } + async deleteWorkspaceItem(projectId, itemId) { throw new Error("Method not implemented."); } +} diff --git a/src/domain/mapper/EntityMapper.js b/src/domain/mapper/EntityMapper.js index c09eda7..3908649 100644 --- a/src/domain/mapper/EntityMapper.js +++ b/src/domain/mapper/EntityMapper.js @@ -15,6 +15,12 @@ import { TestCaseEntity } from '../entity/TestCaseEntity'; import { TestCaseDTO } from '@infrastructure/DTO/TestCaseDTO'; import { SubmissionEntity } from '../entity/SubmissionEntity'; import { NotificationEntity } from '../entity/NotificationEntity'; +import { ProjectEntity, ProjectRoleEntity, ProjectMemberEntity, ProjectApplicationEntity, JobTitleTagEntity } from '../entity/ProjectEntity'; +import { TaskEntity, SprintEntity, CheckpointEntity } from '../entity/TaskEntity'; +import { JobAdEntity } from '../entity/JobAdEntity'; +import { WorkspaceItemEntity } from '../entity/WorkspaceItemEntity'; +import { ReviewRequestEntity } from '../entity/ReviewRequestEntity'; + /** * EntityMapper utility for converting DTOs to Domain Entities. @@ -131,7 +137,10 @@ export class EntityMapper { description: dto.description, isPublic: dto.public, instructor: this.toUser(dto.instructor), - isDraft: !!dto.isDraft + isDraft: !!dto.isDraft, + embedSource: dto.embedSource, + embedUrl: dto.embedUrl, + embedMetadata: dto.embedMetadata, }); } @@ -427,7 +436,10 @@ export class EntityMapper { description: dto.description, image: dto.image ? this.toMedia(dto.image) : null, author: dto.publisher ? this.toUser(dto.publisher) : null, - isDraft: !!dto.isDraft + isDraft: !!dto.isDraft, + type: dto.type || 'blog', + projectRole: dto.project_role ? this.toProjectRole(dto.project_role) : null, + project: dto.project ? this.toProject(dto.project) : null }); } @@ -621,4 +633,238 @@ export class EntityMapper { actor: this.toUser(dto.actor) }); } + + /** + * Maps a ProjectDTO to ProjectEntity + */ + static toProject(dto) { + if (!dto) return null; + return new ProjectEntity({ + id: dto.id, + uid: dto.documentId, + title: dto.title, + description: dto.description, + methodology: dto.methodology, + status: dto.status, + visibility: dto.visibility, + githubRepoUrl: dto.github_repo_url, + githubRepoId: dto.github_repo_id, + engagementScore: dto.engagement_score, + createdAt: dto.createdAt, + updatedAt: dto.updatedAt, + publisher: this.toUser(dto.publisher), + roles: Array.isArray(dto.project_roles || dto.roles) ? (dto.project_roles || dto.roles).map(r => this.toProjectRole(r)) : [], + members: Array.isArray(dto.project_members || dto.members) ? (dto.project_members || dto.members).map(m => this.toProjectMember(m)) : [], + sprints: Array.isArray(dto.sprints) ? dto.sprints.map(s => this.toSprint(s)) : [], + wikiDocs: dto.wiki_docs, + architectureDiagram: dto.architecture_diagram + }); + } + + /** + * Maps a ProjectRoleDTO to ProjectRoleEntity + */ + static toProjectRole(dto) { + if (!dto) return null; + return new ProjectRoleEntity({ + id: dto.id, + uid: dto.documentId, + customLabel: dto.custom_label, + permissions: dto.permissions, + isOpen: dto.is_open, + requiredLevel: dto.required_level, + slots: dto.slots, + jobTitleTag: this.toJobTitleTag(dto.job_title_tag) + }); + } + + /** + * Maps a ProjectMemberDTO to ProjectMemberEntity + */ + static toProjectMember(dto) { + if (!dto) return null; + return new ProjectMemberEntity({ + id: dto.id, + uid: dto.documentId, + user: this.toUser(dto.user), + projectRole: this.toProjectRole(dto.project_role), + githubUsername: dto.github_username, + isActive: dto.is_active, + joinedAt: dto.joined_at + }); + } + + /** + * Maps a ProjectApplicationDTO to ProjectApplicationEntity + */ + static toProjectApplication(dto) { + if (!dto) return null; + return new ProjectApplicationEntity({ + id: dto.id, + uid: dto.documentId, + type: dto.type, + status: dto.status, + message: dto.message, + createdAt: dto.createdAt, + user: this.toUser(dto.user), + project: this.toProject(dto.project), + projectRole: this.toProjectRole(dto.project_role) + }); + } + + /** + * Maps a JobTitleTagDTO to JobTitleTagEntity + */ + static toJobTitleTag(dto) { + if (!dto) return null; + return new JobTitleTagEntity({ + id: dto.id, + uid: dto.documentId, + slug: dto.slug, + labelAr: dto.label_ar, + labelEn: dto.label_en, + category: dto.category + }); + } + + /** + * Maps a TaskDTO to TaskEntity + */ + static toTask(dto) { + if (!dto) return null; + return new TaskEntity({ + id: dto.id, + uid: dto.documentId, + title: dto.title, + description: dto.description, + status: dto.status, + priority: dto.priority, + branchPattern: dto.branch_pattern, + githubPrId: dto.github_pr_id, + ciStatus: dto.ci_status, + lastCommitSha: dto.last_commit_sha, + prUrl: dto.pr_url, + prTitle: dto.pr_title, + order: dto.order, + createdAt: dto.createdAt, + updatedAt: dto.updatedAt, + assignee: this.toProjectMember(dto.assignee), + sprint: this.toSprint(dto.sprint), + projectId: dto.projectId, + taskType: dto.task_type, + layerId: dto.layer_id, + stage: dto.stage, + checkpointId: dto.checkpoint?.documentId || (typeof dto.checkpoint === 'string' ? dto.checkpoint : null), + checkpoint: dto.checkpoint ? (typeof dto.checkpoint === 'object' ? this.toCheckpoint(dto.checkpoint) : null) : null + }); + } + + + /** + * Maps a CheckpointDTO to CheckpointEntity + */ + static toCheckpoint(dto) { + if (!dto) return null; + return new CheckpointEntity({ + id: dto.id, + uid: dto.documentId, + name: dto.name, + stage: dto.stage, + createdAt: dto.createdAt, + updatedAt: dto.updatedAt, + sprint: this.toSprint(dto.sprint), + tasks: Array.isArray(dto.tasks) ? dto.tasks.map(t => this.toTask(t)).filter(Boolean) : [], + projectId: dto.projectId + }); + } + + /** + * Maps a SprintDTO to SprintEntity + */ + static toSprint(dto) { + if (!dto) return null; + return new SprintEntity({ + id: dto.id, + uid: dto.documentId, + number: dto.number, + goal: dto.goal, + status: dto.status, + startDate: dto.start_date, + endDate: dto.end_date, + retrospectiveNotes: dto.retrospective_notes, + projectId: dto.projectId + }); + } + + /** + * Maps a JobAdDTO to JobAdEntity + */ + static toJobAd(dto) { + if (!dto) return null; + return new JobAdEntity({ + roleId: dto.role_id, + roleUid: dto.role_documentId, + jobTitle: dto.job_title, + customLabel: dto.custom_label, + requiredLevel: dto.required_level, + slots: dto.slots, + isMatched: dto.is_matched, + hasApplied: dto.has_applied, + project: dto.project ? { + uid: dto.project.documentId, + title: dto.project.title, + methodology: dto.project.methodology, + status: dto.project.status, + publisher: dto.project.publisher ? { + uid: dto.project.publisher.documentId, + username: dto.project.publisher.username + } : null + } : null + }); + } + + /** + * Maps a WorkspaceItemDTO to WorkspaceItemEntity + */ + static toWorkspaceItem(dto) { + if (!dto) return null; + return new WorkspaceItemEntity({ + id: dto.id, + uid: dto.documentId, + projectId: dto.projectId, + projectMemberId: dto.project_member, + type: dto.type, + title: dto.title, + content: dto.content, + status: dto.status, + linkedTaskId: dto.linked_task?.documentId || dto.linked_task, + linkedTask: this.toTask(dto.linked_task) + }); + } + + /** + * Maps a ReviewRequestDTO to ReviewRequestEntity + */ + static toReviewRequest(dto) { + if (!dto) return null; + return new ReviewRequestEntity({ + id: dto.id, + uid: dto.documentId, + projectId: dto.projectId, + taskId: dto.task?.documentId || dto.task, + task: this.toTask(dto.task), + workspaceItemId: dto.workspace_item?.documentId || dto.workspace_item, + workspaceItem: this.toWorkspaceItem(dto.workspace_item), + externalLink: dto.external_link, + requesterId: dto.requester?.documentId || dto.requester, + requester: this.toProjectMember(dto.requester), + reviewerId: dto.reviewer?.documentId || dto.reviewer, + reviewer: this.toProjectMember(dto.reviewer), + status: dto.status, + message: dto.message, + feedback: dto.feedback, + createdAt: dto.createdAt, + updatedAt: dto.updatedAt + }); + } } diff --git a/src/domain/useCase/feed/useFetchJobAds.js b/src/domain/useCase/feed/useFetchJobAds.js new file mode 100644 index 0000000..8343c81 --- /dev/null +++ b/src/domain/useCase/feed/useFetchJobAds.js @@ -0,0 +1,24 @@ +import { useCallback, useMemo } from 'react'; +import { useAsyncUseCase } from '../useAsyncUseCase'; +import { RecommendationRepository } from '@infrastructure/repository/RecommendationRepository'; +import { EntityMapper } from '../../mapper/EntityMapper'; + +export const useFetchJobAds = () => { + const repository = useMemo(() => new RecommendationRepository(), []); + + const fetchLogic = useCallback(async () => { + const result = await repository.getJobAds(4); // limit 4 + return Array.isArray(result) + ? result.map(dto => EntityMapper.toJobAd(dto)).filter(Boolean) + : []; + }, [repository]); + + const { execute: fetchJobAds, returnedData: jobAds, inProgress: loading, error } = useAsyncUseCase(fetchLogic); + + return { + fetchJobAds, + jobAds, + loading, + error + }; +}; diff --git a/src/domain/useCase/project/useCreateProject.js b/src/domain/useCase/project/useCreateProject.js new file mode 100644 index 0000000..a118fa6 --- /dev/null +++ b/src/domain/useCase/project/useCreateProject.js @@ -0,0 +1,21 @@ +import { useCallback, useMemo } from 'react'; +import { useAsyncUseCase } from '../useAsyncUseCase'; +import { repositoryRegistry } from '@infrastructure/repository/RepositoryRegistry'; +import { EntityMapper } from '../../mapper/EntityMapper'; + +export const useCreateProject = () => { + const repository = useMemo(() => repositoryRegistry.projectRepository, []); + + const createLogic = useCallback(async (projectData) => { + const dto = await repository.create(projectData); + return EntityMapper.toProject(dto); + }, [repository]); + + const { execute: createProject, inProgress, error } = useAsyncUseCase(createLogic); + + return { + createProject, + creating: inProgress, + error + }; +}; diff --git a/src/domain/useCase/project/useFetchProjects.js b/src/domain/useCase/project/useFetchProjects.js new file mode 100644 index 0000000..22f7559 --- /dev/null +++ b/src/domain/useCase/project/useFetchProjects.js @@ -0,0 +1,25 @@ +import { useCallback, useMemo } from 'react'; +import { useAsyncUseCase } from '../useAsyncUseCase'; +import { repositoryRegistry } from '@infrastructure/repository/RepositoryRegistry'; +import { EntityMapper } from '../../mapper/EntityMapper'; + +export const useFetchProjects = () => { + const repository = useMemo(() => repositoryRegistry.projectRepository, []); + + const fetchLogic = useCallback(async () => { + const result = await repository.getAll(); + const items = Array.isArray(result) ? result : []; + return items + .map(dto => EntityMapper.toProject(dto)) + .filter(Boolean); + }, [repository]); + + const { execute: fetchProjects, returnedData, inProgress, error } = useAsyncUseCase(fetchLogic); + + return { + fetchProjects, + projects: returnedData, + loading: inProgress, + error + }; +}; diff --git a/src/domain/useCase/project/useJobTitleTags.js b/src/domain/useCase/project/useJobTitleTags.js new file mode 100644 index 0000000..d3460f0 --- /dev/null +++ b/src/domain/useCase/project/useJobTitleTags.js @@ -0,0 +1,51 @@ +import { useCallback, useMemo } from 'react'; +import { useAsyncUseCase } from '../useAsyncUseCase'; +import { repositoryRegistry } from '@infrastructure/repository/RepositoryRegistry'; +import { EntityMapper } from '../../mapper/EntityMapper'; + +export const useJobTitleTags = () => { + const repository = useMemo(() => repositoryRegistry.projectRepository, []); + + // Get static taxonomy + const fetchLogic = useCallback(async () => { + const result = await repository.getJobTitleTags(); + return result.map(dto => EntityMapper.toJobTitleTag(dto)); + }, [repository]); + + const { execute: fetchTags, returnedData: tags, inProgress: loadingTags } = useAsyncUseCase(fetchLogic); + + // Get my chosen titles + const fetchMyTitlesLogic = useCallback(async () => { + // Returns the junction objects directly for the UI + return await repository.getMyJobTitles(); + }, [repository]); + + const { execute: fetchMyTitles, returnedData: myTitles, inProgress: loadingMyTitles } = useAsyncUseCase(fetchMyTitlesLogic); + + // Add my title + const addLogic = useCallback(async (tagId, experienceLevel) => { + return await repository.addMyJobTitle(tagId, experienceLevel); + }, [repository]); + + const { execute: addMyTitle, inProgress: isAdding } = useAsyncUseCase(addLogic); + + // Remove my title + const removeLogic = useCallback(async (tagId) => { + return await repository.removeMyJobTitle(tagId); + }, [repository]); + + const { execute: removeMyTitle, inProgress: isRemoving } = useAsyncUseCase(removeLogic); + + return { + fetchTags, + tags, + loadingTags, + fetchMyTitles, + myTitles, + loadingMyTitles, + addMyTitle, + isAdding, + removeMyTitle, + isRemoving + }; +}; diff --git a/src/domain/useCase/project/useProjectApplications.js b/src/domain/useCase/project/useProjectApplications.js new file mode 100644 index 0000000..c25d93d --- /dev/null +++ b/src/domain/useCase/project/useProjectApplications.js @@ -0,0 +1,54 @@ +import { useCallback, useMemo } from 'react'; +import { useAsyncUseCase } from '../useAsyncUseCase'; +import { repositoryRegistry } from '@infrastructure/repository/RepositoryRegistry'; +import { EntityMapper } from '../../mapper/EntityMapper'; + +export const useProjectApplications = (projectId) => { + const repository = useMemo(() => repositoryRegistry.projectRepository, []); + + // Get Applications + const fetchLogic = useCallback(async () => { + if (!projectId) return []; + const result = await repository.getProjectApplications(projectId); + return result.map(dto => EntityMapper.toProjectApplication(dto)); + }, [repository, projectId]); + + const { execute: fetchApplications, returnedData: applications, inProgress: loadingApplications } = useAsyncUseCase(fetchLogic); + + // Apply + const applyLogic = useCallback(async (data) => { + const result = await repository.applyToRole(projectId, data); + return EntityMapper.toProjectApplication(result); + }, [repository, projectId]); + + const { execute: applyToRole, inProgress: isApplying } = useAsyncUseCase(applyLogic); + + // Respond (Accept/Reject) + const respondLogic = useCallback(async (applicationId, status) => { + const result = await repository.respondToApplication(applicationId, status); + return EntityMapper.toProjectApplication(result); + }, [repository]); + + const { execute: respondToApplication, inProgress: isResponding } = useAsyncUseCase(respondLogic); + + // Get My Applications + const fetchMyApplicationsLogic = useCallback(async () => { + const result = await repository.getMyApplications(); + return (result || []).map(dto => EntityMapper.toProjectApplication(dto)); + }, [repository]); + + const { execute: fetchMyApplications, returnedData: myApplications, inProgress: loadingMyApplications } = useAsyncUseCase(fetchMyApplicationsLogic); + + return { + fetchApplications, + applications, + loadingApplications, + applyToRole, + isApplying, + respondToApplication, + isResponding, + fetchMyApplications, + myApplications, + loadingMyApplications + }; +}; diff --git a/src/domain/useCase/project/useProjectDetails.js b/src/domain/useCase/project/useProjectDetails.js new file mode 100644 index 0000000..359ffc2 --- /dev/null +++ b/src/domain/useCase/project/useProjectDetails.js @@ -0,0 +1,23 @@ +import { useCallback, useMemo } from 'react'; +import { useAsyncUseCase } from '../useAsyncUseCase'; +import { repositoryRegistry } from '@infrastructure/repository/RepositoryRegistry'; +import { EntityMapper } from '../../mapper/EntityMapper'; + +export const useProjectDetails = (projectId) => { + const repository = useMemo(() => repositoryRegistry.projectRepository, []); + + const fetchLogic = useCallback(async () => { + if (!projectId) return null; + const result = await repository.getById(projectId); + return EntityMapper.toProject(result); + }, [repository, projectId]); + + const { execute: fetchDetails, returnedData, inProgress, error } = useAsyncUseCase(fetchLogic); + + return { + fetchDetails, + project: returnedData, + loading: inProgress, + error + }; +}; diff --git a/src/domain/useCase/project/useProjectMembers.js b/src/domain/useCase/project/useProjectMembers.js new file mode 100644 index 0000000..4cd455d --- /dev/null +++ b/src/domain/useCase/project/useProjectMembers.js @@ -0,0 +1,68 @@ +import { useState, useCallback } from 'react'; +import { repositoryRegistry } from '@infrastructure/repository/RepositoryRegistry'; +import { toast } from 'react-hot-toast'; + +export const useProjectMembers = (projectUid) => { + const [roles, setRoles] = useState([]); + const [loadingRoles, setLoadingRoles] = useState(false); + const [isAssigning, setIsAssigning] = useState(false); + const [isCreatingRole, setIsCreatingRole] = useState(false); + const [error, setError] = useState(null); + + const projectRepo = repositoryRegistry.projectRepository; + + const fetchRoles = useCallback(async () => { + if (!projectUid) return; + setLoadingRoles(true); + setError(null); + try { + const data = await projectRepo.getRoles(projectUid); + setRoles(data); + } catch (err) { + console.error('Failed to fetch roles:', err); + setError(err); + } finally { + setLoadingRoles(false); + } + }, [projectUid, projectRepo]); + + const assignRole = async (memberId, roleId) => { + setIsAssigning(true); + try { + await projectRepo.assignMemberRole(projectUid, memberId, roleId); + toast.success('Role assigned successfully!'); + return true; + } catch (err) { + toast.error(err.message || 'Failed to assign role'); + return false; + } finally { + setIsAssigning(false); + } + }; + + const createRole = async (roleData) => { + setIsCreatingRole(true); + try { + await projectRepo.createRole(projectUid, roleData); + toast.success('Role created successfully!'); + fetchRoles(); // Refresh roles list automatically + return true; + } catch (err) { + toast.error(err.message || 'Failed to create role'); + return false; + } finally { + setIsCreatingRole(false); + } + }; + + return { + roles, + loadingRoles, + isAssigning, + isCreatingRole, + error, + fetchRoles, + assignRole, + createRole + }; +}; diff --git a/src/domain/useCase/project/useReviewRequests.js b/src/domain/useCase/project/useReviewRequests.js new file mode 100644 index 0000000..2051d43 --- /dev/null +++ b/src/domain/useCase/project/useReviewRequests.js @@ -0,0 +1,79 @@ +import { useState, useCallback } from 'react'; +import { repositoryRegistry } from '@infrastructure/repository/RepositoryRegistry'; +import { EntityMapper } from '../../mapper/EntityMapper'; +import { toast } from 'react-hot-toast'; + +export const useReviewRequests = (projectId) => { + const [reviewRequests, setReviewRequests] = useState([]); + const [loading, setLoading] = useState(false); + const [resolving, setResolving] = useState(false); + const [error, setError] = useState(null); + + const reviewRepo = repositoryRegistry.reviewRequestRepository; + + const fetchReviewRequests = useCallback(async () => { + if (!projectId) return; + setLoading(true); + setError(null); + try { + const data = await reviewRepo.getReviewRequests(projectId); + const entities = (data || []).map(item => EntityMapper.toReviewRequest(item)).filter(Boolean); + setReviewRequests(entities); + } catch (err) { + console.error('Failed to fetch review requests:', err); + setError(err); + } finally { + setLoading(false); + } + }, [projectId, reviewRepo]); + + const createReviewRequest = async (data) => { + if (!projectId) return null; + setResolving(true); + try { + const response = await reviewRepo.createReviewRequest(projectId, data); + const entity = EntityMapper.toReviewRequest(response); + if (entity) { + setReviewRequests(prev => [entity, ...prev]); + toast.success('Review request submitted successfully!'); + } + return entity; + } catch (err) { + console.error('Failed to create review request:', err); + toast.error(err.message || 'Failed to submit review request'); + return null; + } finally { + setResolving(false); + } + }; + + const resolveReviewRequest = async (requestId, data) => { + if (!projectId) return false; + setResolving(true); + try { + const response = await reviewRepo.resolveReviewRequest(projectId, requestId, data); + const entity = EntityMapper.toReviewRequest(response); + if (entity) { + setReviewRequests(prev => prev.map(req => req.uid === requestId ? entity : req)); + toast.success(`Review successfully resolved: ${data.status}`); + } + return true; + } catch (err) { + console.error('Failed to resolve review request:', err); + toast.error(err.message || 'Failed to resolve review'); + return false; + } finally { + setResolving(false); + } + }; + + return { + reviewRequests, + loading, + resolving, + error, + fetchReviewRequests, + createReviewRequest, + resolveReviewRequest + }; +}; diff --git a/src/domain/useCase/project/useUpdateProject.js b/src/domain/useCase/project/useUpdateProject.js new file mode 100644 index 0000000..0ef73ae --- /dev/null +++ b/src/domain/useCase/project/useUpdateProject.js @@ -0,0 +1,22 @@ +import { useCallback, useMemo } from 'react'; +import { useAsyncUseCase } from '../useAsyncUseCase'; +import { repositoryRegistry } from '@infrastructure/repository/RepositoryRegistry'; +import { EntityMapper } from '../../mapper/EntityMapper'; + +export const useUpdateProject = (projectId) => { + const repository = useMemo(() => repositoryRegistry.projectRepository, []); + + const updateLogic = useCallback(async (data) => { + if (!projectId) throw new Error("Project ID is required to update."); + const result = await repository.update(projectId, data); + return EntityMapper.toProject(result); + }, [repository, projectId]); + + const { execute: updateProject, inProgress: isUpdating, error } = useAsyncUseCase(updateLogic); + + return { + updateProject, + isUpdating, + error + }; +}; diff --git a/src/domain/useCase/project/useWorkspace.js b/src/domain/useCase/project/useWorkspace.js new file mode 100644 index 0000000..b2f87e7 --- /dev/null +++ b/src/domain/useCase/project/useWorkspace.js @@ -0,0 +1,94 @@ +import { useState, useCallback } from 'react'; +import { repositoryRegistry } from '@infrastructure/repository/RepositoryRegistry'; +import { EntityMapper } from '../../mapper/EntityMapper'; +import { toast } from 'react-hot-toast'; + +export const useWorkspace = (projectId) => { + const [workspaceItems, setWorkspaceItems] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const workspaceRepo = repositoryRegistry.workspaceRepository; + + const fetchWorkspace = useCallback(async () => { + if (!projectId) return; + setLoading(true); + setError(null); + try { + const data = await workspaceRepo.getWorkspace(projectId); + const entities = (data || []).map(item => EntityMapper.toWorkspaceItem(item)).filter(Boolean); + setWorkspaceItems(entities); + } catch (err) { + console.error('Failed to fetch workspace:', err); + setError(err); + } finally { + setLoading(false); + } + }, [projectId, workspaceRepo]); + + const createWorkspaceItem = async (data) => { + if (!projectId) return null; + setSaving(true); + try { + const response = await workspaceRepo.createWorkspaceItem(projectId, data); + const entity = EntityMapper.toWorkspaceItem(response); + if (entity) { + setWorkspaceItems(prev => [...prev, entity]); + toast.success('Draft created successfully!'); + } + return entity; + } catch (err) { + console.error('Failed to create workspace item:', err); + toast.error(err.message || 'Failed to create draft'); + return null; + } finally { + setSaving(false); + } + }; + + const updateWorkspaceItem = async (itemId, data) => { + if (!projectId) return false; + setSaving(true); + try { + const response = await workspaceRepo.updateWorkspaceItem(projectId, itemId, data); + const entity = EntityMapper.toWorkspaceItem(response); + if (entity) { + setWorkspaceItems(prev => prev.map(item => item.uid === itemId ? entity : item)); + toast.success('Saved draft successfully!'); + } + return true; + } catch (err) { + console.error('Failed to update workspace item:', err); + toast.error(err.message || 'Failed to save changes'); + return false; + } finally { + setSaving(false); + } + }; + + const deleteWorkspaceItem = async (itemId) => { + if (!projectId) return false; + try { + await workspaceRepo.deleteWorkspaceItem(projectId, itemId); + setWorkspaceItems(prev => prev.filter(item => item.uid !== itemId)); + toast.success('Deleted draft successfully!'); + return true; + } catch (err) { + console.error('Failed to delete workspace item:', err); + toast.error(err.message || 'Failed to delete draft'); + return false; + } + }; + + return { + workspaceItems, + loading, + saving, + error, + fetchWorkspace, + createWorkspaceItem, + updateWorkspaceItem, + deleteWorkspaceItem + }; +}; diff --git a/src/domain/useCase/task/useCheckpoints.js b/src/domain/useCase/task/useCheckpoints.js new file mode 100644 index 0000000..a359147 --- /dev/null +++ b/src/domain/useCase/task/useCheckpoints.js @@ -0,0 +1,53 @@ +import { useCallback, useMemo } from 'react'; +import { useAsyncUseCase } from '../useAsyncUseCase'; +import { repositoryRegistry } from '@infrastructure/repository/RepositoryRegistry'; +import { EntityMapper } from '../../mapper/EntityMapper'; + +export const useCheckpoints = (projectId) => { + const repository = useMemo(() => repositoryRegistry.checkpointRepository, []); + + // Fetch Checkpoints + const fetchLogic = useCallback(async (params = {}) => { + if (!projectId) return []; + const result = await repository.getCheckpoints(projectId, params); + return result.map(dto => EntityMapper.toCheckpoint(dto)).filter(Boolean); + }, [repository, projectId]); + + const { execute: fetchCheckpoints, returnedData: checkpoints, inProgress: loadingCheckpoints, error: checkpointsError } = useAsyncUseCase(fetchLogic); + + // Create Checkpoint + const createLogic = useCallback(async (checkpointData) => { + const result = await repository.createCheckpoint(projectId, checkpointData); + return EntityMapper.toCheckpoint(result); + }, [repository, projectId]); + + const { execute: createCheckpoint, inProgress: isCreating } = useAsyncUseCase(createLogic); + + // Update Checkpoint + const updateLogic = useCallback(async (checkpointId, checkpointData) => { + const result = await repository.updateCheckpoint(projectId, checkpointId, checkpointData); + return EntityMapper.toCheckpoint(result); + }, [repository, projectId]); + + const { execute: updateCheckpoint, inProgress: isUpdating } = useAsyncUseCase(updateLogic); + + // Delete Checkpoint + const deleteLogic = useCallback(async (checkpointId) => { + return await repository.deleteCheckpoint(projectId, checkpointId); + }, [repository, projectId]); + + const { execute: deleteCheckpoint, inProgress: isDeleting } = useAsyncUseCase(deleteLogic); + + return { + fetchCheckpoints, + checkpoints, + loadingCheckpoints, + checkpointsError, + createCheckpoint, + isCreating, + updateCheckpoint, + isUpdating, + deleteCheckpoint, + isDeleting + }; +}; diff --git a/src/domain/useCase/task/useKanbanBoard.js b/src/domain/useCase/task/useKanbanBoard.js new file mode 100644 index 0000000..3c422ff --- /dev/null +++ b/src/domain/useCase/task/useKanbanBoard.js @@ -0,0 +1,89 @@ +import { useCallback, useMemo, useRef } from 'react'; +import { useAsyncUseCase } from '../useAsyncUseCase'; +import { repositoryRegistry } from '@infrastructure/repository/RepositoryRegistry'; +import { EntityMapper } from '../../mapper/EntityMapper'; + +// Auto-refresh interval in milliseconds (15s when tab is visible) +const REFRESH_INTERVAL_MS = 15000; + +export const useKanbanBoard = (projectId) => { + const repository = useMemo(() => repositoryRegistry.taskRepository, []); + + // ─── Fetch tasks ───────────────────────────────────────────────────────── + const fetchLogic = useCallback(async () => { + if (!projectId) return []; + const result = await repository.getTasks(projectId); + return result.map(dto => EntityMapper.toTask(dto)); + }, [repository, projectId]); + + const { + execute: fetchTasks, + returnedData: tasks, + inProgress: loadingTasks, + error: tasksError, + } = useAsyncUseCase(fetchLogic); + + // ─── Silent refresh (no loading spinner) ───────────────────────────────── + // Returns a stable ref so the polling interval can always call the latest version + const fetchTasksRef = useRef(fetchTasks); + fetchTasksRef.current = fetchTasks; + + const startPolling = useCallback(() => { + if (!projectId) return () => {}; + + const id = setInterval(() => { + // Only refresh when the browser tab is visible to avoid wasted requests + if (document.visibilityState === 'visible') { + fetchTasksRef.current?.(); + } + }, REFRESH_INTERVAL_MS); + + return () => clearInterval(id); + }, [projectId]); + + // ─── Transition Task Status ─────────────────────────────────────────────── + const transitionLogic = useCallback(async (taskId, newStatus) => { + const result = await repository.transitionTask(projectId, taskId, newStatus); + return EntityMapper.toTask(result); + }, [repository, projectId]); + + const { execute: transitionTask, inProgress: isTransitioning } = useAsyncUseCase(transitionLogic); + + // ─── Reorder Tasks ──────────────────────────────────────────────────────── + const reorderLogic = useCallback(async (taskOrders) => { + return await repository.reorderTasks(projectId, taskOrders); + }, [repository, projectId]); + + const { execute: reorderTasks, inProgress: isReordering } = useAsyncUseCase(reorderLogic); + + // ─── Create Task ────────────────────────────────────────────────────────── + const createLogic = useCallback(async (taskData) => { + const result = await repository.createTask(projectId, taskData); + return EntityMapper.toTask(result); + }, [repository, projectId]); + + const { execute: createTask, inProgress: isCreatingTask } = useAsyncUseCase(createLogic); + + // ─── Delete Task ────────────────────────────────────────────────────────── + const deleteLogic = useCallback(async (taskId) => { + return await repository.deleteTask(projectId, taskId); + }, [repository, projectId]); + + const { execute: deleteTask, inProgress: isDeletingTask } = useAsyncUseCase(deleteLogic); + + return { + fetchTasks, + tasks, + loadingTasks, + tasksError, + startPolling, // call this in KanbanBoard's useEffect → returns cleanup fn + transitionTask, + isTransitioning, + reorderTasks, + isReordering, + createTask, + isCreatingTask, + deleteTask, + isDeletingTask, + }; +}; diff --git a/src/domain/useCase/task/useSprints.js b/src/domain/useCase/task/useSprints.js new file mode 100644 index 0000000..c07007b --- /dev/null +++ b/src/domain/useCase/task/useSprints.js @@ -0,0 +1,50 @@ +import { useCallback, useMemo } from 'react'; +import { useAsyncUseCase } from '../useAsyncUseCase'; +import { repositoryRegistry } from '@infrastructure/repository/RepositoryRegistry'; +import { EntityMapper } from '../../mapper/EntityMapper'; + +export const useSprints = (projectId) => { + const repository = useMemo(() => repositoryRegistry.taskRepository, []); + + const fetchLogic = useCallback(async () => { + if (!projectId) return []; + const result = await repository.getSprints(projectId); + return result.map(dto => EntityMapper.toSprint(dto)); + }, [repository, projectId]); + + const { execute: fetchSprints, returnedData: sprints, inProgress: loadingSprints } = useAsyncUseCase(fetchLogic); + + const startLogic = useCallback(async (sprintId) => { + const result = await repository.startSprint(projectId, sprintId); + return EntityMapper.toSprint(result); + }, [repository, projectId]); + + const { execute: startSprint, inProgress: isStarting } = useAsyncUseCase(startLogic); + + const updateLogic = useCallback(async (sprintId, data) => { + const result = await repository.updateSprint(projectId, sprintId, data); + return EntityMapper.toSprint(result); + }, [repository, projectId]); + + const { execute: updateSprint, inProgress: isUpdating } = useAsyncUseCase(updateLogic); + + const createLogic = useCallback(async (sprintData) => { + const result = await repository.createSprint(projectId, sprintData); + return EntityMapper.toSprint(result); + }, [repository, projectId]); + + const { execute: createSprint, inProgress: isCreating } = useAsyncUseCase(createLogic); + + return { + fetchSprints, + sprints, + loadingSprints, + startSprint, + isStarting, + updateSprint, + isUpdating, + createSprint, + isCreating + }; +}; + diff --git a/src/domain/useCase/useFetchBlogs.js b/src/domain/useCase/useFetchBlogs.js index e4da6e6..f5f487d 100644 --- a/src/domain/useCase/useFetchBlogs.js +++ b/src/domain/useCase/useFetchBlogs.js @@ -33,7 +33,13 @@ export const useFetchBlogs = () => { fields: ['firstname', 'lastname', 'email', 'username', 'birthday', 'university', 'bio'], populate: ['avatar'] }, - image: true + image: true, + project_role: { + populate: ['job_title_tag'] + }, + project: { + populate: ['publisher'] + } }; const rawData = await repository.getBlogs(limit, excludeIds, feedType, populate); diff --git a/src/domain/useCase/useFetchRoles.js b/src/domain/useCase/useFetchRoles.js new file mode 100644 index 0000000..33e731e --- /dev/null +++ b/src/domain/useCase/useFetchRoles.js @@ -0,0 +1,34 @@ +import { useAsyncUseCase } from './useAsyncUseCase'; +import { RoleRepository } from '../../infrastructure/repository/RoleRepository'; +import { useMemo, useEffect, useCallback } from 'react'; + +/** + * Hook for managing roles. + */ +export const useFetchRoles = () => { + const repository = useMemo(() => new RoleRepository(), []); + + const fetchRoles = useCallback(async () => { + const rawRoles = await repository.getAllRoles(); + // Return structured roles for selection + return rawRoles.map(role => ({ + id: role.id, + name: role.name, + description: role.description, + type: role.type + })); + }, [repository]); + + const { execute, returnedData, inProgress, error } = useAsyncUseCase(fetchRoles); + + useEffect(() => { + execute(); + }, [execute]); + + return { + roles: returnedData || [], + isLoading: inProgress, + error, + reloadRoles: execute + }; +}; diff --git a/src/domain/useCase/useUpdateUserRole.js b/src/domain/useCase/useUpdateUserRole.js new file mode 100644 index 0000000..fd4aeb9 --- /dev/null +++ b/src/domain/useCase/useUpdateUserRole.js @@ -0,0 +1,24 @@ +import { useAsyncUseCase } from './useAsyncUseCase'; +import { UserRepository } from '../../infrastructure/repository/UserRepository'; +import { useMemo, useCallback } from 'react'; + +/** + * Hook to update a user's role. + */ +export const useUpdateUserRole = () => { + const repository = useMemo(() => new UserRepository(), []); + + const updateRole = useCallback(async ({ userId, roleId }) => { + // We pass the new role ID to the user update endpoint + const updatedUser = await repository.updateUser(userId, { role: roleId }); + return updatedUser; + }, [repository]); + + const { execute, inProgress, error } = useAsyncUseCase(updateRole); + + return { + updateUserRole: execute, + isUpdating: inProgress, + error + }; +}; diff --git a/src/infrastructure/DTO/BlogDTO.js b/src/infrastructure/DTO/BlogDTO.js index 8d373e3..b7df9b6 100644 --- a/src/infrastructure/DTO/BlogDTO.js +++ b/src/infrastructure/DTO/BlogDTO.js @@ -14,5 +14,8 @@ export class BlogDTO extends BaseContentDTO { * Relationships */ this.publisher = data.publisher ? new UserDTO(data.publisher) : null; // {UserDTO | null} + this.type = data.type || 'blog'; + this.project_role = data.project_role || null; + this.project = data.project || null; } } diff --git a/src/infrastructure/DTO/CheckpointDTO.js b/src/infrastructure/DTO/CheckpointDTO.js new file mode 100644 index 0000000..7b43401 --- /dev/null +++ b/src/infrastructure/DTO/CheckpointDTO.js @@ -0,0 +1,17 @@ +import { TaskDTO, SprintDTO } from './TaskDTO'; + +export class CheckpointDTO { + constructor(data = {}) { + this.id = data.id || data.documentId; + this.documentId = data.documentId; + this.name = data.name; + this.stage = data.stage; + this.createdAt = data.createdAt; + this.updatedAt = data.updatedAt; + + // Relations + this.sprint = data.sprint ? new SprintDTO(data.sprint) : null; + this.tasks = Array.isArray(data.tasks) ? data.tasks.map(t => new TaskDTO(t)) : []; + this.projectId = data.project?.documentId || data.project; + } +} diff --git a/src/infrastructure/DTO/JobAdDTO.js b/src/infrastructure/DTO/JobAdDTO.js new file mode 100644 index 0000000..76b405b --- /dev/null +++ b/src/infrastructure/DTO/JobAdDTO.js @@ -0,0 +1,16 @@ +export class JobAdDTO { + constructor(data = {}) { + this._type = 'job_ad'; + this.role_id = data.role_id; + this.role_documentId = data.role_documentId; + this.job_title = data.job_title || {}; // { slug, label_ar, label_en } + this.custom_label = data.custom_label; + this.required_level = data.required_level; + this.slots = data.slots; + this.is_matched = data.is_matched; + this.has_applied = data.has_applied; + + // Project summary + this.project = data.project || {}; // { documentId, title, methodology, status, publisher: { username, documentId } } + } +} diff --git a/src/infrastructure/DTO/LessonDTO.js b/src/infrastructure/DTO/LessonDTO.js index 1bdc69e..23af2c5 100644 --- a/src/infrastructure/DTO/LessonDTO.js +++ b/src/infrastructure/DTO/LessonDTO.js @@ -12,6 +12,11 @@ export class LessonDTO extends BaseContentDTO { this.description = data.description; // {object | array} - Blocks this.public = !!data.public; // {boolean} + // Embedded video fields + this.embedSource = data.embed_source || null; // {string | null} - Provider: youtube, vimeo, custom + this.embedUrl = data.embed_url || null; // {string | null} - Raw URL as entered by admin + this.embedMetadata = data.embed_metadata || null; // {object | null} - Cached provider metadata + /** * Relationships */ diff --git a/src/infrastructure/DTO/ProjectDTO.js b/src/infrastructure/DTO/ProjectDTO.js new file mode 100644 index 0000000..62671b9 --- /dev/null +++ b/src/infrastructure/DTO/ProjectDTO.js @@ -0,0 +1,79 @@ +import { UserDTO } from './UserDTO'; +import { SprintDTO } from './TaskDTO'; + +export class ProjectDTO { + constructor(data = {}) { + this.id = data.id || data.documentId; + this.documentId = data.documentId; + this.title = data.title; + this.description = data.description; + this.methodology = data.methodology; // agile, waterfall, kanban + this.status = data.status; // draft, active, paused, completed, archived + this.visibility = data.visibility; // public, private + this.github_repo_url = data.github_repo_url; + this.github_repo_id = data.github_repo_id; + this.engagement_score = data.engagement_score; + this.createdAt = data.createdAt; + this.updatedAt = data.updatedAt; + + // Relations + this.publisher = data.publisher ? new UserDTO(data.publisher) : null; + this.roles = Array.isArray(data.project_roles || data.roles) ? (data.project_roles || data.roles).map(r => new ProjectRoleDTO(r)) : []; + this.members = Array.isArray(data.project_members || data.members) ? (data.project_members || data.members).map(m => new ProjectMemberDTO(m)) : []; + this.sprints = Array.isArray(data.sprints) ? data.sprints.map(s => new SprintDTO(s)) : []; + this.wiki_docs = data.wiki_docs; + this.architecture_diagram = data.architecture_diagram; + } +} + +export class ProjectRoleDTO { + constructor(data = {}) { + this.id = data.id || data.documentId; + this.documentId = data.documentId; + this.custom_label = data.custom_label; + this.permissions = data.permissions || {}; // { read, write, review, admin } + this.is_open = data.is_open; + this.required_level = data.required_level; + this.slots = data.slots; + this.job_title_tag = data.job_title_tag ? new JobTitleTagDTO(data.job_title_tag) : null; + } +} + +export class ProjectMemberDTO { + constructor(data = {}) { + this.id = data.id || data.documentId; + this.documentId = data.documentId; + this.user = data.users_permissions_user ? new UserDTO(data.users_permissions_user) : null; + this.project_role = data.project_role ? new ProjectRoleDTO(data.project_role) : null; + this.github_username = data.github_username; + this.is_active = data.is_active; + this.joined_at = data.joined_at; + } +} + +export class ProjectApplicationDTO { + constructor(data = {}) { + this.id = data.id || data.documentId; + this.documentId = data.documentId; + this.type = data.type; // apply, invite + this.status = data.status; // pending, accepted, rejected + this.message = data.message; + this.createdAt = data.createdAt; + + // Relations + this.user = data.user ? new UserDTO(data.user) : null; + this.project = data.project ? new ProjectDTO(data.project) : null; + this.project_role = data.project_role ? new ProjectRoleDTO(data.project_role) : null; + } +} + +export class JobTitleTagDTO { + constructor(data = {}) { + this.id = data.id || data.documentId; + this.documentId = data.documentId; + this.slug = data.slug; + this.label_ar = data.label_ar; + this.label_en = data.label_en; + this.category = data.category; + } +} diff --git a/src/infrastructure/DTO/Request/BaseRequest.js b/src/infrastructure/DTO/Request/BaseRequest.js index 224062f..758f7cb 100644 --- a/src/infrastructure/DTO/Request/BaseRequest.js +++ b/src/infrastructure/DTO/Request/BaseRequest.js @@ -7,14 +7,22 @@ export class BaseRequest { /** * Standard method to format the payload for Strapi. * Also applies security sanitization to prevent XSS and injections. + * Note: embed_* fields are exempted from sanitization because they contain + * URLs with special characters (://) that get destroyed by validator.escape(). + * These fields are validated by the backend provider registry instead. * @returns {object} - Raw fields sanitized */ toJSON() { const payload = {}; + const SANITIZE_EXEMPT = ['embed_url', 'embed_source', 'embed_metadata']; + Object.keys(this).forEach(key => { if (this[key] !== undefined) { - // Apply sanitization to all outgoing data - payload[key] = SecurityUtils.sanitizeData(this[key]); + if (SANITIZE_EXEMPT.includes(key)) { + payload[key] = this[key]; // Pass through raw — validated by backend + } else { + payload[key] = SecurityUtils.sanitizeData(this[key]); + } } }); return payload; @@ -36,9 +44,9 @@ export class BaseRequest { * @throws {Error} if data is invalid or unsafe. */ validate() { - // Security Audit + // Security Audit — skip embed fields (URLs break XSS detection) Object.keys(this).forEach(key => { - if (this[key] !== undefined) { + if (this[key] !== undefined && !key.includes("embed")) { SecurityUtils.validateSafety(this[key]); } }); diff --git a/src/infrastructure/DTO/Request/LessonRequest.js b/src/infrastructure/DTO/Request/LessonRequest.js index ec75c8b..0cf3028 100644 --- a/src/infrastructure/DTO/Request/LessonRequest.js +++ b/src/infrastructure/DTO/Request/LessonRequest.js @@ -7,13 +7,18 @@ export class LessonRequest extends BaseRequest { constructor(data = {}) { super(); this.title = data.title; - this.type_of_lesson = data.type || 'video'; // Enum: video, article + this.type_of_lesson = data.type || 'video'; // Enum: video, article, embedded this.video = data.videoId || null; // {id} this.description = data.description; // {blocks} this.week = data.weekId; // {id | documentId} this.public = !!data.public; this.isDraft = data.isDraft ?? true; + // Embedded video fields (only relevant when type = 'embedded') + this.embed_url = data.embedUrl || data.embed_url || null; + this.embed_source = data.embedSource || data.embed_source || null; + this.embed_metadata = data.embedMetadata || data.embed_metadata || null; + // Strapi v4/v5 relationship arrays this.course_types = data.courseTypeIds; this.problem_types = data.problemTypeIds; diff --git a/src/infrastructure/DTO/ReviewRequestDTO.js b/src/infrastructure/DTO/ReviewRequestDTO.js new file mode 100644 index 0000000..8ce557e --- /dev/null +++ b/src/infrastructure/DTO/ReviewRequestDTO.js @@ -0,0 +1,21 @@ +import { ProjectMemberDTO } from './ProjectDTO'; +import { TaskDTO } from './TaskDTO'; +import { WorkspaceItemDTO } from './WorkspaceItemDTO'; + +export class ReviewRequestDTO { + constructor(data = {}) { + this.id = data.id || data.documentId; + this.documentId = data.documentId; + this.projectId = data.project?.documentId || data.project; + this.task = data.task ? new TaskDTO(data.task) : null; + this.workspace_item = data.workspace_item ? new WorkspaceItemDTO(data.workspace_item) : null; + this.external_link = data.external_link; + this.requester = data.requester ? new ProjectMemberDTO(data.requester) : null; + this.reviewer = data.reviewer ? new ProjectMemberDTO(data.reviewer) : null; + this.status = data.status; // pending, approved, changes_requested, rejected + this.message = data.message; + this.feedback = data.feedback; + this.createdAt = data.createdAt; + this.updatedAt = data.updatedAt; + } +} diff --git a/src/infrastructure/DTO/TaskDTO.js b/src/infrastructure/DTO/TaskDTO.js new file mode 100644 index 0000000..a9aa53f --- /dev/null +++ b/src/infrastructure/DTO/TaskDTO.js @@ -0,0 +1,49 @@ +import { ProjectMemberDTO } from './ProjectDTO'; + +export class TaskDTO { + constructor(data = {}) { + this.id = data.id || data.documentId; + this.documentId = data.documentId; + this.title = data.title; + this.description = data.description; + this.status = data.status; // todo, in_progress, in_review, done, blocked + this.priority = data.priority; // low, medium, high, critical + this.branch_pattern = data.branch_pattern; + this.github_pr_id = data.github_pr_id; + this.ci_status = data.ci_status; // pending, success, failure, cancelled + this.last_commit_sha = data.last_commit_sha; + this.pr_url = data.pr_url; + this.pr_title = data.pr_title; + this.order = data.order; + this.createdAt = data.createdAt; + this.updatedAt = data.updatedAt; + + // Extra fields + this.task_type = data.task_type; + this.layer_id = data.layer_id; + this.stage = data.stage; + + // Relations + this.assignee = data.assignee ? new ProjectMemberDTO(data.assignee) : null; + this.sprint = data.sprint ? new SprintDTO(data.sprint) : null; + this.checkpoint = data.checkpoint || null; // checkpoint documentId or object + // project is omitted to avoid circular dependency, usually we just need project.id + this.projectId = data.project?.documentId || data.project; + } +} + +export class SprintDTO { + constructor(data = {}) { + this.id = data.id || data.documentId; + this.documentId = data.documentId; + this.number = data.number; + this.goal = data.goal; + this.status = data.status; // planned, active, completed + this.start_date = data.start_date; + this.end_date = data.end_date; + this.retrospective_notes = data.retrospective_notes; + + // Relation IDs + this.projectId = data.project?.documentId || data.project; + } +} diff --git a/src/infrastructure/DTO/WorkspaceItemDTO.js b/src/infrastructure/DTO/WorkspaceItemDTO.js new file mode 100644 index 0000000..1d27bc7 --- /dev/null +++ b/src/infrastructure/DTO/WorkspaceItemDTO.js @@ -0,0 +1,15 @@ +import { TaskDTO } from './TaskDTO'; + +export class WorkspaceItemDTO { + constructor(data = {}) { + this.id = data.id || data.documentId; + this.documentId = data.documentId; + this.projectId = data.project?.documentId || data.project; + this.project_member = data.project_member?.documentId || data.project_member; + this.type = data.type; + this.title = data.title; + this.content = data.content; + this.status = data.status; + this.linked_task = data.linked_task ? new TaskDTO(data.linked_task) : null; + } +} diff --git a/src/infrastructure/repository/BaseRepository.js b/src/infrastructure/repository/BaseRepository.js index 0394baa..bfb9995 100644 --- a/src/infrastructure/repository/BaseRepository.js +++ b/src/infrastructure/repository/BaseRepository.js @@ -38,6 +38,22 @@ export class BaseRepository extends IApiClient { return await fetchWrapper(url, true, 'application/json', 'POST', body); } + async patch(endpoint, requestDto, wrap = true) { + if (requestDto && typeof requestDto.validate === 'function') { + requestDto.validate(); + } + + let payload = requestDto; + if (requestDto && typeof requestDto.toPayload === 'function') { + payload = requestDto.toPayload(); + if (payload && payload.data) wrap = false; + } + + const url = this.#buildUrl(endpoint); + const body = wrap ? { data: payload } : payload; + return await fetchWrapper(url, true, 'application/json', 'PATCH', body); + } + async put(endpoint, id, requestDto, wrap = true, params = {}) { if (requestDto && typeof requestDto.validate === 'function') { requestDto.validate(); diff --git a/src/infrastructure/repository/CheckpointRepository.js b/src/infrastructure/repository/CheckpointRepository.js new file mode 100644 index 0000000..44a8e51 --- /dev/null +++ b/src/infrastructure/repository/CheckpointRepository.js @@ -0,0 +1,29 @@ +import { repositoryRegistry } from './RepositoryRegistry'; +import { CheckpointDTO } from '../DTO/CheckpointDTO'; + +export class CheckpointRepository { + constructor(apiClient = repositoryRegistry.apiClient) { + this.apiClient = apiClient; + this.projectsEndpoint = import.meta.env.VITE_API_PROJECTS || 'api/projects'; + } + + async getCheckpoints(projectId, params = {}) { + const response = await this.apiClient.get(`${this.projectsEndpoint}/${projectId}/checkpoints`, params); + const data = response?.data || response || []; + return data.map(item => new CheckpointDTO(item)); + } + + async createCheckpoint(projectId, data) { + const response = await this.apiClient.post(`${this.projectsEndpoint}/${projectId}/checkpoints`, data); + return new CheckpointDTO(response?.data || response); + } + + async updateCheckpoint(projectId, checkpointId, data) { + const response = await this.apiClient.patch(`${this.projectsEndpoint}/${projectId}/checkpoints/${checkpointId}`, data); + return new CheckpointDTO(response?.data || response); + } + + async deleteCheckpoint(projectId, checkpointId) { + return await this.apiClient.delete(`${this.projectsEndpoint}/${projectId}/checkpoints/${checkpointId}`); + } +} diff --git a/src/infrastructure/repository/ProjectRepository.js b/src/infrastructure/repository/ProjectRepository.js new file mode 100644 index 0000000..0ef3561 --- /dev/null +++ b/src/infrastructure/repository/ProjectRepository.js @@ -0,0 +1,140 @@ +import { IProjectAccess } from '../../domain/interface/IProjectAccess'; +import { repositoryRegistry } from './RepositoryRegistry'; +import { ProjectDTO, ProjectRoleDTO, ProjectMemberDTO, ProjectApplicationDTO, JobTitleTagDTO } from '../DTO/ProjectDTO'; + +export class ProjectRepository extends IProjectAccess { + constructor(apiClient = repositoryRegistry.apiClient) { + super(); + this.apiClient = apiClient; + this.projectsEndpoint = import.meta.env.VITE_API_PROJECTS || 'api/projects'; + this.jobTitleTagsEndpoint = import.meta.env.VITE_API_JOB_TITLE_TAGS || 'api/job-title-tags'; + this.userJobTitlesEndpoint = import.meta.env.VITE_API_USER_JOB_TITLES || 'api/users/me/job-titles'; + this.projectApplicationsEndpoint = import.meta.env.VITE_API_PROJECT_APPLICATIONS || 'api/project-applications'; + this.userApplicationsEndpoint = 'api/users/me/applications'; + } + + async getAll(params = {}) { + const response = await this.apiClient.get(this.projectsEndpoint, params); + const data = response?.data || response || []; + return data.map(item => new ProjectDTO(item)); + } + + async getById(id, params = {}) { + const response = await this.apiClient.get(`${this.projectsEndpoint}/${id}`, params); + return new ProjectDTO(response?.data || response); + } + + async create(data) { + const response = await this.apiClient.post(this.projectsEndpoint, data); + return new ProjectDTO(response?.data || response); + } + + async update(id, data) { + const response = await this.apiClient.put(this.projectsEndpoint, id, data); + return new ProjectDTO(response?.data || response); + } + + async delete(id) { + return await this.apiClient.delete(`${this.projectsEndpoint}/${id}`); + } + + // --- Roles --- + async getRoles(projectId) { + const response = await this.apiClient.get(`${this.projectsEndpoint}/${projectId}/roles`); + const data = response?.data || response || []; + return data.map(item => new ProjectRoleDTO(item)); + } + + async createRole(projectId, data) { + const response = await this.apiClient.post(`${this.projectsEndpoint}/${projectId}/roles`, data); + return new ProjectRoleDTO(response?.data || response); + } + + async updateRole(projectId, roleId, data) { + const response = await this.apiClient.patch(`${this.projectsEndpoint}/${projectId}/roles/${roleId}`, data); + return new ProjectRoleDTO(response?.data || response); + } + + // --- Members --- + async getMembers(projectId) { + const response = await this.apiClient.get(`${this.projectsEndpoint}/${projectId}/members`); + const data = response?.data || response || []; + return data.map(item => new ProjectMemberDTO(item)); + } + + async removeMember(projectId, memberId) { + return await this.apiClient.delete(`${this.projectsEndpoint}/${projectId}/members/${memberId}`); + } + + async assignMemberRole(projectId, memberId, roleId) { + const response = await this.apiClient.patch(`${this.projectsEndpoint}/${projectId}/members/${memberId}/assign-role`, { role: roleId }); + return response?.data || response; + } + + async updateMemberGithubUsername(projectId, memberId, githubUsername) { + const response = await this.apiClient.patch( + `${this.projectsEndpoint}/${projectId}/members/${memberId}/github-username`, + { github_username: githubUsername } + ); + return new ProjectMemberDTO(response?.data || response); + } + + // --- GitHub Repo Linking --- + async linkGithubRepo(projectId, { github_repo_url, github_repo_id, github_webhook_secret }) { + const response = await this.apiClient.patch( + `${this.projectsEndpoint}/${projectId}/github-repo`, + { github_repo_url, github_repo_id, github_webhook_secret } + ); + return response?.data || response; + } + + // --- Applications --- + async applyToRole(projectId, data) { + const response = await this.apiClient.post(`${this.projectsEndpoint}/${projectId}/apply`, data); + return new ProjectApplicationDTO(response?.data || response); + } + + async inviteToRole(projectId, data) { + const response = await this.apiClient.post(`${this.projectsEndpoint}/${projectId}/invite`, data); + return new ProjectApplicationDTO(response?.data || response); + } + + async respondToApplication(applicationId, status) { + const response = await this.apiClient.patch(`${this.projectApplicationsEndpoint}/${applicationId}/respond`, { decision: status }); + return new ProjectApplicationDTO(response?.data || response); + } + + async getProjectApplications(projectId) { + const response = await this.apiClient.get(`${this.projectsEndpoint}/${projectId}/applications`); + const data = response?.data || response || []; + return data.map(item => new ProjectApplicationDTO(item)); + } + + async getMyApplications() { + const response = await this.apiClient.get(this.userApplicationsEndpoint); + const data = response?.data || response || []; + return data.map(item => new ProjectApplicationDTO(item)); + } + + // --- Job Title Tags (Static taxonomy) --- + async getJobTitleTags() { + const response = await this.apiClient.get(this.jobTitleTagsEndpoint); + const data = response?.data || response || []; + return data.map(item => new JobTitleTagDTO(item)); + } + + async getMyJobTitles() { + const response = await this.apiClient.get(this.userJobTitlesEndpoint); + const data = response?.data || response || []; + return data; // Returns the junction objects { id, experience_level, job_title_tag } + } + + async addMyJobTitle(jobTitleTagId, experienceLevel) { + const response = await this.apiClient.post(this.userJobTitlesEndpoint, { job_title_tag: jobTitleTagId, experience_level: experienceLevel }); + return response?.data || response; + } + + async removeMyJobTitle(tagId) { + return await this.apiClient.delete(`${this.userJobTitlesEndpoint}/${tagId}`); + } +} diff --git a/src/infrastructure/repository/RecommendationRepository.js b/src/infrastructure/repository/RecommendationRepository.js index bcc714a..5bf5718 100644 --- a/src/infrastructure/repository/RecommendationRepository.js +++ b/src/infrastructure/repository/RecommendationRepository.js @@ -13,6 +13,7 @@ export class RecommendationRepository extends IRecommendationAccess { this.eventsEndpoint = import.meta.env.VITE_API_EVENTS; this.blogsEndpoint = import.meta.env.VITE_API_BLOG; this.articlesEndpoint = import.meta.env.VITE_API_ARTICLE; + this.jobAdsEndpoint = '/recommendations/job-ads'; } /** @@ -79,6 +80,15 @@ export class RecommendationRepository extends IRecommendationAccess { return response?.data || response || []; } + /** + * @param {number} [limit=4] Max number of job ads + * @returns {Promise} Raw job ad data from recommendation API. + */ + async getJobAds(limit = 4) { + const response = await this.apiClient.get(this.jobAdsEndpoint, { limit }); + return response?.data || response || []; + } + // Base interface methods (not used for recommendations) async getAll(params = {}) { throw new Error('Use getCourses() or getEvents() instead.'); diff --git a/src/infrastructure/repository/RepositoryRegistry.js b/src/infrastructure/repository/RepositoryRegistry.js index ea5a97c..bb5a640 100644 --- a/src/infrastructure/repository/RepositoryRegistry.js +++ b/src/infrastructure/repository/RepositoryRegistry.js @@ -1,6 +1,11 @@ import { BaseRepository } from './BaseRepository'; import { SharedInteractionRepository } from './SharedInteractionRepository'; import { ReportTypeRepository } from './ReportTypeRepository'; +import { ProjectRepository } from './ProjectRepository'; +import { TaskRepository } from './TaskRepository'; +import { CheckpointRepository } from './CheckpointRepository'; +import { WorkspaceRepository } from './WorkspaceRepository'; +import { ReviewRequestRepository } from './ReviewRequestRepository'; /** * Simple dependency registry to manage IApiClient implementations. @@ -10,6 +15,11 @@ class RepositoryRegistry { this._apiClient = new BaseRepository(); this._sharedInteractionRepository = new SharedInteractionRepository(this._apiClient); this._reportTypeRepository = new ReportTypeRepository(); + this._projectRepository = new ProjectRepository(this._apiClient); + this._taskRepository = new TaskRepository(this._apiClient); + this._checkpointRepository = new CheckpointRepository(this._apiClient); + this._workspaceRepository = new WorkspaceRepository(this._apiClient); + this._reviewRequestRepository = new ReviewRequestRepository(this._apiClient); } get apiClient() { @@ -30,8 +40,29 @@ class RepositoryRegistry { get reportTypeRepository() { return this._reportTypeRepository; } + + get projectRepository() { + return this._projectRepository; + } + + get taskRepository() { + return this._taskRepository; + } + + get checkpointRepository() { + return this._checkpointRepository; + } + + get workspaceRepository() { + return this._workspaceRepository; + } + + get reviewRequestRepository() { + return this._reviewRequestRepository; + } } export const repositoryRegistry = new RepositoryRegistry(); + diff --git a/src/infrastructure/repository/ReviewRequestRepository.js b/src/infrastructure/repository/ReviewRequestRepository.js new file mode 100644 index 0000000..bb526a0 --- /dev/null +++ b/src/infrastructure/repository/ReviewRequestRepository.js @@ -0,0 +1,27 @@ +import { IReviewRequestAccess } from '../../domain/interface/IReviewRequestAccess'; +import { repositoryRegistry } from './RepositoryRegistry'; +import { ReviewRequestDTO } from '../DTO/ReviewRequestDTO'; + +export class ReviewRequestRepository extends IReviewRequestAccess { + constructor(apiClient = repositoryRegistry.apiClient) { + super(); + this.apiClient = apiClient; + this.projectsEndpoint = import.meta.env.VITE_API_PROJECTS || 'api/projects'; + } + + async getReviewRequests(projectId) { + const response = await this.apiClient.get(`${this.projectsEndpoint}/${projectId}/review-requests`); + const data = response?.data || response || []; + return data.map(item => new ReviewRequestDTO(item)); + } + + async createReviewRequest(projectId, data) { + const response = await this.apiClient.post(`${this.projectsEndpoint}/${projectId}/review-requests`, data); + return new ReviewRequestDTO(response?.data || response); + } + + async resolveReviewRequest(projectId, requestId, data) { + const response = await this.apiClient.patch(`${this.projectsEndpoint}/${projectId}/review-requests/${requestId}/resolve`, data); + return new ReviewRequestDTO(response?.data || response); + } +} diff --git a/src/infrastructure/repository/RoleRepository.js b/src/infrastructure/repository/RoleRepository.js new file mode 100644 index 0000000..d56b4b3 --- /dev/null +++ b/src/infrastructure/repository/RoleRepository.js @@ -0,0 +1,19 @@ +import { BaseRepository } from './BaseRepository'; + +export class RoleRepository extends BaseRepository { + constructor() { + super(); + this.endpoint = '/api/users-permissions/roles'; + } + + async getAllRoles() { + try { + const response = await this.get(this.endpoint); + // Strapi /api/users-permissions/roles usually returns { roles: [...] } + return response?.roles || response?.data || response || []; + } catch (error) { + console.error('[RoleRepository] Get all roles failed:', error); + throw error; + } + } +} diff --git a/src/infrastructure/repository/TaskRepository.js b/src/infrastructure/repository/TaskRepository.js new file mode 100644 index 0000000..71527b3 --- /dev/null +++ b/src/infrastructure/repository/TaskRepository.js @@ -0,0 +1,73 @@ +import { ITaskAccess } from '../../domain/interface/ITaskAccess'; +import { repositoryRegistry } from './RepositoryRegistry'; +import { TaskDTO, SprintDTO } from '../DTO/TaskDTO'; + +export class TaskRepository extends ITaskAccess { + constructor(apiClient = repositoryRegistry.apiClient) { + super(); + this.apiClient = apiClient; + this.projectsEndpoint = import.meta.env.VITE_API_PROJECTS || 'api/projects'; + } + + // --- Sprints --- + async getSprints(projectId) { + const response = await this.apiClient.get(`${this.projectsEndpoint}/${projectId}/sprints`); + const data = response?.data || response || []; + return data.map(item => new SprintDTO(item)); + } + + async createSprint(projectId, data) { + const response = await this.apiClient.post(`${this.projectsEndpoint}/${projectId}/sprints`, data); + return new SprintDTO(response?.data || response); + } + + async updateSprint(projectId, sprintId, data) { + const response = await this.apiClient.patch(`${this.projectsEndpoint}/${projectId}/sprints/${sprintId}`, data); + return new SprintDTO(response?.data || response); + } + + async startSprint(projectId, sprintId) { + const response = await this.apiClient.post(`${this.projectsEndpoint}/${projectId}/sprints/${sprintId}/start`); + return new SprintDTO(response?.data || response); + } + + // --- Tasks --- + async getTasks(projectId, params = {}) { + const response = await this.apiClient.get(`${this.projectsEndpoint}/${projectId}/tasks`, params); + const data = response?.data || response || []; + return data.map(item => new TaskDTO(item)); + } + + async createTask(projectId, data) { + const response = await this.apiClient.post(`${this.projectsEndpoint}/${projectId}/tasks`, data); + return new TaskDTO(response?.data || response); + } + + async updateTask(projectId, taskId, data) { + const response = await this.apiClient.patch(`${this.projectsEndpoint}/${projectId}/tasks/${taskId}`, data); + return new TaskDTO(response?.data || response); + } + + async transitionTask(projectId, taskId, status) { + const response = await this.apiClient.patch(`${this.projectsEndpoint}/${projectId}/tasks/${taskId}/transition`, { status }); + return new TaskDTO(response?.data || response); + } + + async reorderTasks(projectId, tasks) { + // tasks is an array of { documentId, order } + const response = await this.apiClient.patch(`${this.projectsEndpoint}/${projectId}/tasks/reorder`, { tasks }); + return response?.data || response; + } + + async deleteTask(projectId, taskId) { + return await this.apiClient.delete(`${this.projectsEndpoint}/${projectId}/tasks/${taskId}`); + } + + // Base interface methods + async getAll(params = {}) { + throw new Error('Use getTasks(projectId, params) instead.'); + } + async getById(id, params = {}) { + throw new Error('Not implemented.'); + } +} diff --git a/src/infrastructure/repository/WorkspaceRepository.js b/src/infrastructure/repository/WorkspaceRepository.js new file mode 100644 index 0000000..19e9fcf --- /dev/null +++ b/src/infrastructure/repository/WorkspaceRepository.js @@ -0,0 +1,32 @@ +import { IWorkspaceAccess } from '../../domain/interface/IWorkspaceAccess'; +import { repositoryRegistry } from './RepositoryRegistry'; +import { WorkspaceItemDTO } from '../DTO/WorkspaceItemDTO'; + +export class WorkspaceRepository extends IWorkspaceAccess { + constructor(apiClient = repositoryRegistry.apiClient) { + super(); + this.apiClient = apiClient; + this.projectsEndpoint = import.meta.env.VITE_API_PROJECTS || 'api/projects'; + } + + async getWorkspace(projectId) { + const response = await this.apiClient.get(`${this.projectsEndpoint}/${projectId}/workspace`); + const data = response?.data || response || []; + return data.map(item => new WorkspaceItemDTO(item)); + } + + async createWorkspaceItem(projectId, data) { + const response = await this.apiClient.post(`${this.projectsEndpoint}/${projectId}/workspace`, data); + return new WorkspaceItemDTO(response?.data || response); + } + + async updateWorkspaceItem(projectId, itemId, data) { + const response = await this.apiClient.patch(`${this.projectsEndpoint}/${projectId}/workspace/${itemId}`, data); + return new WorkspaceItemDTO(response?.data || response); + } + + async deleteWorkspaceItem(projectId, itemId) { + const response = await this.apiClient.delete(`${this.projectsEndpoint}/${projectId}/workspace/${itemId}`); + return response?.data || response; + } +} diff --git a/src/presentation/feature/cms/components/CMSActionBar.jsx b/src/presentation/feature/cms/components/CMSActionBar.jsx index 615d8fc..ac0ccd0 100644 --- a/src/presentation/feature/cms/components/CMSActionBar.jsx +++ b/src/presentation/feature/cms/components/CMSActionBar.jsx @@ -10,17 +10,17 @@ export const CMSActionBar = ({ onExit }) => {
-
-
+
+
Archive Active
diff --git a/src/presentation/feature/cms/components/CMSCreateCategorizationModal.jsx b/src/presentation/feature/cms/components/CMSCreateCategorizationModal.jsx index bfbe59c..b49b42d 100644 --- a/src/presentation/feature/cms/components/CMSCreateCategorizationModal.jsx +++ b/src/presentation/feature/cms/components/CMSCreateCategorizationModal.jsx @@ -25,25 +25,25 @@ export const CMSCreateCategorizationModal = ({
{/* Backdrop */}
{/* Modal Content */} -
-
+
+
-
+
-

{modalHeader}

+

{modalHeader}

Append new taxonomy node

@@ -60,7 +60,7 @@ export const CMSCreateCategorizationModal = ({ type="text" value={title} onChange={(e) => setTitle(e.target.value)} - className="w-full h-14 px-6 bg-white border border-border-default rounded-2xl font-serif text-sm focus:ring-2 focus:ring-accent-primary/20 focus:border-accent-primary outline-none transition-all shadow-sm" + className="w-full h-14 px-6 bg-surface-elevated border border-border-default rounded-2xl font-serif text-sm focus:ring-2 focus:ring-accent-primary/20 focus:border-accent-primary outline-none transition-all shadow-sm" placeholder="e.g. Advanced Calculus, Backend Systems..." />
@@ -77,7 +77,7 @@ export const CMSCreateCategorizationModal = ({ @@ -238,14 +239,14 @@ export const CMSResourceTable = ({ placeholder={`Query for ${sectionName.toLowerCase()}...`} value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} - className="w-full h-12 md:h-16 pl-14 md:pl-16 pr-6 bg-ivory border border-border-default rounded-2xl md:rounded-[24px] font-serif italic text-xs md:text-sm focus:ring-2 focus:ring-accent-primary/20 focus:border-accent-primary outline-none transition-all shadow-whisper" + className="w-full h-12 md:h-16 pl-14 md:pl-16 pr-6 bg-surface border border-border-default rounded-2xl md:rounded-[24px] font-serif italic text-xs md:text-sm focus:ring-2 focus:ring-accent-primary/20 focus:border-accent-primary outline-none transition-all shadow-whisper" />
{selectedIds.length > 0 && ( -
+
{selectedIds.length} Selections -
+
{/* 3. REPOSITORY LEDGER */} -
+
{/* Ledger Header */} -
+
- - Descriptor + Descriptor
-
+
{columns?.status || 'Shelf Status'} {columns?.metric || 'Engagement'} @@ -278,7 +279,7 @@ export const CMSResourceTable = ({ {isLoading ? (
-

Consulting Archives...

+

Consulting Archives...

) : paginatedItems.length === 0 ? (
@@ -304,25 +305,26 @@ export const CMSResourceTable = ({ key={id} className={cn( "px-3 md:px-10 py-4 md:py-7 transition-all duration-500 group relative", - isSelected ? "bg-parchment/60" : "hover:bg-parchment/30" + isSelected ? "bg-background/60" : "hover:bg-background/30" )} + ref={openDropdownId === id ? dropdownRef : null} > {/* Desktop: horizontal row */}
-
+
-

{title}

+

{title}

Catalogued on {dateStr} • Ref: {id.toString().slice(0, 8)}

@@ -333,26 +335,26 @@ export const CMSResourceTable = ({ {isDraft ? 'Draft' : 'Published'}
-
+
{engagementScore !== null ? `${engagementScore}` : '—'}
-
+
+ ) : ( + + Edit Manuscript + + )}
@@ -406,17 +417,17 @@ export const CMSResourceTable = ({
-

{title}

+

{title}

{dateStr} • {id.toString().slice(0, 8)}

{/* Mobile action button */} -
+
+ ) : ( + + Edit + + )}
-
+
{activePage} / {totalPages}
diff --git a/src/presentation/feature/cms/components/CMSSidebar.jsx b/src/presentation/feature/cms/components/CMSSidebar.jsx index 5a7fbc2..d68724e 100644 --- a/src/presentation/feature/cms/components/CMSSidebar.jsx +++ b/src/presentation/feature/cms/components/CMSSidebar.jsx @@ -23,7 +23,7 @@ export const CMSSidebar = ({ sections, activeSection }) => { className={cn( "w-full flex items-center justify-between py-2.5 px-3 transition-all duration-300 group rounded-xl", activeSection === section.name - ? "bg-near-black text-ivory shadow-lg" + ? "bg-text-primary text-surface shadow-lg" : "text-text-muted hover:bg-surface-sunken hover:text-text-primary" )} > @@ -36,7 +36,7 @@ export const CMSSidebar = ({ sections, activeSection }) => { {section.count} @@ -47,15 +47,15 @@ export const CMSSidebar = ({ sections, activeSection }) => {
-
-

+
+

Storage Vault

-
+

- Capacity used: 42.8 GB of 500 GB + Capacity used: 42.8 GB of 500 GB

diff --git a/src/presentation/feature/cms/components/CategorizationSelector.jsx b/src/presentation/feature/cms/components/CategorizationSelector.jsx index 1f4b337..2891bc4 100644 --- a/src/presentation/feature/cms/components/CategorizationSelector.jsx +++ b/src/presentation/feature/cms/components/CategorizationSelector.jsx @@ -47,7 +47,7 @@ export const CategorizationSelector = ({ selectedItems.map(item => (
{item.title || item.type}
@@ -123,7 +123,7 @@ export const CategorizationSelector = ({ diff --git a/src/presentation/feature/cms/components/ChangeUserRoleModal.jsx b/src/presentation/feature/cms/components/ChangeUserRoleModal.jsx new file mode 100644 index 0000000..8d79e71 --- /dev/null +++ b/src/presentation/feature/cms/components/ChangeUserRoleModal.jsx @@ -0,0 +1,133 @@ +import React, { useState, useEffect } from 'react'; +import { ShieldCheck, X, Loader2, Save } from 'lucide-react'; +import { cn } from '@core/utils/cn'; +import { useFetchRoles } from '@domain/useCase/useFetchRoles'; +import { useUpdateUserRole } from '@domain/useCase/useUpdateUserRole'; + +export const ChangeUserRoleModal = ({ user, onClose, onSuccess }) => { + const { roles, isLoading: isLoadingRoles } = useFetchRoles(); + const { updateUserRole, isUpdating } = useUpdateUserRole(); + const [selectedRole, setSelectedRole] = useState(null); + + useEffect(() => { + if (roles.length > 0 && user?.role?.id) { + setSelectedRole(user.role.id); + } + }, [roles, user]); + + const handleSubmit = async () => { + if (!selectedRole || selectedRole === user?.role?.id) return; + + try { + await updateUserRole({ userId: user.id, roleId: selectedRole }); + onSuccess(); + } catch (error) { + console.error("Failed to update role", error); + } + }; + + const hasChanged = selectedRole && selectedRole !== user?.role?.id; + + return ( +
+
+ +
+ {/* Header */} +
+
+
+ +
+
+

User Privileges

+

Adjust role for {user?.username}

+
+
+ +
+ + {/* Body */} +
+ {isLoadingRoles ? ( +
+ + Fetching Roles... +
+ ) : ( +
+ + +
+ {roles.map((role) => ( + + ))} +
+
+ )} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +}; diff --git a/src/presentation/feature/cms/components/EmbedUrlInput.jsx b/src/presentation/feature/cms/components/EmbedUrlInput.jsx new file mode 100644 index 0000000..bbd99eb --- /dev/null +++ b/src/presentation/feature/cms/components/EmbedUrlInput.jsx @@ -0,0 +1,213 @@ +import React, { useState, useEffect, useMemo, useRef } from 'react'; +import { Link as LinkIcon, Loader2, ExternalLink, Youtube, MonitorPlay } from 'lucide-react'; +import { cn } from '@core/utils/cn'; + +/** + * EmbedUrlInput: URL input with live video preview. + * + * Detects provider from URL, shows iframe preview after debounce, + * and displays thumbnail from provider (no oEmbed fetch — CORS-free). + * Full metadata is resolved by the backend on save. + * + * @param {string} value - Current URL value + * @param {Function} onChange - URL change callback + */ + +const PROVIDERS = { + youtube: { + name: 'YouTube', + color: 'red', + match: (url) => /(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)/.test(url), + extractId: (url) => { + const patterns = [ + /(?:youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/, + /(?:youtu\.be\/)([a-zA-Z0-9_-]{11})/, + /(?:youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/, + /(?:youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/, + ]; + for (const p of patterns) { + const m = url.match(p); + if (m) return m[1]; + } + return null; + }, + getEmbedUrl: (id) => `https://www.youtube.com/embed/${id}`, + getThumbnail: (id) => `https://img.youtube.com/vi/${id}/hqdefault.jpg`, + }, + vimeo: { + name: 'Vimeo', + color: 'blue', + match: (url) => /vimeo\.com\/\d+/.test(url), + extractId: (url) => { + const m = url.match(/vimeo\.com\/(\d+)/); + return m ? m[1] : null; + }, + getEmbedUrl: (id) => `https://player.vimeo.com/video/${id}`, + getThumbnail: () => null, + }, +}; + +function detectProvider(url) { + if (!url) return null; + for (const [key, provider] of Object.entries(PROVIDERS)) { + if (provider.match(url)) return { key, ...provider }; + } + return null; +} + +export const EmbedUrlInput = ({ value, onChange }) => { + const [previewData, setPreviewData] = useState(null); + const previewDebounceRef = useRef(null); + + // Instant detection for badge only (no iframe) + const detected = useMemo(() => { + const provider = detectProvider(value); + if (!provider) return null; + const videoId = provider.extractId(value); + if (!videoId) return null; + return { + ...provider, + videoId, + embedUrl: provider.getEmbedUrl(videoId), + thumbnail: provider.getThumbnail(videoId), + }; + }, [value]); + + // Debounced preview: only render iframe after user stops typing for 800ms + useEffect(() => { + if (previewDebounceRef.current) clearTimeout(previewDebounceRef.current); + + if (!detected) { + setPreviewData(null); + return; + } + + previewDebounceRef.current = setTimeout(() => { + setPreviewData(detected); + }, 800); + + return () => { + if (previewDebounceRef.current) clearTimeout(previewDebounceRef.current); + }; + }, [detected?.videoId]); + + const providerColors = { + red: { + badge: 'bg-red-500/10 border-red-500/20 text-red-500', + }, + blue: { + badge: 'bg-blue-400/10 border-blue-400/20 text-blue-400', + }, + }; + + const colors = detected ? providerColors[detected.color] || providerColors.blue : null; + + return ( +
+ + + {/* URL Input */} +
+
+ +
+ onChange(e.target.value)} + placeholder="https://youtube.com/watch?v=... or https://vimeo.com/..." + className="w-full bg-surface-sunken/40 border border-border-subtle rounded-2xl pl-12 pr-6 py-5 text-sm font-bold text-text-primary focus:border-blue-500 focus:bg-surface-sunken outline-none transition-all placeholder:text-text-muted/20 shadow-inner" + /> +
+ + {/* Provider Badge */} + {value && ( +
+ + {detected ? `▶ ${detected.name} Detected` : '▶ Custom Source'} + + {detected && !previewData && ( + + Loading preview... + + )} +
+ )} + + {/* Loading state: provider detected but waiting for debounce */} + {detected && !previewData && ( +
+ +

Loading preview...

+
+ )} + + {/* Live Preview — only after debounce completes */} + {previewData && ( +
+ {/* Video iframe */} +
+