diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md new file mode 100644 index 00000000..c0ac7b31 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,27 @@ +--- +name: "🐞 Bug Fix" +about: "Report a bug or something that is not working as expected" +title: "[FIX] " +labels: bug +assignees: "" +--- + +## πŸ› Bug Description +A clear and concise description of what the bug is. + +## πŸ”„ Steps to Reproduce +Steps to reproduce the behavior: +1. Go to ... +2. Click on ... +3. See error ... + +## βœ… Expected Behavior +What should have happened instead? + +## πŸ“Έ Screenshots / Logs +If applicable, add screenshots or error messages. + +## πŸ–₯️ Environment +- OS: +- Version: +- Other relevant info: diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md new file mode 100644 index 00000000..647c8c4d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.md @@ -0,0 +1,19 @@ +--- +name: "✨ Feature Request" +about: "Suggest a new feature or improvement" +title: "[FEATURE] " +labels: enhancement +assignees: "" +--- + +## ✨ Description +What would you like to add or improve? + +## 🎯 Use Case +Why is this useful? Who benefits from it? + +## πŸ’‘ Proposed Solution +How could this be implemented? (optional) + +## πŸ“Ž Additional Info +Anything else you’d like to share? (related issues, links, references, etc.) diff --git a/.github/ISSUE_TEMPLATE/other.md b/.github/ISSUE_TEMPLATE/other.md new file mode 100644 index 00000000..88466351 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/other.md @@ -0,0 +1,16 @@ +--- +name: "πŸ“Œ Other" +about: "Questions, discussions, or anything else" +title: "[OTHER] " +labels: question +assignees: "" +--- + +## πŸ“Œ Description +What is this about? + +## 🌍 Context +Why is this relevant? + +## πŸ“Ž Additional Info +Anything else that might be useful. diff --git a/.gitignore b/.gitignore index 2cd0c01c..c094933b 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,6 @@ npm-debug.log* yarn-debug.log* yarn-error.log* -.vscode \ No newline at end of file +.vscode +.idea + diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..877839d2 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ + +src/* +src/ \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6891fbef..13ae7765 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,20 +8,26 @@ "name": "team-dev-client-template", "version": "0.1.0", "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/material": "^7.3.2", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^14.4.3", + "browser-image-compression": "^2.0.2", "jwt-decode": "^3.1.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-modal": "^3.16.1", + "react-password-checklist": "^1.8.1", "react-router-dom": "^6.8.0", "react-scripts": "5.0.1", + "reactjs-popup": "^2.0.6", "web-vitals": "^3.1.1" }, "devDependencies": { "eslint": "^8.57.1", - "eslint-config-prettier": "^9.1.0", + "eslint-config-prettier": "^9.1.2", "eslint-config-standard": "^17.1.0", "eslint-plugin-import": "^2.31.0", "eslint-plugin-n": "^16.6.2", @@ -1809,12 +1815,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz", - "integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==", - "dependencies": { - "regenerator-runtime": "^0.13.11" - }, + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -2141,6 +2145,173 @@ "postcss-selector-parser": "^6.0.10" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -3027,6 +3198,225 @@ "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" }, + "node_modules/@mui/core-downloads-tracker": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.2.tgz", + "integrity": "sha512-AOyfHjyDKVPGJJFtxOlept3EYEdLoar/RvssBTWVAvDJGIE676dLi2oT/Kx+FoVXFoA/JdV7DEMq/BVWV3KHRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/material": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.2.tgz", + "integrity": "sha512-qXvbnawQhqUVfH1LMgMaiytP+ZpGoYhnGl7yYq2x57GYzcFL/iPzSZ3L30tlbwEjSVKNYcbiKO8tANR1tadjUg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.3", + "@mui/core-downloads-tracker": "^7.3.2", + "@mui/system": "^7.3.2", + "@mui/types": "^7.4.6", + "@mui/utils": "^7.3.2", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.1.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^7.3.2", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/react-is": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz", + "integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==", + "license": "MIT" + }, + "node_modules/@mui/private-theming": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.2.tgz", + "integrity": "sha512-ha7mFoOyZGJr75xeiO9lugS3joRROjc8tG1u4P50dH0KR7bwhHznVMcYg7MouochUy0OxooJm/OOSpJ7gKcMvg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.3", + "@mui/utils": "^7.3.2", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.2.tgz", + "integrity": "sha512-PkJzW+mTaek4e0nPYZ6qLnW5RGa0KN+eRTf5FA2nc7cFZTeM+qebmGibaTLrgQBy3UpcpemaqfzToBNkzuxqew==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.3", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.2.tgz", + "integrity": "sha512-9d8JEvZW+H6cVkaZ+FK56R53vkJe3HsTpcjMUtH8v1xK6Y1TjzHdZ7Jck02mGXJsE6MQGWVs3ogRHTQmS9Q/rA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.3", + "@mui/private-theming": "^7.3.2", + "@mui/styled-engine": "^7.3.2", + "@mui/types": "^7.4.6", + "@mui/utils": "^7.3.2", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.6.tgz", + "integrity": "sha512-NVBbIw+4CDMMppNamVxyTccNv0WxtDb7motWDlMeSC8Oy95saj1TIZMGynPpFLePt3yOD8TskzumeqORCgRGWw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.3" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.2.tgz", + "integrity": "sha512-4DMWQGenOdLnM3y/SdFQFwKsCLM+mqxzvoWp9+x2XdEzXapkznauHLiXtSohHs/mc0+5/9UACt1GdugCX2te5g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.3", + "@mui/types": "^7.4.6", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.1.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils/node_modules/react-is": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz", + "integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==", + "license": "MIT" + }, "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "version": "5.1.1-v1", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", @@ -3152,6 +3542,16 @@ } } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@remix-run/router": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.3.1.tgz", @@ -4121,9 +4521,10 @@ "integrity": "sha512-KufADq8uQqo1pYKVIYzfKbJfBAc0sOeXqGbFaSpv8MRmC/zXgowNZmFcbngndGk922QDmOASEXUZCaY48gs4cg==" }, "node_modules/@types/prop-types": { - "version": "15.7.5", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" }, "node_modules/@types/q": { "version": "1.5.5", @@ -4158,6 +4559,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", @@ -5576,6 +5986,15 @@ "node": ">=8" } }, + "node_modules/browser-image-compression": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/browser-image-compression/-/browser-image-compression-2.0.2.tgz", + "integrity": "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==", + "license": "MIT", + "dependencies": { + "uzip": "0.20201231.0" + } + }, "node_modules/browser-process-hrtime": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", @@ -5858,6 +6277,15 @@ "wrap-ansi": "^7.0.0" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -6505,9 +6933,10 @@ "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==" }, "node_modules/csstype": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", - "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==" + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -6832,6 +7261,16 @@ "utila": "~0.4" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dom-serializer": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", @@ -7360,9 +7799,9 @@ } }, "node_modules/eslint-config-prettier": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", - "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", + "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, "license": "MIT", "bin": { @@ -8505,6 +8944,12 @@ "url": "https://github.com/avajs/find-cache-dir?sponsor=1" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -9169,6 +9614,21 @@ "he": "bin/he" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/hoopy": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz", @@ -12246,7 +12706,8 @@ "node_modules/jwt-decode": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", - "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==", + "license": "MIT" }, "node_modules/keyv": { "version": "4.5.4", @@ -14974,6 +15435,15 @@ "react-dom": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18" } }, + "node_modules/react-password-checklist": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/react-password-checklist/-/react-password-checklist-1.8.1.tgz", + "integrity": "sha512-QHIU/OejxoH4/cIfYLHaHLb+yYc8mtL0Vr4HTmULxQg3ZNdI9Ni/yYf7pwLBgsUh4sseKCV/GzzYHWpHqejTGw==", + "license": "MIT", + "peerDependencies": { + "react": ">16.0.0-alpha || >17.0.0-alpha || >18.0.0-alpha" + } + }, "node_modules/react-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", @@ -15084,6 +15554,35 @@ } } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/reactjs-popup": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/reactjs-popup/-/reactjs-popup-2.0.6.tgz", + "integrity": "sha512-A+tt+x9wdgZiZjv0e2WzYLD3IfFwJALaRaqwrCSXGjo0iQdsry/EtBEbQXRSmQs7cHmOi5eytCiSlOm8k4C+dg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -16316,6 +16815,12 @@ "postcss": "^8.2.15" } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -17103,6 +17608,12 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/uzip": { + "version": "0.20201231.0", + "resolved": "https://registry.npmjs.org/uzip/-/uzip-0.20201231.0.tgz", + "integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==", + "license": "MIT" + }, "node_modules/v8-to-istanbul": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", diff --git a/package.json b/package.json index 249b6b05..0db5557c 100644 --- a/package.json +++ b/package.json @@ -3,15 +3,21 @@ "version": "0.1.0", "private": true, "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/material": "^7.3.2", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^14.4.3", + "browser-image-compression": "^2.0.2", "jwt-decode": "^3.1.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-modal": "^3.16.1", + "react-password-checklist": "^1.8.1", "react-router-dom": "^6.8.0", "react-scripts": "5.0.1", + "reactjs-popup": "^2.0.6", "web-vitals": "^3.1.1" }, "scripts": { @@ -43,7 +49,7 @@ }, "devDependencies": { "eslint": "^8.57.1", - "eslint-config-prettier": "^9.1.0", + "eslint-config-prettier": "^9.1.2", "eslint-config-standard": "^17.1.0", "eslint-plugin-import": "^2.31.0", "eslint-plugin-n": "^16.6.2", diff --git a/public/faviconsvg.svg b/public/faviconsvg.svg new file mode 100644 index 00000000..03d77140 --- /dev/null +++ b/public/faviconsvg.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/favicontransparent.ico b/public/favicontransparent.ico new file mode 100644 index 00000000..d73f75c4 Binary files /dev/null and b/public/favicontransparent.ico differ diff --git a/public/faviconwhite.ico b/public/faviconwhite.ico new file mode 100644 index 00000000..b7340a08 Binary files /dev/null and b/public/faviconwhite.ico differ diff --git a/public/index.html b/public/index.html index aa069f27..80309479 100644 --- a/public/index.html +++ b/public/index.html @@ -1,15 +1,16 @@ - + - + - + + - React App + Cohort Manager diff --git a/public/microsoft-azure-logo.svg b/public/microsoft-azure-logo.svg new file mode 100644 index 00000000..d321d23a --- /dev/null +++ b/public/microsoft-azure-logo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/App.css b/src/App.css index ec62bd1a..5262a6ba 100644 --- a/src/App.css +++ b/src/App.css @@ -7,10 +7,17 @@ grid-template-columns: 151px 2fr 1fr; grid-template-rows: 96px auto; background-color: #f0f5fa; - height: 100vh; + + height: auto; + min-height: 100vh; } .ReactModal__Body--open, .ReactModal__Html--open { overflow: hidden; } + +.border-line { + border-bottom: 1px solid var(--color-blue5); + padding: 20px 10px; +} diff --git a/src/App.js b/src/App.js index 136c3a15..ce976fdd 100644 --- a/src/App.js +++ b/src/App.js @@ -7,37 +7,144 @@ import Loading from './pages/loading'; import Verification from './pages/verification'; import { AuthProvider, ProtectedRoute } from './context/auth'; import { ModalProvider } from './context/modal'; +import { PostsProvider } from './context/posts'; +import { CommentsProvider } from './context/comments'; import Welcome from './pages/welcome'; +import { FormProvider } from './context/form'; +import Cohort from './pages/cohort'; +import ProfilePage from './pages/profile'; +import { UserRoleProvider } from './context/userRole.'; +import EditPage from './pages/edit'; +import SearchPage from './pages/search'; +import { SearchResultsProvider } from './context/searchResults'; +import AddStudent from './pages/addStudent'; +import AddCohort from './pages/addCohort'; +import NewStudent from './pages/cohort/newStudent'; + +import { CohortProvider } from './context/selectedCohort'; +import EditCohort from './pages/editCohort'; + + const App = () => { return ( <> - - - } /> - } /> - } /> - } /> - - - - - } - /> - - - - } - /> - - + + + + + + + + } /> + } /> + } /> + } /> + + + + } + /> + + + + } + /> + + + + + + } + /> + + + + + }/> + + + + }/> + + + + + } + /> + + + + + }/> + + + + }/> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + + + + ); diff --git a/src/assets/icons/EditIcon.js b/src/assets/icons/EditIcon.js new file mode 100644 index 00000000..a3784603 --- /dev/null +++ b/src/assets/icons/EditIcon.js @@ -0,0 +1,9 @@ +const EditIcon = () => { + return ( + + + + ); +}; + +export default EditIcon; diff --git a/src/assets/icons/addCohortIcon.js b/src/assets/icons/addCohortIcon.js new file mode 100644 index 00000000..418e64da --- /dev/null +++ b/src/assets/icons/addCohortIcon.js @@ -0,0 +1,9 @@ +const AddCohortIcon = () => { + return ( + <> + + + ) +} + +export default AddCohortIcon \ No newline at end of file diff --git a/src/assets/icons/addStudentIcon.js b/src/assets/icons/addStudentIcon.js new file mode 100644 index 00000000..0312da9c --- /dev/null +++ b/src/assets/icons/addStudentIcon.js @@ -0,0 +1,8 @@ +const AddStudentIcon = () => { + return ( + <> + + + ) +} +export default AddStudentIcon \ No newline at end of file diff --git a/src/assets/icons/arrowBack.js b/src/assets/icons/arrowBack.js new file mode 100644 index 00000000..326dc72d --- /dev/null +++ b/src/assets/icons/arrowBack.js @@ -0,0 +1,8 @@ +const ArrowBack = () => { + + return ( + + ); +} + +export default ArrowBack; \ No newline at end of file diff --git a/src/assets/icons/arrowDownIcon.js b/src/assets/icons/arrowDownIcon.js new file mode 100644 index 00000000..652b56f1 --- /dev/null +++ b/src/assets/icons/arrowDownIcon.js @@ -0,0 +1,6 @@ +const ArrowDownIcon = () => { +return (<> + +) +} +export default ArrowDownIcon \ No newline at end of file diff --git a/src/assets/icons/checkCircleIcon.js b/src/assets/icons/checkCircleIcon.js new file mode 100644 index 00000000..e7b44bcb --- /dev/null +++ b/src/assets/icons/checkCircleIcon.js @@ -0,0 +1,7 @@ +const CheckCircleIcon = () => { + return (<> + + ) +} + +export default CheckCircleIcon \ No newline at end of file diff --git a/src/assets/icons/checkIcon.js b/src/assets/icons/checkIcon.js new file mode 100644 index 00000000..6c8b9f56 --- /dev/null +++ b/src/assets/icons/checkIcon.js @@ -0,0 +1,8 @@ +const CheckIcon = () => { +return (<> + + + +) +} +export default CheckIcon \ No newline at end of file diff --git a/src/assets/icons/cogIcon.js b/src/assets/icons/cogIcon.js deleted file mode 100644 index 9253d6be..00000000 --- a/src/assets/icons/cogIcon.js +++ /dev/null @@ -1,12 +0,0 @@ -const CogIcon = () => { - return ( - - - - ); -}; - -export default CogIcon; diff --git a/src/assets/icons/cohortIcon-fill.js b/src/assets/icons/cohortIcon-fill.js index 817b44b7..3cc54e18 100644 --- a/src/assets/icons/cohortIcon-fill.js +++ b/src/assets/icons/cohortIcon-fill.js @@ -1,11 +1,6 @@ const CohortIconFill = () => { return ( - - - + ); }; diff --git a/src/assets/icons/cohortIcon.js b/src/assets/icons/cohortIcon.js index 4b7ee14f..8a8aa551 100644 --- a/src/assets/icons/cohortIcon.js +++ b/src/assets/icons/cohortIcon.js @@ -1,10 +1,6 @@ -const CohortIcon = ({ colour = '#64648C' }) => { +const CohortIcon = () => { return ( - - + ); }; diff --git a/src/assets/icons/commentBubbleIcon.js b/src/assets/icons/commentBubbleIcon.js new file mode 100644 index 00000000..79048a2b --- /dev/null +++ b/src/assets/icons/commentBubbleIcon.js @@ -0,0 +1,9 @@ +const CommentBubbleIcon = () => { + return ( + + + +); +}; + +export default CommentBubbleIcon; diff --git a/src/assets/icons/commentBubbleIconFilled.js b/src/assets/icons/commentBubbleIconFilled.js new file mode 100644 index 00000000..115b18ce --- /dev/null +++ b/src/assets/icons/commentBubbleIconFilled.js @@ -0,0 +1,8 @@ +const CommentBubbleIconFilled = () => { + return ( + + + ); +}; + +export default CommentBubbleIconFilled; diff --git a/src/assets/icons/dataAnalyticsLogo.js b/src/assets/icons/dataAnalyticsLogo.js new file mode 100644 index 00000000..f39dfb47 --- /dev/null +++ b/src/assets/icons/dataAnalyticsLogo.js @@ -0,0 +1,8 @@ +const DataAnalyticsLogo = () => { + return ( + <> + + ) +} + +export default DataAnalyticsLogo \ No newline at end of file diff --git a/src/assets/icons/editCohortIcon.js b/src/assets/icons/editCohortIcon.js new file mode 100644 index 00000000..bebce7f1 --- /dev/null +++ b/src/assets/icons/editCohortIcon.js @@ -0,0 +1,9 @@ +const EditCohortIcon = () => { + return ( + <> + + + ) +} + +export default EditCohortIcon \ No newline at end of file diff --git a/src/assets/icons/excersicesIcon.js b/src/assets/icons/excersicesIcon.js new file mode 100644 index 00000000..613b9473 --- /dev/null +++ b/src/assets/icons/excersicesIcon.js @@ -0,0 +1,10 @@ +const ExcersicesIcon = () => { + return ( + + + + + ) +} + +export default ExcersicesIcon \ No newline at end of file diff --git a/src/assets/icons/excersicesIconFilled.js b/src/assets/icons/excersicesIconFilled.js new file mode 100644 index 00000000..3613a36b --- /dev/null +++ b/src/assets/icons/excersicesIconFilled.js @@ -0,0 +1,7 @@ +const ExcersicesIconFilled = () => { + return ( + + ) +} + +export default ExcersicesIconFilled \ No newline at end of file diff --git a/src/assets/icons/exitIcon.js b/src/assets/icons/exitIcon.js new file mode 100644 index 00000000..9600cd9a --- /dev/null +++ b/src/assets/icons/exitIcon.js @@ -0,0 +1,11 @@ +const ExitIcon = () => { + return ( + <> + + + + + ) +} + +export default ExitIcon \ No newline at end of file diff --git a/src/assets/icons/frontEndLogo.js b/src/assets/icons/frontEndLogo.js new file mode 100644 index 00000000..ac6ad0d3 --- /dev/null +++ b/src/assets/icons/frontEndLogo.js @@ -0,0 +1,8 @@ +const FrontEndLogo = () => { + return ( + <> + + ) +} + +export default FrontEndLogo \ No newline at end of file diff --git a/src/assets/icons/heartIcon.js b/src/assets/icons/heartIcon.js new file mode 100644 index 00000000..97e063d9 --- /dev/null +++ b/src/assets/icons/heartIcon.js @@ -0,0 +1,8 @@ +const HeartIcon = () => { + return ( + + + ); +}; + +export default HeartIcon; diff --git a/src/assets/icons/heartIconFilled.js b/src/assets/icons/heartIconFilled.js new file mode 100644 index 00000000..eb01618a --- /dev/null +++ b/src/assets/icons/heartIconFilled.js @@ -0,0 +1,8 @@ +const HeartIconFilled = () => { + return ( + + + ); +}; + +export default HeartIconFilled; \ No newline at end of file diff --git a/src/assets/icons/homeIcon.js b/src/assets/icons/homeIcon.js index 235fcfdf..71c3b87d 100644 --- a/src/assets/icons/homeIcon.js +++ b/src/assets/icons/homeIcon.js @@ -1,9 +1,7 @@ -const HomeIcon = ({ colour = '#64648C' }) => { +const HomeIcon = () => { return ( - - - - ); + + ); }; export default HomeIcon; diff --git a/src/assets/icons/homeIconFilled.js b/src/assets/icons/homeIconFilled.js new file mode 100644 index 00000000..78c4998a --- /dev/null +++ b/src/assets/icons/homeIconFilled.js @@ -0,0 +1,7 @@ +const HomeIconFilled = ({colour}) => { + return ( + + ); +}; + +export default HomeIconFilled; diff --git a/src/assets/icons/lockIcon.js b/src/assets/icons/lockIcon.js new file mode 100644 index 00000000..a785b31a --- /dev/null +++ b/src/assets/icons/lockIcon.js @@ -0,0 +1,11 @@ + +const LockIcon = () => { + return ( + + + + + ); +}; + +export default LockIcon; diff --git a/src/assets/icons/logsIcon.js b/src/assets/icons/logsIcon.js new file mode 100644 index 00000000..f0e086c8 --- /dev/null +++ b/src/assets/icons/logsIcon.js @@ -0,0 +1,9 @@ +const LogsIcon = () => { + return ( + + + + ) +} + +export default LogsIcon \ No newline at end of file diff --git a/src/assets/icons/logsIconFilled.js b/src/assets/icons/logsIconFilled.js new file mode 100644 index 00000000..464f3cae --- /dev/null +++ b/src/assets/icons/logsIconFilled.js @@ -0,0 +1,9 @@ +const LogsIconFilled = () => { + return ( + + + + ) +} + +export default LogsIconFilled \ No newline at end of file diff --git a/src/assets/icons/notesIcon.js b/src/assets/icons/notesIcon.js new file mode 100644 index 00000000..6bd41927 --- /dev/null +++ b/src/assets/icons/notesIcon.js @@ -0,0 +1,9 @@ +const NotesIcon = () => { + return ( + + + + ) +} + +export default NotesIcon \ No newline at end of file diff --git a/src/assets/icons/notesIconFilled.js b/src/assets/icons/notesIconFilled.js new file mode 100644 index 00000000..b53035a0 --- /dev/null +++ b/src/assets/icons/notesIconFilled.js @@ -0,0 +1,9 @@ +const NotesIconFilled = () => { + return ( + + + + ) +} + +export default NotesIconFilled \ No newline at end of file diff --git a/src/assets/icons/profileIcon.js b/src/assets/icons/profileIcon.js index 9f57f958..462e7410 100644 --- a/src/assets/icons/profileIcon.js +++ b/src/assets/icons/profileIcon.js @@ -1,21 +1,7 @@ -const ProfileIcon = ({ colour = '#64648C', background = 'transparent' }) => { +const ProfileIcon = () => { return ( - - + + ); }; diff --git a/src/assets/icons/profileIconFilled.js b/src/assets/icons/profileIconFilled.js new file mode 100644 index 00000000..3f9fb82f --- /dev/null +++ b/src/assets/icons/profileIconFilled.js @@ -0,0 +1,17 @@ +const ProfileIconFilled = () => { + return ( + + + ); +}; + +export default ProfileIconFilled; + + + + + diff --git a/src/assets/icons/reporticon.js b/src/assets/icons/reporticon.js new file mode 100644 index 00000000..7e648e42 --- /dev/null +++ b/src/assets/icons/reporticon.js @@ -0,0 +1,9 @@ + + +const ReportIcon = () => { + return ( + + ); +}; + +export default ReportIcon; diff --git a/src/assets/icons/sendIcon.js b/src/assets/icons/sendIcon.js new file mode 100644 index 00000000..d3ff5b41 --- /dev/null +++ b/src/assets/icons/sendIcon.js @@ -0,0 +1,9 @@ +const SendIcon = () => { + return ( + + + + ); +}; + +export default SendIcon; \ No newline at end of file diff --git a/src/assets/icons/software-logo.js b/src/assets/icons/software-logo.js new file mode 100644 index 00000000..0c0a161d --- /dev/null +++ b/src/assets/icons/software-logo.js @@ -0,0 +1,7 @@ +const SoftwareLogo = () => { + return ( + + ) +} + +export default SoftwareLogo \ No newline at end of file diff --git a/src/assets/icons/specialismIcon.js b/src/assets/icons/specialismIcon.js new file mode 100644 index 00000000..f4c17a85 --- /dev/null +++ b/src/assets/icons/specialismIcon.js @@ -0,0 +1,59 @@ +// contains all specialism icons used in the app, +// add yours if you add more courses, just copy&paste and change the function name, color prop and path data, and add it to the export statement below. + +// maybe add the same style as for ProfileCircle where these are used? + +const SoftwareIcon = ({ color = '#28C84F', background = 'transparent'}) => { + return ( + + + + ) +}; + +const FrontendIcon = ({ color = '#FFFFFF', background = '#6E6EDC'}) => { + return ( + + + + ) +}; + +const DataAnalyticsIcon = ({ color = '#FFFFFF', background = '#46A0FA'}) => { + return ( + + + + ) +}; + + +export { SoftwareIcon, FrontendIcon, DataAnalyticsIcon }; \ No newline at end of file diff --git a/src/components/card/style.css b/src/components/card/style.css index c1f0e8b2..e7e867a5 100644 --- a/src/components/card/style.css +++ b/src/components/card/style.css @@ -2,11 +2,11 @@ background: white; padding: 24px; border-radius: 8px; - width: 100%; + width: 100% !important; margin-bottom: 25px; border: 1px #e6ebf5 solid; } .card-shadow { box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.25); -} +} \ No newline at end of file diff --git a/src/components/comment/dropdown/index.js b/src/components/comment/dropdown/index.js new file mode 100644 index 00000000..4246495a --- /dev/null +++ b/src/components/comment/dropdown/index.js @@ -0,0 +1,66 @@ +import { useState, useRef, useEffect } from 'react'; +import { CascadingMenuComment } from './menu/index'; +import { useComments } from '../../../context/comments'; + +const MenuComment = ({ edit=false, del=false, report=false, menuVisible, commentText, postId, commentId, name, onCommentDeleted, onDeleteComment }) => { + const [isMenuVisible, setIsMenuVisible] = useState(menuVisible || false); + const menuRef = useRef(null); + const { deleteComment } = useComments(); + + const handleCommentDeleted = async () => { + if (onDeleteComment) { + await onDeleteComment(); + } else { + const success = await deleteComment(postId, commentId); + if (success && onCommentDeleted) { + onCommentDeleted(commentId); + } + } + }; + + // Close menu when clicking outside + useEffect(() => { + const handleClickOutside = (event) => { + if (menuRef.current && !menuRef.current.contains(event.target)) { + setIsMenuVisible(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + return ( +
+
{ + setIsMenuVisible(!isMenuVisible); + }}> + + β€’ + β€’ + β€’ + +
+
+ {isMenuVisible && ( + + )} +
+
+ ); +}; + +export default MenuComment; \ No newline at end of file diff --git a/src/components/comment/dropdown/menu/index.js b/src/components/comment/dropdown/menu/index.js new file mode 100644 index 00000000..bbaf40e4 --- /dev/null +++ b/src/components/comment/dropdown/menu/index.js @@ -0,0 +1,85 @@ +import { useState } from 'react'; +import EditIcon from '../../../../assets/icons/EditIcon'; +import DeleteIcon from '../../../../assets/icons/deleteIcon'; +import Menu from '../../../menu'; +import MenuItem from '../../../menu/menuItem'; +import './style.css'; +import ReportIcon from '../../../../assets/icons/reporticon'; +import SimpleProfileCircle from '../../../simpleProfileCircle'; + +const ProfileCircleComment = ({ initials, menuVisible, commentText, postId, commentId, name }) => { + const [isMenuVisible, setIsMenuVisible] = useState(menuVisible || false); + + return ( +
setIsMenuVisible(!isMenuVisible)}> + {isMenuVisible && ( + + )} + +
+ + {/*

{initials}

*/} +
+
+ ); +}; + +export const CascadingMenuComment = ({edit, del, report, commentText, postId, commentId, name, isMenuVisible, setIsMenuVisible, onCommentDeleted }) => { + return ( + + {edit && + } + linkTo="" + text="Edit comment" + clickable={"CommentModal"} + commentText={commentText} + postId={postId} + commentId={commentId} + name={name} + isMenuVisible={isMenuVisible} + setIsMenuVisible={setIsMenuVisible} + /> + } + {del && + } + text="Delete comment" + clickable={"DeleteComment"} + postId={postId} + commentId={commentId} + name={name} + isMenuVisible={isMenuVisible} + setIsMenuVisible={setIsMenuVisible} + onCommentDeleted={onCommentDeleted} + /> +} + {report && + } + text="Report comment" + clickable={"ReportComment"} + postId={postId} + commentId={commentId} + name={name} + isMenuVisible={isMenuVisible} + setIsMenuVisible={setIsMenuVisible} + onCommentDeleted={onCommentDeleted} + /> +} + + + ); +}; + +export default ProfileCircleComment; \ No newline at end of file diff --git a/src/components/comment/dropdown/menu/style.css b/src/components/comment/dropdown/menu/style.css new file mode 100644 index 00000000..a183ef4f --- /dev/null +++ b/src/components/comment/dropdown/menu/style.css @@ -0,0 +1,37 @@ +/* Comment dropdown menu styles */ +.comment .edit-icon-wrapper { + position: relative; + margin-left: auto; +} + +.comment .icon-button { + width: 40px; + height: 40px; + margin-top: 4px; + margin-left: 4px; + background-color: #F0F5FA; + border: none; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.comment .dots { + display: flex; + gap: 4px; + font-size: 18px; + color: #64648C; +} + +.comment .dot { + line-height: 1; +} + +.comment .menu-left { + position: absolute; + top: 100%; + right: 0; + z-index: 10; +} \ No newline at end of file diff --git a/src/components/comment/index.js b/src/components/comment/index.js index 16ffb366..9b73b5a5 100644 --- a/src/components/comment/index.js +++ b/src/components/comment/index.js @@ -1,10 +1,53 @@ -const Comment = ({ name, content }) => { +import MenuComment from './dropdown'; +import { useComments } from '../../context/comments'; +import './style.css'; +import UserIcon from '../profile-icon'; +import SimpleProfileCircle from '../simpleProfileCircle'; + +const Comment = ({ id,userId, name, content, postId, commentId, photo, onCommentDeleted }) => { + const { deleteComment } = useComments(); + + const initials = name.match(/\b(\w)/g)?.join('') || 'NA'; + + const handleDeleteComment = async () => { + const success = await deleteComment(postId, commentId); + if (success && onCommentDeleted) { + onCommentDeleted(commentId); + } + }; + return ( - <> -
{name}
-

{content}

- +
+
+ {/*
*/} + + + {/*

{initials}

*/} +{/*
*/} +
+
+
{name}
+

{content}

+
+ + +
); }; -export default Comment; +export default Comment; \ No newline at end of file diff --git a/src/components/comment/style.css b/src/components/comment/style.css new file mode 100644 index 00000000..7d9af6c6 --- /dev/null +++ b/src/components/comment/style.css @@ -0,0 +1,35 @@ +.comment { + display: grid; + grid-template-columns: 56px 1fr auto; + gap: 12px; + align-items: flex-start +} + +.comment__bubble { + background: var(--color-blue5); + border-radius: 16px; + padding: 12px 16px; + color: var(--color-blue) +} + +.comment__author { + font-size: .9rem; + font-weight: 600; + margin: 0 0 4px 0; + color: var(--color-blue) +} + +.comment__content { + margin: 0; + line-height: 1.4 +} + +.comment__menu { + border: 0; + background: transparent; + color: var(--color-blue1); + padding: 4px 8px; + border-radius: 4px; + font-size: 1.2rem; + line-height: 1 +} \ No newline at end of file diff --git a/src/components/courseIcon/index.js b/src/components/courseIcon/index.js new file mode 100644 index 00000000..bcd0466a --- /dev/null +++ b/src/components/courseIcon/index.js @@ -0,0 +1,34 @@ +import DataAnalyticsLogo from "../../assets/icons/dataAnalyticsLogo" +import FrontEndLogo from "../../assets/icons/frontEndLogo" +import SoftwareLogo from "../../assets/icons/software-logo" +import './style.css' + +const CourseIcon = ({courseName, cohort, startDate, endDate}) => { + return( + <> +
+
+ {courseName === "Software Development" && } + {courseName === "Front-End Development" && } + {courseName === "Data Analytics" && } +
+ +
+

{courseName}, Cohort {cohort}

+

{startDate} - {endDate}

+
+
+ + ) +} + +export default CourseIcon \ No newline at end of file diff --git a/src/components/courseIcon/style.css b/src/components/courseIcon/style.css new file mode 100644 index 00000000..65f74dff --- /dev/null +++ b/src/components/courseIcon/style.css @@ -0,0 +1,66 @@ +.course { + display: flex; + align-items: center; + padding: 8px 12px; + gap: 12px; + margin-bottom: -23px; +} + +.course-info { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-width: 0; +} + + +.course-title { + margin: 0; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 20px; + color: #000046; + +} + +.pop-up { + position: absolute; + top: 100%; /* under .edit-icon */ + right: 0; /* hΓΈyrejustert med ikonet */ + margin-top: 8px; + z-index: 100; +} + + +.edit-icon-wrapper { + position: relative; + display: inline-block; +} + +.icon-button { + width: 40px; + height: 40px; + margin-top: 4px; + margin-left: 4px; + background-color: #F0F5FA; + border: none; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.dots { + display: flex; + gap: 4px; + font-size: 18px; + color:#64648C; + +} + +.dot { + line-height: 1; +} \ No newline at end of file diff --git a/src/components/createComment/index.js b/src/components/createComment/index.js new file mode 100644 index 00000000..b60ed9fa --- /dev/null +++ b/src/components/createComment/index.js @@ -0,0 +1,132 @@ +import { forwardRef, useState } from 'react'; +import useAuth from '../../hooks/useAuth'; +import { useComments } from '../../context/comments'; +import jwtDecode from 'jwt-decode'; +import SendIcon from '../../assets/icons/sendIcon'; +import './style.css'; +import SimpleProfileCircle from '../simpleProfileCircle'; + +const CreateComment = forwardRef(({ postId, onCommentAdded }, ref) => { + const { token } = useAuth(); + const { addComment } = useComments(); + const [message, setMessage] = useState(null); + const [text, setText] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Safely decode token with fallback + let decodedToken = {}; + try { + if (token || localStorage.getItem('token')) { + decodedToken = jwtDecode(token || localStorage.getItem('token')) || {}; + } + } catch (error) { + console.error('Invalid token in CreateComment:', error); + } + + const fullName = `${decodedToken.firstName || decodedToken.first_name || 'Current'} ${decodedToken.lastName || decodedToken.last_name || 'User'}`; + const initials = fullName?.match(/\b(\w)/g)?.join('') || 'NO'; + + const onChange = (e) => { + setText(e.target.value); + }; + + const onSubmit = async (e) => { + e.preventDefault(); + if (!text.trim() || isSubmitting) return; + + setIsSubmitting(true); + try { + const { userId } = decodedToken; + + if (!userId) { + setMessage('Could not determine user. Please log in again.'); + setIsSubmitting(false); + return; + } + + const response = await addComment(postId, { body: text, userId }); + console.log('Comment created successfully:', response); + + // Store the comment text before clearing + const commentText = text; + + // Clear the input and show success message + setText(''); + setMessage('Comment posted successfully!'); + + // Create a properly structured comment object for immediate display + const firstName = decodedToken.firstName || decodedToken.first_name || 'Current'; + const lastName = decodedToken.lastName || decodedToken.last_name || 'User'; + + const newComment = { + id: response.data?.comment?.id, + body: commentText, + user: { + id: userId, + profile: { + firstName, + lastName, + photo: localStorage.getItem("userPhoto") || null + } + }, + timeCreated: new Date().toISOString() + }; + + // Call the callback to update the parent component + if (onCommentAdded) { + onCommentAdded(newComment); + } + + // Clear success message after 2 seconds + setTimeout(() => { + setMessage(null); + }, 2000); + + } catch (error) { + console.error('Error creating comment:', error); + setMessage('Failed to post comment. Please try again.'); + setTimeout(() => { + setMessage(null); + }, 3000); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+ + {/*

{initials}

*/} +
+ +
+ + +
+ + {message &&

{message}

} +
+ ); +}); + +CreateComment.displayName = 'CreateComment'; + +export default CreateComment; \ No newline at end of file diff --git a/src/components/createComment/style.css b/src/components/createComment/style.css new file mode 100644 index 00000000..b0f754c7 --- /dev/null +++ b/src/components/createComment/style.css @@ -0,0 +1,102 @@ +.create-comment { + display: grid; + grid-template-columns: 40px 1fr; + gap: 8px; + align-items: center; + padding: 8px 0; +} + +.profile-icon--sm { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + color: white; + font-size: 14px +} + +.profile-icon--sm p { + line-height: 40px; + font-size: 1rem +} + +.create-comment__input-wrapper { + position: relative; + display: flex; + align-items: center; + width: 100%; +} + +.create-comment__input { + width: 100%; + padding: 12px 60px 12px 16px; /* space for the send button */ + border: 1px solid transparent; + border-radius: 16px; /* match comment bubble radius */ + background-color: var(--color-blue5); + font-size: 14px; + outline: none; + transition: background-color 0.2s ease; + color: var(--color-blue); + cursor: text; +} + +.create-comment__input:focus { + background-color: var(--color-blue5); + border-color: var(--color-blue2); +} + +.create-comment__input::placeholder { + color: var(--color-blue1); +} + +.create-comment__submit { + /* position via flex instead of absolute to avoid overlay issues */ + width: 36px !important; /* override global button width */ + height: 36px; + min-width: 36px; + border: none; + border-radius: 50% !important; + background-color: var(--color-blue5); + color: var(--color-blue); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + padding: 0 !important; /* neutralize global button padding */ + opacity: 0.8; + /* Visually sit over the right edge of the input without absolute */ + margin-left: -44px; /* pull button into input area */ + margin-right: 8px; /* keep a small right gap */ +} + +.create-comment__submit:enabled { + background-color: var(--color-blue5); + color: var(--color-blue); + opacity: 1; +} + +.create-comment__submit:enabled:hover { + background-color: var(--color-blue5); +} + +.create-comment__submit:disabled { + background-color: #e4e6ea; + color: var(--color-blue1); + cursor: not-allowed; +} + +.create-comment__submit svg { + width: 18px; + height: 18px; +} + +.create-comment__message { + grid-column: 1 / -1; + margin-top: 8px; + font-size: 12px; + color: var(--color-blue1); +} \ No newline at end of file diff --git a/src/components/createPostModal/index.js b/src/components/createPostModal/index.js index f19e5f1d..36387738 100644 --- a/src/components/createPostModal/index.js +++ b/src/components/createPostModal/index.js @@ -1,37 +1,120 @@ import { useState } from 'react'; import useModal from '../../hooks/useModal'; +import useAuth from '../../hooks/useAuth'; +import { usePosts } from '../../context/posts'; import './style.css'; import Button from '../button'; +import { post } from '../../service/apiClient'; +import jwtDecode from 'jwt-decode'; +import SimpleProfileCircle from '../simpleProfileCircle'; -const CreatePostModal = () => { +const CreatePostModal = ({ authorName, onPostAdded }) => { // Use the useModal hook to get the closeModal function so we can close the modal on user interaction const { closeModal } = useModal(); + const { token } = useAuth(); + const { addPost } = usePosts(); const [message, setMessage] = useState(null); const [text, setText] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + // Safely decode token with fallback + let decodedToken = {}; + try { + if (token || localStorage.getItem('token')) { + decodedToken = jwtDecode(token || localStorage.getItem('token')) || {}; + } + } catch (error) { + console.error('Invalid token in CreatePostModal:', error); + } + + const fullName = `${decodedToken.firstName || decodedToken.first_name || 'Current'} ${decodedToken.lastName || decodedToken.last_name || 'User'}`; + const initials = fullName?.match(/\b(\w)/g)?.join('') || 'NO'; + const onChange = (e) => { setText(e.target.value); }; - const onSubmit = () => { - setMessage('Submit button was clicked! Closing modal in 2 seconds...'); + const onSubmit = async () => { + if (!text.trim() || isSubmitting) return; - setTimeout(() => { - setMessage(null); - closeModal(); - }, 2000); + setIsSubmitting(true); + setMessage('Creating post...'); + + try { + const { userId } = decodedToken; + + if (!userId) { + setMessage('Could not determine user. Please log in again.'); + setIsSubmitting(false); + return; + } + + const response = await post('posts', { content: text, userId }); + console.log('Post created successfully:', response); + + // Get user info from token for immediate display + const firstName = decodedToken.firstName || decodedToken.first_name || 'Current'; + const lastName = decodedToken.lastName || decodedToken.last_name || 'User'; + console.log("response", response) + // Create a properly structured post object for immediate display + const newPost = { + id: response.data?.post?.id || response.id || `temp-${Date.now()}-${Math.random()}`, + content: text, + user: { + id: userId, + profile: { + firstName, + lastName, + photo: localStorage.getItem("userPhoto") || null + + } + }, + timeCreated: new Date().toISOString(), + timeUpdated: new Date().toISOString(), + comments: [], + likes: 0 + }; + + // Clear the input and show success message + setText(''); + setMessage('Post created successfully!'); + + // Add the post using context + addPost(newPost); + + // Close modal after short delay + setTimeout(() => { + closeModal(); + }, 100); + + } catch (error) { + console.error('Error creating post:', error); + setMessage('Failed to create post. Please try again.'); + setTimeout(() => { + setMessage(null); + }, 3000); + } finally { + setIsSubmitting(false); + } }; return ( <>
-

AJ

+ + + + {/*

{initials}

*/}
-

Alex J

+

{fullName}

+{/* + */}
@@ -43,7 +126,7 @@ const CreatePostModal = () => { onClick={onSubmit} text="Post" classes={`${text.length ? 'blue' : 'offwhite'} width-full`} - disabled={!text.length} + disabled={!text.length || isSubmitting} />
diff --git a/src/components/createPostModal/style.css b/src/components/createPostModal/style.css index 989fc0d8..59356a3b 100644 --- a/src/components/createPostModal/style.css +++ b/src/components/createPostModal/style.css @@ -8,7 +8,29 @@ textarea { width: 100%; height: 256px; resize: none; - background-color: var(--color-blue5); - border-radius: 8px; + background-color: #e6ebf5 !important; + border: none; + border-radius: 9px; padding: 16px; + font-size: 14px; + color: #1c1e21; + outline: none; + transition: none; + font-family: inherit; +} + +textarea:focus { + background-color: #e6ebf5 !important; +} + +textarea:active { + background-color: #e6ebf5 !important; +} + +textarea:hover { + background-color: #e6ebf5 !important; +} + +textarea::placeholder { + color: #64648c; } diff --git a/src/components/dropdown/index.js b/src/components/dropdown/index.js new file mode 100644 index 00000000..9149a65e --- /dev/null +++ b/src/components/dropdown/index.js @@ -0,0 +1,75 @@ +import React, { useState, useRef, useEffect } from "react"; + +function DropdownMenu({ + label, + options = [], + value, + onChange, + placeholder = "Select an option", +}) { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const toggleDropdown = () => setIsOpen((prev) => !prev); + + const handleOptionClick = (optionValue) => { + onChange(optionValue); + setIsOpen(false); + }; + + const handleClickOutside = (event) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setIsOpen(false); + } + }; + + useEffect(() => { + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const normalizedOptions = Array.isArray(options) + ? options.map((opt) => + typeof opt === "object" + ? { label: opt.label, value: opt.value } + : { label: opt, value: opt } + ) + : []; + + const selectedOption = normalizedOptions.find((opt) => opt.value === value); + + return ( +
+ {label && } + + + {isOpen && ( + + )} +
+ ); +} + +export default DropdownMenu; diff --git a/src/components/dropdown/style.css b/src/components/dropdown/style.css new file mode 100644 index 00000000..9619e527 --- /dev/null +++ b/src/components/dropdown/style.css @@ -0,0 +1,45 @@ +/* Dropdown Button */ +.dropbtn { + background-color: #3498DB; + color: white; + padding: 16px; + font-size: 16px; + border: none; + cursor: pointer; +} + +/* Dropdown button on hover & focus */ +.dropbtn:hover, .dropbtn:focus { + background-color: #2980B9; +} + +/* The container
- needed to position the dropdown content */ +.dropdown { + position: relative; + left: 0; + display: inline-block; +} + +/* Dropdown Content (Hidden by Default) */ +.dropdown-content { + display: none; + position: absolute; + background-color: #f1f1f1; + min-width: 160px; + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); + z-index: 1; +} + +/* Links inside the dropdown */ +.dropdown-content a { + color: black; + padding: 12px 16px; + text-decoration: none; + display: block; +} + +/* Change color of dropdown links on hover */ +.dropdown-content a:hover {background-color: #ddd;} + +/* Show the dropdown menu (use JS to add this class to the .dropdown-content container when the user clicks on the dropdown button) */ +.show {display:block;} \ No newline at end of file diff --git a/src/components/editCommentModal/index.js b/src/components/editCommentModal/index.js new file mode 100644 index 00000000..507af4ec --- /dev/null +++ b/src/components/editCommentModal/index.js @@ -0,0 +1,83 @@ +import { useState } from 'react'; +import useModal from '../../hooks/useModal'; +import './style.css'; +import Button from '../button'; +// eslint-disable-next-line camelcase +import jwt_decode from 'jwt-decode'; +import useAuth from '../../hooks/useAuth'; +import { put } from '../../service/apiClient'; +import SimpleProfileCircle from '../simpleProfileCircle'; + + + +const EditCommentModal = ({ postText, postId, name, commentId}) => { + const { closeModal } = useModal(); + const { token } = useAuth(); + const [message, setMessage] = useState(null); + const [text, setText] = useState(postText || ''); + const initials = name?.match(/\b(\w)/g); + + + const onChange = (e) => { + setText(e.target.value); + }; + + const onSubmit = async () => { + try { + const { userId } = jwt_decode(token || localStorage.getItem('token')) || {}; + if (!userId) { + setMessage('Could not determine user. Please log in again.'); + return; + } + + const postResponse = await put(`posts/${String(postId)}/comments/${String(commentId)}`, { body: text, userId }); + console.log('Post updated successfully:', postResponse); + setMessage('Posted! Closing modal in 1.5 seconds...'); + setTimeout(() => { + setMessage(null); + closeModal(); + }, 1500); + } catch (error) { + console.error('Error creating post:', error); + setMessage('Failed to create post. Please try again.'); + } + + + window.location.reload(); + + console.log('Submitting comment:', text); + }; + + return ( + <> +
+
+ + {/*

{initials}

*/} +
+
+

{name}

+
+
+ +
+ +
+ +
+
+ + {message &&

{message}

} + + ); +}; + +export default EditCommentModal; diff --git a/src/components/editCommentModal/style.css b/src/components/editCommentModal/style.css new file mode 100644 index 00000000..5b7faa1d --- /dev/null +++ b/src/components/editCommentModal/style.css @@ -0,0 +1,28 @@ +.create-post-user-details { + display: grid; + grid-template-columns: 56px auto; + column-gap: 20px; +} + +textarea { + width: 100%; + height: 256px; + resize: none; + background-color: #e6ebf5; + border: none; + border-radius: 9px; + padding: 16px; + font-size: 14px; + color: #1c1e21; + outline: none; + transition: background-color 0.2s ease; + font-family: inherit; +} + +textarea:focus { + background-color: #e6ebf5; +} + +textarea::placeholder { + color: #64648c; +} diff --git a/src/components/editIconCohortTeacher/cascadingMenuCohort/index.js b/src/components/editIconCohortTeacher/cascadingMenuCohort/index.js new file mode 100644 index 00000000..99af2453 --- /dev/null +++ b/src/components/editIconCohortTeacher/cascadingMenuCohort/index.js @@ -0,0 +1,21 @@ + +import Menu from "../../menu" +import MenuItem from "../../menu/menuItem" +import AddCohortIcon from "../../../assets/icons/addCohortIcon" +import EditCohortIcon from "../../../assets/icons/editCohortIcon" +import DeleteIcon from "../../../assets/icons/deleteIcon" + +const CascadingMenuCohort = () => { + + return ( + <> + + } text="Add cohort" linkTo="/cohorts/new"/> + } text="Edit cohort"/> + } text="Delete cohort"/> + + + ) +} + +export default CascadingMenuCohort \ No newline at end of file diff --git a/src/components/editIconCohortTeacher/index.js b/src/components/editIconCohortTeacher/index.js new file mode 100644 index 00000000..1383dd44 --- /dev/null +++ b/src/components/editIconCohortTeacher/index.js @@ -0,0 +1,42 @@ +import { useState, useRef, useEffect } from 'react'; +import CascadingMenuCohort from './cascadingMenuCohort'; +import './style.css'; + + +const EditIconCohortTeacher = ({ initials, menuVisible }) => { + const [isMenuVisible, setIsMenuVisible] = useState(menuVisible || false); + const menuRef = useRef(null); + + // Lukk meny ved klikk utenfor + useEffect(() => { + const handleClickOutside = (event) => { + if (menuRef.current && !menuRef.current.contains(event.target)) { + setIsMenuVisible(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + return ( +
+
setIsMenuVisible(!isMenuVisible)}> + + β€’ + β€’ + β€’ + +
+
+ {isMenuVisible && } +
+
+ + + ); +}; + +export default EditIconCohortTeacher; diff --git a/src/components/editIconCohortTeacher/style.css b/src/components/editIconCohortTeacher/style.css new file mode 100644 index 00000000..7bd82ce4 --- /dev/null +++ b/src/components/editIconCohortTeacher/style.css @@ -0,0 +1,7 @@ + +.menu-left { + position: absolute; + left: -100px; /* juster avstanden etter behov */ + top: 0; + z-index: 1000; +} diff --git a/src/components/editIconCourse/cascadingMenuCourse/index.js b/src/components/editIconCourse/cascadingMenuCourse/index.js new file mode 100644 index 00000000..550d5c50 --- /dev/null +++ b/src/components/editIconCourse/cascadingMenuCourse/index.js @@ -0,0 +1,46 @@ + +import Menu from "../../menu" +import MenuItem from "../../menu/menuItem" +import AddCohortIcon from "../../../assets/icons/addCohortIcon" +import EditCohortIcon from "../../../assets/icons/editCohortIcon" +import DeleteIcon from "../../../assets/icons/deleteIcon" +import AddStudentIcon from "../../../assets/icons/addStudentIcon" +import { useState } from "react" +import { useSelectedCohortId } from "../../../context/selectedCohort" + +const CascadingMenuCourse = ({ id, setIsMenuVisible, setRefresh, cohort }) => { + + const [clicked, setClicked] = useState(false); + const {cohortId} = useSelectedCohortId(); + + return ( + + + } text="Add student to cohort" linkTo="/cohorts/add" /> + } linkTo="newStudent" text = "Add new student"/> + } text="Edit cohort" linkTo={`${cohort}/edit`}/> + {clicked ? + } + text="Confirm deletion" + selectedCohortId = {cohortId} + clickable="DeleteCohort" + style={{color: 'red'}} + setRefresh={setRefresh} + setIsMenuVisible={setIsMenuVisible} + /> + : + } + text="Delete Cohort" + clickable="Clicked" + clicked={clicked} + setClicked={setClicked} + setRefresh={setRefresh} + setIsMenuVisible={setIsMenuVisible} + /> + } + + ) +} + +export default CascadingMenuCourse diff --git a/src/components/editIconCourse/index.js b/src/components/editIconCourse/index.js new file mode 100644 index 00000000..cb7d2e87 --- /dev/null +++ b/src/components/editIconCourse/index.js @@ -0,0 +1,43 @@ +import { useState, useRef, useEffect } from 'react'; +import './style.css'; +import CascadingMenuCourse from './cascadingMenuCourse'; + + +const EditIconCouse = ({ initials, menuVisible, cohort, setRefresh }) => { + const [isMenuVisible, setIsMenuVisible] = useState(menuVisible || false); + const menuRef = useRef(null); + + // Lukk meny ved klikk utenfor + useEffect(() => { + const handleClickOutside = (event) => { + if (menuRef.current && !menuRef.current.contains(event.target)) { + setIsMenuVisible(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + return ( +
+
setIsMenuVisible(!isMenuVisible)}> + + β€’ + β€’ + β€’ + +
+ {console.log(cohort, "EditIconCouse")} +
+ {isMenuVisible && } +
+
+ + + ); +}; + +export default EditIconCouse; diff --git a/src/components/editIconCourse/style.css b/src/components/editIconCourse/style.css new file mode 100644 index 00000000..bb96e1e0 --- /dev/null +++ b/src/components/editIconCourse/style.css @@ -0,0 +1,7 @@ + +.menu-left { + position: absolute; + left: -300px; /* juster avstanden etter behov */ + top: 0; + z-index: 1000; +} diff --git a/src/components/editPostModal/index.js b/src/components/editPostModal/index.js index 1292ce17..f5bce1c0 100644 --- a/src/components/editPostModal/index.js +++ b/src/components/editPostModal/index.js @@ -2,33 +2,62 @@ import { useState } from 'react'; import useModal from '../../hooks/useModal'; import './style.css'; import Button from '../button'; +// eslint-disable-next-line camelcase +import jwt_decode from 'jwt-decode'; +import useAuth from '../../hooks/useAuth'; +import { put } from '../../service/apiClient'; +import SimpleProfileCircle from '../simpleProfileCircle'; -const EditPostModal = () => { + + +const EditPostModal = ({ postText, postId, name }) => { const { closeModal } = useModal(); + const { token } = useAuth(); const [message, setMessage] = useState(null); - const [text, setText] = useState(''); + const [text, setText] = useState(postText || ''); + const initials = name?.match(/\b(\w)/g)?.join('') || 'NA'; const onChange = (e) => { setText(e.target.value); }; - const onSubmit = () => { - setMessage('Submit button was clicked! Closing modal in 2 seconds...'); + const onSubmit = async () => { + try { + const { userId } = jwt_decode(token || localStorage.getItem('token')) || {}; + if (!userId) { + setMessage('Could not determine user. Please log in again.'); + return; + } + + const postResponse = await put(`posts/${String(postId)}`, { content: text, userId }); + console.log('Post updated successfully:', postResponse); + setMessage('Posted! Closing modal in 1.5 seconds...'); + setTimeout(() => { + setMessage(null); + closeModal(); + }, 1500); + } catch (error) { + console.error('Error creating post:', error); + setMessage('Failed to create post. Please try again.'); + } + + + window.location.reload(); - setTimeout(() => { - setMessage(null); - closeModal(); - }, 2000); + console.log('Submitting comment:', text); }; return ( <>
-

AJ

+ + {/*

{initials}

*/}
-

Alex J

+

{name}

diff --git a/src/components/editPostModal/style.css b/src/components/editPostModal/style.css index 989fc0d8..5b7faa1d 100644 --- a/src/components/editPostModal/style.css +++ b/src/components/editPostModal/style.css @@ -8,7 +8,21 @@ textarea { width: 100%; height: 256px; resize: none; - background-color: var(--color-blue5); - border-radius: 8px; + background-color: #e6ebf5; + border: none; + border-radius: 9px; padding: 16px; + font-size: 14px; + color: #1c1e21; + outline: none; + transition: background-color 0.2s ease; + font-family: inherit; +} + +textarea:focus { + background-color: #e6ebf5; +} + +textarea::placeholder { + color: #64648c; } diff --git a/src/components/form/numberInput/index.js b/src/components/form/numberInput/index.js new file mode 100644 index 00000000..00205226 --- /dev/null +++ b/src/components/form/numberInput/index.js @@ -0,0 +1,25 @@ + +const NumberInput = ({ value, onChange, name, label, icon, type = 'number',placeholder}) => { + + return ( +
+ {label && } + { + if (e.target.value.length > 11) { + e.target.value = e.target.value.slice(0, 11); + }}} + /> + {icon && {icon}} +
+ ); + } + +export default NumberInput; \ No newline at end of file diff --git a/src/components/form/textInput/index.js b/src/components/form/textInput/index.js index 39da3cae..a38857ce 100644 --- a/src/components/form/textInput/index.js +++ b/src/components/form/textInput/index.js @@ -1,59 +1,88 @@ import { useState } from 'react'; -const TextInput = ({ value, onChange, name, label, icon, type = 'text' }) => { - const [input, setInput] = useState(''); - const [showpassword, setShowpassword] = useState(false); - if (type === 'password') { - return ( -
- - { - onChange(e); - setInput(e.target.value); - }} - /> - {showpassword && } - -
- ); - } else { - return ( -
- {label && } +const TextInput = ({ + value, + onChange, + name, + label, + type = 'text', + placeholder, + readOnly = false, + icon, + onBlur, + + iconRight = true +}) => { + const [showPassword, setShowPassword] = useState(false); + const isPasswordField = type === 'password'; + const inputType = isPasswordField ? (showPassword ? 'text' : 'password') : type; + + return ( +
+ {label && } + +
- {icon && {icon}} -
- ); - } -}; + {icon && {icon}} -const EyeLogo = () => { - return ( - - - + {icon && ( + + {icon} + + )} + + {isPasswordField && ( + + )} +
+
); }; +const EyeLogo = () => ( + + + +); + export default TextInput; diff --git a/src/components/form/textInput/textInput.css b/src/components/form/textInput/textInput.css new file mode 100644 index 00000000..51d61385 --- /dev/null +++ b/src/components/form/textInput/textInput.css @@ -0,0 +1,24 @@ +.password-wrapper { + position: relative; + } + + .showpasswordbutton { + position: absolute; + top: 50%; + right: 8px; + transform: translateY(-50%); + background: none; + border: none; + cursor: pointer; + } + +======= +.showpasswordbutton { + position: absolute; + top: 50%; + right: 8px; + transform: translateY(-50%); + background: none; + border: none; + cursor: pointer; +} diff --git a/src/components/fullscreenCard/fullscreenCard.css b/src/components/fullscreenCard/fullscreenCard.css new file mode 100644 index 00000000..276aebf0 --- /dev/null +++ b/src/components/fullscreenCard/fullscreenCard.css @@ -0,0 +1,96 @@ +.fullscreen-card { + width: 150%; + height: auto; + min-height: 80vh; + background: white; + padding: 2rem; + box-sizing: border-box; + + display: flex; + flex-direction: column; + + border: 1px solid #e6ebf5; + border-radius: 12px; + margin: 0 auto; +} + +.top-bar { + height: auto; + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 2rem; + box-sizing: border-box; +} + +.profile-container { + display: flex; + flex-direction: row; + gap: 3rem; + padding: 3rem; + font-family: 'Inter', sans-serif; + font-size: 1.4rem; + flex-wrap: wrap; +} + +.photo-section { + flex: 0 0 auto; + display: flex; + flex-direction: column; + align-items: center; +} + +.profile-photo { + width: 150px; + height: 150px; + object-fit: cover; + border-radius: 50%; +} + +.info-section { + flex: 1; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.edit { + background-color: var(--color-blue5); + border: none; + + cursor: pointer; + font-size: 16px; +} + +.photo-section { + display: flex; + flex-direction: column; + align-items: center; +} + +.name-text { + margin-top: 0.5rem; + font-weight: 600; + font-size: 1.6rem; + text-align: center; +} + +.bio-text { + margin-top: 0.5rem; + text-align: center; + font-style: italic; + color: #555; + font-size: 1.4rem; + max-width: 200px; +} + +.name-text { + font-size: 2rem; + font-weight: 600; + margin: 0; + color: #222; +} + +.edit { + width: 15% !important; +} \ No newline at end of file diff --git a/src/components/fullscreenCard/index.js b/src/components/fullscreenCard/index.js new file mode 100644 index 00000000..95328354 --- /dev/null +++ b/src/components/fullscreenCard/index.js @@ -0,0 +1,83 @@ +import { useEffect, useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import './fullscreenCard.css'; +import ProfileData from '../../pages/profile/profile-data'; +import useAuth from '../../hooks/useAuth'; +import jwtDecode from 'jwt-decode'; +import { getUserById } from '../../service/apiClient'; +import '../../pages/loading'; +import SimpleProfileCircle from '../simpleProfileCircle'; + +const FullScreenCard = () => { + const [user, setUser] = useState(null); + const { token } = useAuth(); + + + // Safely decode token with fallback + let userId; + try { + const decodedToken = jwtDecode(token || localStorage.getItem('token')); + userId = decodedToken?.userId; + } catch (error) { + console.error('Invalid token:', error); + userId = null; + } + + const navigate = useNavigate(); + const { id } = useParams(); + const targetId = id ?? userId; + const isOwnProfile = String(targetId) === String(userId); + + useEffect(() => { + async function fetchUser() { + try { + const data = await getUserById(targetId); + setUser(data); + } catch (error) { + console.error('Error fetching user:', error); + } + } + fetchUser(); + }, [targetId]); + + const goToEdit = () => { + navigate(`/profile/${userId}/edit`); + }; + + if (!user || !user.profile) { + return
+
+

Loading...

+
+ +
+
+
+ } + + const firstname = user.profile.firstName; + const lastname = user.profile.lastName; + const name = firstname + " " + lastname; + const initials = name.split(" ").map((n)=>n[0]).join("").toUpperCase() + + + return ( +
+
+ + {/* n[0]).join("").toUpperCase()}/> */} +
+

{name}

+
+ {isOwnProfile && ( + + )} +
+
+ + +
+ ); +}; + +export default FullScreenCard; diff --git a/src/components/header/index.js b/src/components/header/index.js index c591f1e1..2ed0eedc 100644 --- a/src/components/header/index.js +++ b/src/components/header/index.js @@ -3,14 +3,31 @@ import useAuth from '../../hooks/useAuth'; import './style.css'; import Card from '../card'; import ProfileIcon from '../../assets/icons/profileIcon'; -import CogIcon from '../../assets/icons/cogIcon'; +import CogIcon from '../../assets/icons/EditIcon'; import LogoutIcon from '../../assets/icons/logoutIcon'; import { NavLink } from 'react-router-dom'; import { useState } from 'react'; +import jwtDecode from 'jwt-decode'; +import SimpleProfileCircle from '../simpleProfileCircle'; + const Header = () => { const { token, onLogout } = useAuth(); const [isMenuVisible, setIsMenuVisible] = useState(false); + + // Safely decode token with fallback + let decodedToken = {}; + try { + if (token || localStorage.getItem('token')) { + decodedToken = jwtDecode(token || localStorage.getItem('token')) || {}; + } + } catch (error) { + console.error('Invalid token in Header:', error); + } + + const fullName = `${decodedToken.firstName || decodedToken.first_name || 'Current'} ${decodedToken.lastName || decodedToken.last_name || 'User'}`; + const initials = fullName?.match(/\b(\w)/g)?.join('') || 'NO'; + const onClickProfileIcon = () => { setIsMenuVisible(!isMenuVisible); @@ -20,12 +37,20 @@ const Header = () => { return null; } + let userIdFromToken = null; + + const decoded = jwtDecode(token || localStorage.getItem('token')); + userIdFromToken = decoded.userId; + return ( -
+
-

AJ

+ +
{isMenuVisible && ( @@ -33,19 +58,23 @@ const Header = () => {
-

AJ

+ + +
-

Alex Jameson

- Software Developer, Cohort 3 +

{fullName}

+ {decoded.specialism}, Cohort {decoded.cohortId || "3"}
  • - +

    Profile

  • diff --git a/src/components/header/style.css b/src/components/header/style.css index cca1ac7a..361faa33 100644 --- a/src/components/header/style.css +++ b/src/components/header/style.css @@ -1,16 +1,14 @@ -header { +.app-header { grid-column: span 3; display: grid; grid-template-columns: 1fr 50px; background-color: #000046; padding: 20px 64px; } - -header .profile-icon { +.app-header .profile-icon { cursor: pointer; } - -header .user-panel { +.app-header .user-panel { position: absolute; right: 60px; top: 85px; diff --git a/src/components/menu/menu.css b/src/components/menu/menu.css index 619ac144..5b4c18fe 100644 --- a/src/components/menu/menu.css +++ b/src/components/menu/menu.css @@ -21,7 +21,8 @@ .menu li { position: relative; } -.menu li a { +.menu li a, +.menu li button { display: grid; grid-template-columns: 40px auto; gap: 20px; @@ -29,18 +30,22 @@ padding: 16px 28px; text-decoration: none; } -.menu li a svg { +.menu li a svg, +.menu li button svg { justify-self: center; } -.menu li a p { +.menu li a p, +.menu li button p { color: var(--color-blue1); font-size: 18px; line-height: 24px; } -.menu li a svg path { +.menu li a svg path, +.menu li button svg path { fill: var(--color-blue1); } -.menu li a svg:nth-of-type(2) { +.menu li a svg:nth-of-type(2), +.menu li button svg:nth-of-type(2) { position: absolute; top: 50%; right: 26px; @@ -53,13 +58,16 @@ .menu li:hover { background: var(--color-blue7); } -.menu li:hover > a > p { +.menu li:hover > a > p, +.menu li:hover > button > p { color: var(--color-blue); } -.menu li:hover > a > svg { +.menu li:hover > a > svg, +.menu li:hover > button > svg { fill: var(--color-blue); } -.menu li:hover > a > svg path { +.menu li:hover > a > svg path, +.menu li:hover > button > svg path { fill: var(--color-blue); } .menu li:hover > ul { diff --git a/src/components/menu/menuItem/index.js b/src/components/menu/menuItem/index.js index 7b045e88..22907e01 100644 --- a/src/components/menu/menuItem/index.js +++ b/src/components/menu/menuItem/index.js @@ -1,7 +1,211 @@ import { NavLink } from 'react-router-dom'; import ArrowRightIcon from '../../../assets/icons/arrowRightIcon'; +import useModal from '../../../hooks/useModal'; +import EditPostModal from '../../editPostModal'; +import EditCommentModal from '../../editCommentModal'; +import { usePosts } from '../../../context/posts'; +import { useComments } from '../../../context/comments'; +import { Snackbar, SnackbarContent } from '@mui/material'; +import Portal from '@mui/material/Portal'; +import { useState } from 'react'; +import CheckCircleIcon from '../../../assets/icons/checkCircleIcon'; +import { del, get } from '../../../service/apiClient'; +import { useSelectedCohortId } from '../../../context/selectedCohort'; + +const MenuItem = ({ icon, text, children, linkTo = '#nogo', clickable, postText, postId, name, isMenuVisible, setIsMenuVisible, commentText, commentId, onCommentDeleted, onPostDeleted, profileId, clicked, setClicked, setRefresh}) => { + const { openModal, setModal } = useModal(); + + const { deletePost } = usePosts(); + const { deleteComment } = useComments(); + const [snackbarOpen, setSnackbarOpen] = useState(false); + const [snackbarMessage, setSnackbarMessage] = useState(''); + const cohortId = useSelectedCohortId(); + + const showModal = () => { + setModal('Edit post', ); + setIsMenuVisible(false); + openModal(); + }; + + const showCommentModal = () => { + setModal('Edit comment', ); + setIsMenuVisible(false); + openModal(); + }; + + const handleDeletePost = async () => { + console.log('deletePost function called'); + // Show feedback first so the component stays mounted for the Snackbar + setSnackbarMessage('Post deleted, wait two seconds'); + setSnackbarOpen(true); + // Perform the destructive action after the Snackbar displays + setTimeout(async () => { + try { + const success = await deletePost(postId); + if (success) { + console.log('Post deleted successfully'); + if (onPostDeleted) onPostDeleted(postId); + } else { + console.error('Failed to delete post'); + } + } catch (error) { + console.error('Error deleting post:', error); + } finally { + setIsMenuVisible(false); + } + }, 100); + }; + + const handleClick = () => { + setClicked(!clicked); + } + + const handleDeleteCohort = async () => { + console.log("deleteCohort function called, cohortId:", cohortId) + + console.log('deleteCohort function called'); + try { + await del(`cohorts/${cohortId.cohortId}`); + setSnackbarMessage('Cohort deleted successfully'); + setSnackbarOpen(true); + setTimeout(() => { + setIsMenuVisible(false); + setRefresh(prev => !prev); + }, 2500); + } catch(error) { + console.log("couldnt delete cohort", error) + setIsMenuVisible(false); + setRefresh(prev => !prev); + } + } + + const handleDeleteUser = async () => { + console.log('deleteUser function called'); + try { + const profile = await get('profiles/' + profileId); + await del('users/' + profile.data.profile.user.id); + setSnackbarMessage('User deleted successfully'); + setSnackbarOpen(true); + setTimeout(() => { + setIsMenuVisible(false); + setRefresh(prev => !prev); + }, 2100); + } catch(error) { + console.log("couldnt delete user", error) + setIsMenuVisible(false); + setRefresh(prev => !prev); + } + } + + const handleDeleteComment = async () => { + console.log('deleteComment function called'); + + // If there's a callback provided, use it instead of calling the API directly + if (onCommentDeleted) { + // Show feedback first, then call the callback after delay + setSnackbarMessage('Comment deleted, wait two seconds'); + setSnackbarOpen(true); + setTimeout(() => { + onCommentDeleted(commentId); + setIsMenuVisible(false); + }, 2100); + return; + } + + // Only call the API directly if no callback is provided + setSnackbarMessage('Comment deleted'); + setSnackbarOpen(true); + setTimeout(async () => { + try { + const success = await deleteComment(postId, commentId); + if (success) { + console.log('Comment deleted successfully'); + } else { + console.error('Failed to delete comment'); + } + } catch (error) { + console.error('Error deleting comment:', error); + } finally { + setIsMenuVisible(false); + } + }, 2100); + }; + + const handleReport = () => { + // Show the snackbar first; delay hiding the menu so the component doesn't unmount before Snackbar renders + setSnackbarMessage('Reported'); + setSnackbarOpen(true); + setTimeout(() => { + setIsMenuVisible(false); + }, 2100); // slightly longer than autoHideDuration + }; + + const getClickHandler = () => { + switch (clickable) { + case "Modal": + return showModal; + case "CommentModal": + return showCommentModal; + case "Delete": + return handleDeletePost; + case "DeleteComment": + return handleDeleteComment; + case "Report": + return handleReport; + case "ReportComment": + return handleReport; + case "DeleteUser": + return handleDeleteUser; + case "DeleteCohort": + return handleDeleteCohort; + case "Clicked": + return handleClick; + default: + return undefined; + } + }; + + if (clickable) { + return ( +
  • + + {children &&
      {children}
    } + + setSnackbarOpen(false)} + > + + + {snackbarMessage || 'Action completed'} + + } + /> + + +
  • + ); + } -const MenuItem = ({ icon, text, children, linkTo = '#nogo' }) => { return (
  • @@ -10,7 +214,36 @@ const MenuItem = ({ icon, text, children, linkTo = '#nogo' }) => { {children && } {children &&
      {children}
    } + + setSnackbarOpen(false)} + > + + + {snackbarMessage || 'Action completed'} + + } + /> + +
  • + ); }; diff --git a/src/components/navigation/index.js b/src/components/navigation/index.js index b31393a8..c6c91d0f 100644 --- a/src/components/navigation/index.js +++ b/src/components/navigation/index.js @@ -1,38 +1,96 @@ import { NavLink } from 'react-router-dom'; import CohortIcon from '../../assets/icons/cohortIcon'; -import HomeIcon from '../../assets/icons/homeIcon'; import ProfileIcon from '../../assets/icons/profileIcon'; import useAuth from '../../hooks/useAuth'; import './style.css'; +import { useState } from 'react'; +import ProfileIconFilled from '../../assets/icons/profileIconFilled'; +import HomeIconFilled from '../../assets/icons/homeIconFilled'; +import HomeIcon from '../../assets/icons/homeIcon'; +import CohortIconFill from '../../assets/icons/cohortIcon-fill'; +import ExcersicesIconFilled from '../../assets/icons/excersicesIconFilled'; +import ExcersicesIcon from '../../assets/icons/excersicesIcon'; +import NotesIconFilled from '../../assets/icons/notesIconFilled'; +import NotesIcon from '../../assets/icons/notesIcon'; +import LogsIconFilled from '../../assets/icons/logsIconFilled'; +import LogsIcon from '../../assets/icons/logsIcon'; +import jwtDecode from 'jwt-decode'; +import { useUserRoleData } from '../../context/userRole.'; const Navigation = () => { const { token } = useAuth(); - + const [active, setActive] = useState(1) + const{userRole} = useUserRoleData() + if (!token) { return null; } + let userIdFromToken = null; + try { + const decoded = jwtDecode(token); + userIdFromToken = decoded.userId; + } catch (err) { + console.error("Error when decoding by navigation", err); + } + return ( ); diff --git a/src/components/navigation/style.css b/src/components/navigation/style.css index 91849135..2b443e74 100644 --- a/src/components/navigation/style.css +++ b/src/components/navigation/style.css @@ -27,3 +27,33 @@ nav svg { nav p { line-height: 24px; } + +.nav-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 10px; + background-color: white; + border-radius: 8px; + text-decoration: none; + transition: background-color 0.3s ease; +} + +.nav-item.active { + background: #E6EBF5; +} + + +.no-line { + border: none; +} + +.border-line { + border-bottom: 1px solid var(--color-blue5); +} + +nav li:hover a { +background: #F5FAFF; +border-radius: 5px; + +} \ No newline at end of file diff --git a/src/components/post/dropdown/index.js b/src/components/post/dropdown/index.js new file mode 100644 index 00000000..408ae42b --- /dev/null +++ b/src/components/post/dropdown/index.js @@ -0,0 +1,42 @@ +import { useState, useRef, useEffect } from 'react'; +import { CascadingMenuPost } from './menu/index'; + +const MenuPost = ({ edit=false, report=false, del=false, menuVisible, postText, postId, name, onPostDeleted, commentText, commentId, post }) => { + const [isMenuVisible, setIsMenuVisible] = useState(menuVisible || false); + const menuRef = useRef(null); + + // Close menu when clicking outside + useEffect(() => { + const handleClickOutside = (event) => { + if (menuRef.current && !menuRef.current.contains(event.target)) { + setIsMenuVisible(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + return ( +
    +
    { + setIsMenuVisible(!isMenuVisible); + }}> + + β€’ + β€’ + β€’ + +
    +
    + {isMenuVisible && } +
    +
    + + + ); +}; + +export default MenuPost; diff --git a/src/components/post/dropdown/menu/index.js b/src/components/post/dropdown/menu/index.js new file mode 100644 index 00000000..359d67dd --- /dev/null +++ b/src/components/post/dropdown/menu/index.js @@ -0,0 +1,47 @@ +import { useState } from 'react'; +import EditIcon from '../../../../assets/icons/EditIcon'; +import DeleteIcon from '../../../../assets/icons/deleteIcon'; +import Menu from '../../../menu'; +import MenuItem from '../../../menu/menuItem'; +import './style.css'; +import ReportIcon from '../../../../assets/icons/reporticon'; +import SimpleProfileCircle from '../../../simpleProfileCircle'; + +const ProfileCirclePost = ({ initials, menuVisible, postText, postId, name }) => { + const [isMenuVisible, setIsMenuVisible] = useState(menuVisible || false); + + + + return ( +
    setIsMenuVisible(!isMenuVisible)}> + {isMenuVisible && } + +
    + + +
    +
    + ); +}; + +export const CascadingMenuPost = ({ editPost, deletePost, reportPost, postText, postId, name, isMenuVisible, setIsMenuVisible }) => { + return ( + +{/*
  • + + +

    Edit post

    +
  • */} + {editPost ? } linkTo="" text="Edit post" clickable={"Modal"} postText={postText} postId={postId} name={name} isMenuVisible={isMenuVisible} setIsMenuVisible={setIsMenuVisible} /> : null} + {deletePost ? } text="Delete post" clickable={"Delete"} postId={postId} name={name} isMenuVisible={isMenuVisible} setIsMenuVisible={setIsMenuVisible}/> : null} + {reportPost ? } text="Report post" clickable={"Report"} postId={postId} name={name} isMenuVisible={isMenuVisible} setIsMenuVisible={setIsMenuVisible}/> : null} +
    + ); +}; + +export default ProfileCirclePost; diff --git a/src/components/post/dropdown/menu/style.css b/src/components/post/dropdown/menu/style.css new file mode 100644 index 00000000..467e0d43 --- /dev/null +++ b/src/components/post/dropdown/menu/style.css @@ -0,0 +1,98 @@ +.user { + display: flex; + align-items: center; + padding: 8px 12px; + gap: 12px; +} + + + +.user-info { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-width: 0; +} + +.profile-circle{ + min-width: 56px; + height: 56px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + + +.user-name { + margin: 0; + font-size: 20px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color:#000046; + +} + +.user-role { + font-size: 16px; + color:#64648C +} + +.edit-icon-wrapper { + position: relative; + display: inline-block; +} + +.icon-button { + width: 40px; + height: 40px; + margin-top: 4px; + margin-left: 4px; + background-color: #F0F5FA; + border: none; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.dots { + display: flex; + gap: 4px; + font-size: 18px; + color:#64648C; + +} + +.dot { + line-height: 1; +} + +/* Reset and override all menu positioning */ +.edit-icon-wrapper .menu-left { + position: absolute !important; + left: auto !important; + right: 100% !important; /* Position to the left of trigger */ + top: 0 !important; /* Align with trigger top */ + transform: translateX(-10px) !important; /* Add small gap */ + z-index: 1000 !important; +} + +/* Complete override of menu positioning with maximum specificity */ +.edit-icon-wrapper .menu-left .menu { + position: relative !important; + left: 0 !important; + top: 0 !important; + right: auto !important; +} + +.edit-icon-wrapper .menu-left .menu ul { + position: relative !important; + left: 0 !important; + top: 0 !important; + right: auto !important; + transform: none !important; +} diff --git a/src/components/post/index.js b/src/components/post/index.js index 337ca5a6..236c59c0 100644 --- a/src/components/post/index.js +++ b/src/components/post/index.js @@ -1,55 +1,233 @@ -import useModal from '../../hooks/useModal'; +import { useEffect, useRef, useState } from 'react'; import Card from '../card'; import Comment from '../comment'; -import EditPostModal from '../editPostModal'; -import ProfileCircle from '../profileCircle'; + +import CreateComment from '../createComment'; +import HeartIcon from '../../assets/icons/heartIcon'; +import HeartIconFilled from '../../assets/icons/heartIconFilled'; +import CommentBubbleIcon from '../../assets/icons/commentBubbleIcon'; +import CommentBubbleIconFilled from '../../assets/icons/commentBubbleIconFilled'; import './style.css'; +import { usePosts } from '../../context/posts'; + +import MenuPost from './dropdown'; +import jwtDecode from 'jwt-decode'; +import useAuth from '../../hooks/useAuth'; +import SimpleProfileCircle from '../simpleProfileCircle'; +import { get } from '../../service/apiClient'; + +const Post = ({ post, refresh }) => { + const { getUserLikedPosts, toggleLike } = usePosts(); + const commentInputRef = useRef(null); + const [localComments, setLocalComments] = useState((post.comments || []).reverse()); + const [isLiked, setIsLiked] = useState(false); + const [likeCount, setLikeCount] = useState(post.likesCount || post.likes || 0); + const [isAnimating, setIsAnimating] = useState(false); + const [isCommentHovered, setIsCommentHovered] = useState(false); + const { token } = useAuth(); + const { userId } = jwtDecode(token || localStorage.getItem('token')) || {}; + const [exists, setExists] = useState(true); + + useEffect(() => { + async function fetchPost() { + try { + const response = await get("posts/" + post.id); + if(response.data.message === "Post not found") { + setExists(false); + } + } catch (error) { + setExists(false); + } + } + fetchPost(); + }, [refresh]); + + const authorName = post.user.profile + ? `${post.user.profile.firstName || 'Unknown'} ${post.user.profile.lastName || 'User'}` + : 'Unknown User'; + const userInitials = authorName.match(/\b(\w)/g)?.join('') || 'NA'; + + const isLikedInitial = () => { + const likedPosts = getUserLikedPosts(); + if (!Array.isArray(likedPosts)) { + setIsLiked(false); + return; + } + + const liked = likedPosts.some((likedPost) => likedPost.id === post.id); + setIsLiked(liked); + }; + + useEffect(() => { + isLikedInitial(); + }, [post.id]); // Remove user dependency since we get it from context + + const formatDate = (dateString) => { + if (!dateString) return 'Unknown date'; + const d = new Date(dateString); + if (isNaN(d.getTime())) return 'Unknown date'; + const months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + const day = String(d.getDate()).padStart(2, '0'); + const month = months[d.getMonth()]; + const hours = String(d.getHours()).padStart(2, '0'); + const minutes = String(d.getMinutes()).padStart(2, '0'); + return `${day} ${month} at ${hours}.${minutes}`; + }; -const Post = ({ name, date, content, comments = [], likes = 0 }) => { - const { openModal, setModal } = useModal(); + const comments = Array.isArray(localComments) ? localComments : []; + const handleCommentClick = () => { + if (commentInputRef.current) { + commentInputRef.current.focus(); + } + }; + + const handleCommentAdded = (newComment) => { + // Add the new comment to the local state + setLocalComments(prevComments => [...prevComments, newComment]); + }; + + const handleCommentDeleted = (deletedCommentId) => { + // Remove the deleted comment from the local state + setLocalComments(prevComments => prevComments.filter(comment => comment.id !== deletedCommentId)); + }; - const userInitials = name.match(/\b(\w)/g); + const handleLikeClick = async () => { + // Trigger animation + setIsAnimating(true); + + // Store current state in case we need to revert + const previousLikedState = isLiked; + const previousLikeCount = likeCount; + + // Optimistically update UI + setIsLiked(prev => !prev); + setLikeCount(prev => previousLikedState ? prev - 1 : prev + 1); + + // Reset animation after a short delay + setTimeout(() => { + setIsAnimating(false); + }, 300); - const showModal = () => { - setModal('Edit post', ); - openModal(); + try { + const success = await toggleLike(post.id, !previousLikedState); + if (!success) { + // Revert optimistic updates on error + setIsLiked(previousLikedState); + setLikeCount(previousLikeCount); + } + } catch (error) { + // Revert optimistic updates on error + setIsLiked(previousLikedState); + setLikeCount(previousLikeCount); + console.error('Failed to update like state:', error); + } }; return ( + !exists ? null :
    -
    - - -
    -

    {name}

    - {date} +
    + +{/* */} +{/* + */} +
    +

    {authorName}

    + {formatDate(post.timeCreated)} + {(post.timeCreated === post.timeUpdated) ? null : (

    Edited

    )} +
    + +
    -
    -

    ...

    -
    +
    +

    {post.content}

    -
    -

    {content}

    -
    - -
    -
    -
    Like
    -
    Comment
    +
    +
    + +
    -

    {!likes && 'Be the first to like this'}

    +

    + {likeCount === 0 ? 'Be the first to like this' : `${likeCount} ${likeCount === 1 ? 'like' : 'likes'}`} +

    -
    - {comments.map((comment) => ( - - ))} + {comments.length > 2 && ( +
    See previous comments
    + )} + +
    + {comments.map((comment, idx) => { + const commentAuthorName = comment.user?.profile + ? `${comment.user.profile.firstName || 'Unknown'} ${comment.user.profile.lastName || 'User'}` + : 'Unknown User'; + + return ( + + ); + })} +
    diff --git a/src/components/post/style.css b/src/components/post/style.css index 3eff5afc..a944f95b 100644 --- a/src/components/post/style.css +++ b/src/components/post/style.css @@ -1,50 +1,165 @@ .post { display: grid; - row-gap: 20px; + row-gap: 16px; } -.post-details { +/* Header */ +.post__header { display: grid; - grid-template-columns: 56px auto 48px; - column-gap: 20px; + grid-template-columns: 56px auto 40px; + column-gap: 16px; + align-items: center } -.post-user-name { - padding-top: 4px; +/* Override UserIcon padding in post header */ +.post__header .user { + padding-left: 0 !important; + padding-right: 0 !important; } -.post-user-name p { +.post__meta { + display: grid; + row-gap: 2px; +} + +.post__author { font-weight: 600; font-size: 1.1rem; } -.post-user-name small { - color: #64648c; +.post__date { + color: var(--color-blue1); } -.edit-icon { - border-radius: 50%; +.post__menu { width: 40px; height: 40px; - background: #f0f5fa; + border-radius: 8px; + border: 0; + background: var(--color-blue5); + color: var(--color-blue1); + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + font-weight: bold; + cursor: pointer } -.edit-icon p { - text-align: center; - font-size: 20px; +/* Content */ +.post__content p { + line-height: 1.6; } -.post-interactions-container { - display: grid; - grid-template-columns: 1fr 3fr; - padding: 20px 10px; +/* Actions row */ +.post__actions { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 0 } -.post-interactions { +.post__actions-left { + display: flex; + gap: 12px; +} + +.pill { + display: inline-flex; + align-items: center; + gap: 8px; + border: none; + background: #eef3f9; + /* muted */ + color: var(--color-blue1); + padding: 8px 12px; + border-radius: 999px; + cursor: pointer; + transition: all 0.2s ease; +} + +.pill:hover { + background: #d9e7f4; +} + +.pill svg { + transition: all 0.2s ease; +} + +.pill--animating svg { + animation: heartBounce 0.4s ease-out; +} + +/* New action button styles to match the photo design */ +.action-button { + display: inline-flex; + align-items: center; + gap: 6px; + border: none; + background: transparent; + color: #65676B; + padding: 8px 12px; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; + font-size: 14px; + font-weight: 500; +} + +.action-button:hover { + background: #f2f3f5; +} + +.action-button svg { + width: 48px; + height: 48px; + transition: all 0.2s ease; +} + +.action-button--animating svg { + animation: heartBounce 0.4s ease-out; +} + +.action-button--liked { + color: #c00000; +} + +.action-button--liked svg { + fill: #c00000; +} + +@keyframes heartBounce { + 0% { + transform: translateY(0) scale(1); + } + 30% { + transform: translateY(-3px) scale(1.1); + } + 60% { + transform: translateY(1px) scale(1.05); + } + 100% { + transform: translateY(0) scale(1); + } +} + +.pill svg { + width: 18px; + height: 18px; +} + +.post__likes-hint { + color: var(--color-blue1); +} + +/* Comments section spacing */ +.post__comments { display: grid; - grid-template-columns: 1fr 1fr; + row-gap: 12px; } -.post-interactions-container p { +.post__see-previous { + color: var(--color-blue1); + font-size: 0.95rem; text-align: right; -} +} \ No newline at end of file diff --git a/src/components/posts/index.js b/src/components/posts/index.js index 79756c41..c7de0c07 100644 --- a/src/components/posts/index.js +++ b/src/components/posts/index.js @@ -1,27 +1,18 @@ -import { useEffect, useState } from 'react'; import Post from '../post'; -import { getPosts } from '../../service/apiClient'; +import { usePosts } from '../../context/posts'; -const Posts = () => { - const [posts, setPosts] = useState([]); +const Posts = ({ refresh }) => { + const { posts, loading } = usePosts(); - useEffect(() => { - getPosts().then(setPosts); - }, []); + if (loading) { + return
    Loading posts...
    ; + } return ( <> - {posts.map((post) => { - return ( - - ); - })} + {posts.map((post) => ( + + ))} ); }; diff --git a/src/components/posts/style.css b/src/components/posts/style.css new file mode 100644 index 00000000..e69de29b diff --git a/src/components/profile-icon-searchStudentView/index.js b/src/components/profile-icon-searchStudentView/index.js new file mode 100644 index 00000000..ce9b6ab5 --- /dev/null +++ b/src/components/profile-icon-searchStudentView/index.js @@ -0,0 +1,85 @@ + +import Popup from 'reactjs-popup'; +import './style.css'; +import SeeProfile from '../seeProfile'; +import { useNavigate } from 'react-router-dom'; +import SimpleProfileCircle from '../simpleProfileCircle'; +const UserIconStudentView = ({ id, initials, firstname, lastname, role, photo=null}) => { + const navigate = useNavigate(); + + const styleGuideColors = [ + "#28C846", + "#A0E6AA", + "#46DCD2", + "#82E6E6", + "#5ABEDC", + "#46C8FA", + "#46A0FA", + "#666EDC" + ]; + + const getColorFromInitials = (initials) => { + let hash = 0; + for (let i = 0; i < initials.length; i++) { + hash = initials.charCodeAt(i) + ((hash << 5) - hash); + } + + const index = Math.abs(hash) % styleGuideColors.length; + return styleGuideColors[index]; + }; + + const backgroundColor = getColorFromInitials(initials); + + const viewProfile = () => { + navigate(`/profile`); // MΓ₯ legge til ID - senere + } + + return ( +
    + +
    +
    + + {/*

    {initials}

    */} +
    +
    +
    +

    {firstname} {lastname}

    +

    {role}

    +
    +
    + + +
    + +
    + + β€’ + β€’ + β€’ + +
    +
    + + } position="left center" + closeOnDocumentClick + arrow={false}> + + +
+ ) +} + + +export default UserIconStudentView; + + + \ No newline at end of file diff --git a/src/components/profile-icon-searchStudentView/style.css b/src/components/profile-icon-searchStudentView/style.css new file mode 100644 index 00000000..cbdf3e6b --- /dev/null +++ b/src/components/profile-icon-searchStudentView/style.css @@ -0,0 +1,71 @@ +.user { + display: flex; + align-items: center; + padding: 8px 12px; + gap: 12px; +} + +.user-info { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-width: 0; +} + +.profile-circle{ + min-width: 56px; + height: 56px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + + +.user-name { + margin: 0; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pop-up { + position: absolute; + top: 100%; /* under .edit-icon */ + right: 0; /* hΓΈyrejustert med ikonet */ + margin-top: 8px; + z-index: 100; +} + + +.edit-icon-wrapper { + position: relative; + display: inline-block; +} + +.icon-button { + width: 40px; + height: 40px; + margin-top: 4px; + margin-left: 4px; + background-color: #F0F5FA; + border: none; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.dots { + display: flex; + gap: 4px; + font-size: 18px; + color:#64648C; + +} + +.dot { + line-height: 1; +} \ No newline at end of file diff --git a/src/components/profile-icon-searchTeacherView/cascadinuMenuSearch/index.js b/src/components/profile-icon-searchTeacherView/cascadinuMenuSearch/index.js new file mode 100644 index 00000000..fd75d893 --- /dev/null +++ b/src/components/profile-icon-searchTeacherView/cascadinuMenuSearch/index.js @@ -0,0 +1,45 @@ + +import Menu from "../../menu" +import MenuItem from "../../menu/menuItem" +import DeleteIcon from "../../../assets/icons/deleteIcon" +import NotesIcon from "../../../assets/icons/notesIcon" +import CohortIcon from "../../../assets/icons/cohortIcon" +import ProfileIcon from "../../../assets/icons/profileIcon" +import { useState } from "react" + +const CascadingMenuSearch = ({ id, setIsMenuVisible, setRefresh }) => { + + const [clicked, setClicked] = useState(false); + + return ( + <> + + } text="Profile" /> + } text = "Add note"/> + } text="Move to cohort"/> + {clicked ? + } + text="Confirm deletion" + cohortId = {id} + clickable="DeleteCohort" + style={{color: 'red'}} + setIsMenuVisible={setIsMenuVisible} + setRefresh={setRefresh} + /> + : + } + text="Delete Cohort" + clickable="Clicked" + clicked={clicked} + setClicked={setClicked} + setIsMenuVisible={setIsMenuVisible} + setRefresh={setRefresh} + /> + } + + + ) +} + +export default CascadingMenuSearch \ No newline at end of file diff --git a/src/components/profile-icon-searchTeacherView/index.js b/src/components/profile-icon-searchTeacherView/index.js new file mode 100644 index 00000000..b578e837 --- /dev/null +++ b/src/components/profile-icon-searchTeacherView/index.js @@ -0,0 +1,91 @@ + +import './style.css'; +import { useNavigate } from 'react-router-dom'; +import CascadingMenuSearch from './cascadinuMenuSearch'; +import { useEffect, useRef, useState } from 'react'; +import SimpleProfileCircle from '../simpleProfileCircle'; +const UserIconTeacherView = ({ id, initials, firstname, lastname, role, menuVisible, photo=null}) => { + + const [isMenuVisible, setIsMenuVisible] = useState(menuVisible || false); + const menuRef = useRef(null); + const navigate = useNavigate(); + const styleGuideColors = [ + "#28C846", + "#A0E6AA", + "#46DCD2", + "#82E6E6", + "#5ABEDC", + "#46C8FA", + "#46A0FA", + "#666EDC" + ]; + + const getColorFromInitials = (initials) => { + let hash = 0; + for (let i = 0; i < initials.length; i++) { + hash = initials.charCodeAt(i) + ((hash << 5) - hash); + } + + const index = Math.abs(hash) % styleGuideColors.length; + return styleGuideColors[index]; + }; + + const backgroundColor = getColorFromInitials(initials); + + const viewProfile = () => { + navigate(`/profile`); // MΓ₯ legge til ID - senere + } + + useEffect(() => { + const handleClickOutside = (event) => { + if (menuRef.current && !menuRef.current.contains(event.target)) { + setIsMenuVisible(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + + return ( +
+ +
+
+ + +
+
+
+

{firstname} {lastname}

+

{role}

+ +
+
+ + + +
+ +
+
setIsMenuVisible(!isMenuVisible)}> + + β€’ + β€’ + β€’ + +
+ + +
+ {isMenuVisible && } +
+
+ +
+ ) +} + +export default UserIconTeacherView; diff --git a/src/components/profile-icon-searchTeacherView/style.css b/src/components/profile-icon-searchTeacherView/style.css new file mode 100644 index 00000000..edb77975 --- /dev/null +++ b/src/components/profile-icon-searchTeacherView/style.css @@ -0,0 +1,89 @@ +.user { + display: flex; + align-items: center; + padding: 8px 12px; + gap: 12px; +} + +.user-info { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-width: 0; +} + +.user-top { + display: flex; + align-items: center; + justify-content: space-between; /* navnet til venstre, knappene til hΓΈyre */ +} + +.user-name { + margin: 0; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pop-up { + position: absolute; + top: 100%; /* under .edit-icon */ + right: 0; /* hΓΈyrejustert med ikonet */ + margin-top: 8px; + z-index: 100; +} + +.profile-circle{ + min-width: 56px; + height: 56px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + + +.edit-icon-wrapper { + position: relative; + display: inline-block; +} + +.icon-button { + width: 40px; + height: 40px; + margin-top: 4px; + margin-left: 4px; + background-color: #F0F5FA; + border: none; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.dots { + display: flex; + gap: 4px; + font-size: 18px; + color:#64648C; + +} + +.dot { + line-height: 1; +} + +.buttons { + width: 120px !important; + text-align: center !important; + padding: 2px 0 !important; + font-size: 16px !important; + color: #64648C ; +} + +.cascading-menu-container { + position: absolute; + z-index: 1000; +} \ No newline at end of file diff --git a/src/components/profile-icon-teacherView/editIconTeacher/index.js b/src/components/profile-icon-teacherView/editIconTeacher/index.js new file mode 100644 index 00000000..d79c7d3c --- /dev/null +++ b/src/components/profile-icon-teacherView/editIconTeacher/index.js @@ -0,0 +1,39 @@ +import { useState, useRef, useEffect } from 'react'; +import { CascadingMenu } from '../../profileCircle'; + +const EditIconTeacher = ({ id, initials, menuVisible, setRefresh}) => { + const [isMenuVisible, setIsMenuVisible] = useState(menuVisible || false); + const menuRef = useRef(null); + // Lukk meny ved klikk utenfor + useEffect(() => { + const handleClickOutside = (event) => { + if (menuRef.current && !menuRef.current.contains(event.target)) { + setIsMenuVisible(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + return ( +
+
setIsMenuVisible(!isMenuVisible)}> + + β€’ + β€’ + β€’ + +
+
+ {isMenuVisible && } +
+
+ + + ); +}; + +export default EditIconTeacher; diff --git a/src/components/profile-icon-teacherView/index.js b/src/components/profile-icon-teacherView/index.js new file mode 100644 index 00000000..18ec0ab6 --- /dev/null +++ b/src/components/profile-icon-teacherView/index.js @@ -0,0 +1,56 @@ +import SimpleProfileCircle from '../simpleProfileCircle'; +import EditIconTeacher from './editIconTeacher'; +import './style.css'; + + +const ProfileIconTeacher = ({id, initials, firstname, lastname, role, setRefresh, photo=null}) => { + + const styleGuideColors = [ + "#28C846", + "#A0E6AA", + "#46DCD2", + "#82E6E6", + "#5ABEDC", + "#46C8FA", + "#46A0FA", + "#666EDC" + ]; + + + const getColorFromInitials = (initials) => { + let hash = 0; + for (let i = 0; i < initials.length; i++) { + hash = initials.charCodeAt(i) + ((hash << 5) - hash); + } + + const index = Math.abs(hash) % styleGuideColors.length; + return styleGuideColors[index]; + }; + + + + const backgroundColor = getColorFromInitials(initials); + + + + return ( +
+ +
+
+ + {/*

{initials}

*/} +
+
+
+

{firstname} {lastname}

+

{role}

+
+ +
+ ) +} + +export default ProfileIconTeacher; \ No newline at end of file diff --git a/src/components/profile-icon-teacherView/style.css b/src/components/profile-icon-teacherView/style.css new file mode 100644 index 00000000..61947779 --- /dev/null +++ b/src/components/profile-icon-teacherView/style.css @@ -0,0 +1,77 @@ +.user { + display: flex; + align-items: center; + padding: 8px 12px; + gap: 12px; +} + +.user-info { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-width: 0; +} + +.profile-circle{ + min-width: 56px; + height: 56px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + + +.user-name { + margin: 0; + font-size: 20px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color:#000046; + +} + +.user-role { + font-size: 16px; + color:#64648C +} + +.edit-icon-wrapper { + position: relative; + display: inline-block; +} + +.icon-button { + width: 48px; + height: 48px; + margin-top: 4px; + margin-left: 4px; + background-color: #F0F5FA; + border: none; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.dots { + display: flex; + gap: 4px; + font-size: 18px; + color:#64648C; + +} + +.dot { + line-height: 1; +} + +.menu-left { + position: absolute; + left: -500px; /* juster avstanden etter behov */ + top: 0; + z-index: 1000; +} diff --git a/src/components/profile-icon/index.js b/src/components/profile-icon/index.js new file mode 100644 index 00000000..86567f3b --- /dev/null +++ b/src/components/profile-icon/index.js @@ -0,0 +1,82 @@ +import './style.css'; +import SeeProfile from '../seeProfile'; +import { useState } from 'react'; +import SimpleProfileCircle from '../simpleProfileCircle'; + +const UserIcon = ({ id, initials = '', firstname = '', lastname = '', role = '', menu = true, photo = null }) => { + const [isOpen, setIsOpen] = useState(false); + + const styleGuideColors = [ + "#28C846", + "#A0E6AA", + "#46DCD2", + "#82E6E6", + "#5ABEDC", + "#46C8FA", + "#46A0FA", + "#666EDC" + ]; + + const getColorFromInitials = (init) => { + const text = init || ''; + let hash = 0; + for (let i = 0; i < text.length; i++) { + hash = text.charCodeAt(i) + ((hash << 5) - hash); + } + const index = Math.abs(hash) % styleGuideColors.length; + return styleGuideColors[index]; + }; + + const backgroundColor = getColorFromInitials(initials); + + return ( +
+
+
+ +
+
+ + {menu && ( + <> +
+

{firstname} {lastname}

+

{role}

+
+ +
setIsOpen(true)} + role="button" + tabIndex={0} + onKeyDown={(e) => (e.key === 'Enter' ? setIsOpen(true) : null)} + > +
+ + β€’ + β€’ + β€’ + +
+
+ + )} + + {isOpen && ( +
+ setIsOpen(false)} + /> +
+ )} +
+ ); +}; + +export default UserIcon; diff --git a/src/components/profile-icon/style.css b/src/components/profile-icon/style.css new file mode 100644 index 00000000..c6a730e6 --- /dev/null +++ b/src/components/profile-icon/style.css @@ -0,0 +1,72 @@ +.user { + display: flex; + align-items: center; + padding: 8px 12px; + gap: 12px; +} + +.user-info { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-width: 0; +} + + +.profile-circle{ + min-width: 56px; + height: 56px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + + +.user-name { + margin: 0; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pop-up { + position: absolute; + top: 100%; /* under .edit-icon */ + right: 0; /* hΓΈyrejustert med ikonet */ + margin-top: 8px; + z-index: 100; +} + + +.edit-icon-wrapper { + position: relative; + display: inline-block; +} + +.icon-button { + width: 40px; + height: 40px; + margin-top: 4px; + margin-left: 4px; + background-color: #F0F5FA; + border: none; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.dots { + display: flex; + gap: 4px; + font-size: 18px; + color:#64648C; + +} + +.dot { + line-height: 1; +} \ No newline at end of file diff --git a/src/components/profileCircle/index.js b/src/components/profileCircle/index.js index 7dc5a614..fa099593 100644 --- a/src/components/profileCircle/index.js +++ b/src/components/profileCircle/index.js @@ -9,25 +9,51 @@ import SquareBracketsIcon from '../../assets/icons/squareBracketsIcon'; import Menu from '../menu'; import MenuItem from '../menu/menuItem'; import './style.css'; +import SimpleProfileCircle from '../simpleProfileCircle'; -const ProfileCircle = ({ initials }) => { - const [isMenuVisible, setIsMenuVisible] = useState(false); +/* +ADDED A PROP CLICKABLE +use the className='profile-circle-noclick' if you need the ProfileCircle but +do not need the menu options (this is used in the posts in dashboard for both +teachers and students) +*/ +const ProfileCircle = ({ id, initials, menuVisible, clickable, photo=null }) => { + const [isMenuVisible, setIsMenuVisible] = useState(menuVisible || false); return ( -
setIsMenuVisible(!isMenuVisible)}> - {isMenuVisible && } + + <> + {clickable ? ( +
setIsMenuVisible(!isMenuVisible)}> + {isMenuVisible && } +
-

{initials}

+ +
+ ) : ( +
+
+ + +
+
+ )} + + ); }; -const CascadingMenu = () => { + +export const CascadingMenu = ({ id, setIsMenuVisible, setRefresh}) => { + + const [clicked, setClicked] = useState(false); + return ( - } text="Profile" /> + } text="Profile" linkTo={`/profile/${id}`} /> } text="Add note" /> } text="Move to cohort"> @@ -43,8 +69,26 @@ const CascadingMenu = () => { } text="Cohort 3" /> - - } text="Delete student" /> + {clicked ? + } + text="Confirm deletion" + profileId = {id} + clickable="DeleteUser" + style={{color: 'red'}} + setIsMenuVisible={setIsMenuVisible} + setRefresh={setRefresh} + /> + : + } + text="Delete student" + clickable="Clicked" + clicked={clicked} + setClicked={setClicked} + setIsMenuVisible={setIsMenuVisible} + setRefresh={setRefresh} + /> + } ); }; diff --git a/src/components/profileCircle/style.css b/src/components/profileCircle/style.css index 47391fe7..066f134d 100644 --- a/src/components/profileCircle/style.css +++ b/src/components/profileCircle/style.css @@ -3,6 +3,10 @@ cursor: pointer; } +.profile-circle-noclick { + position: relative; +} + .profile-circle-menu { margin-left: 65px; -} +} \ No newline at end of file diff --git a/src/components/seeProfile/index.js b/src/components/seeProfile/index.js new file mode 100644 index 00000000..a8b2996b --- /dev/null +++ b/src/components/seeProfile/index.js @@ -0,0 +1,41 @@ +import Card from '../card'; +import './style.css'; +import { NavLink } from 'react-router-dom'; +import ProfileIcon from '../../assets/icons/profileIcon'; +import SimpleProfileCircle from '../simpleProfileCircle'; + + +const SeeProfile = ({ id, initials, firstname, lastname, role, photo=null }) => { + return ( +
+ +
+
+ + {/*

{initials}

*/} +
+ +
+

{firstname} {lastname}

+ {role} +
+
+ +
+
    +
  • + +

    Profile

    +
    +
  • +
+
+
+
+ ) + +} + +export default SeeProfile; \ No newline at end of file diff --git a/src/components/seeProfile/style.css b/src/components/seeProfile/style.css new file mode 100644 index 00000000..9ba58d0d --- /dev/null +++ b/src/components/seeProfile/style.css @@ -0,0 +1,9 @@ +.user-panel { + position: absolute; + z-index: 2000; +} + + +.card { + width: 450px; +} \ No newline at end of file diff --git a/src/components/simpleProfileCircle/index.js b/src/components/simpleProfileCircle/index.js new file mode 100644 index 00000000..f6b76b0f --- /dev/null +++ b/src/components/simpleProfileCircle/index.js @@ -0,0 +1,65 @@ +import './style.css'; + +const SimpleProfileCircle = ({ initials, size = 56, photo }) => { + const styleGuideColors = [ + "#28C846", + "#A0E6AA", + "#46DCD2", + "#82E6E6", + "#5ABEDC", + "#46C8FA", + "#46A0FA", + "#666EDC" + ]; + + const getColorFromInitials = (initials) => { + if (!initials || typeof initials !== 'string') return styleGuideColors[0]; + + let hash = 0; + for (let i = 0; i < initials.length; i++) { + hash = initials.charCodeAt(i) + ((hash << 5) - hash); + } + + const index = Math.abs(hash) % styleGuideColors.length; + return styleGuideColors[index]; + }; + + const backgroundColor = getColorFromInitials(initials); + + // If photo is provided, show image instead of initials + + if (photo) { + return ( +
+ Profile +
+ ); + } + + // Default behavior - show initials with colored background + return ( +
40 ? '14px' : '12px' + }} + > +

{initials}

+
+ ); +}; + +export default SimpleProfileCircle; \ No newline at end of file diff --git a/src/components/simpleProfileCircle/style.css b/src/components/simpleProfileCircle/style.css new file mode 100644 index 00000000..84047821 --- /dev/null +++ b/src/components/simpleProfileCircle/style.css @@ -0,0 +1,30 @@ +.simple-profile-circle { + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 600; + text-transform: uppercase; + /* border: 1px solid #000; */ +} + +.simple-profile-circle p { + margin: 0; + line-height: 1; + /* font-size: inherit; */ +} + +/* Image variant styling */ +.simple-profile-circle--image { + background-color: transparent; + overflow: hidden; + border: 1px solid #000; +} + +.simple-profile-circle__image { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; +} \ No newline at end of file diff --git a/src/components/socialLinks/index.js b/src/components/socialLinks/index.js index 9b112adb..103214c7 100644 --- a/src/components/socialLinks/index.js +++ b/src/components/socialLinks/index.js @@ -9,68 +9,34 @@ const SocialLinks = () => { }} className="socialbutton" > - - - - - - - - - - + + + + ); }; export default SocialLinks; + + + + diff --git a/src/components/stepper/index.js b/src/components/stepper/index.js index c9e5f259..6013cdd4 100644 --- a/src/components/stepper/index.js +++ b/src/components/stepper/index.js @@ -4,7 +4,7 @@ import Button from '../button'; import './style.css'; import { useState } from 'react'; -const Stepper = ({ header, children, onComplete }) => { +const Stepper = ({ header, children, onComplete, data }) => { const [currentStep, setCurrentStep] = useState(0); const onBackClick = () => { @@ -22,6 +22,33 @@ const Stepper = ({ header, children, onComplete }) => { setCurrentStep(currentStep + 1); }; + const validateName = (data) => { + if(!data) { + alert("OBSS!!! Please write first name and last name") + return false + } else { + return true + } + } + + const validateUsername = (data) => { + if(data.username.length < 7) { + alert("Username is too short. Input must be at least 7 characters long") + return false + } else { + return true + } + } + + const validateMobile = (data) => { + if(data.length < 8) { + alert("Mobile number is too short. Input must be at least 8 characters long") + return false + } else { + return true + } + } + return ( {header} @@ -33,14 +60,34 @@ const Stepper = ({ header, children, onComplete }) => {
); }; -export default Stepper; +export default Stepper; \ No newline at end of file diff --git a/src/context/auth.js b/src/context/auth.js index 47cd66c9..18d101fc 100644 --- a/src/context/auth.js +++ b/src/context/auth.js @@ -4,7 +4,7 @@ import Header from '../components/header'; import Modal from '../components/modal'; import Navigation from '../components/navigation'; import useAuth from '../hooks/useAuth'; -import { createProfile, login, register } from '../service/apiClient'; +import { createNewStudent, createProfile, login, refreshToken, register, getUserById } from '../service/apiClient'; // eslint-disable-next-line camelcase import jwt_decode from 'jwt-decode'; @@ -15,11 +15,13 @@ const AuthProvider = ({ children }) => { const navigate = useNavigate(); const location = useLocation(); const [token, setToken] = useState(null); + const [user, setUser] = useState(null); + const [userPhoto, setUserPhoto] = useState(localStorage.getItem('userPhoto')); useEffect(() => { const storedToken = localStorage.getItem('token'); - if (storedToken) { + if (storedToken && !token) { setToken(storedToken); navigate(location.state?.from?.pathname || '/'); } @@ -34,26 +36,96 @@ const AuthProvider = ({ children }) => { localStorage.setItem('token', res.data.token); - setToken(res.token); - navigate(location.state?.from?.pathname || '/'); + setToken(res.data.token); + + navigate(location.state?.from?.pathname || '/'); + + // After successful login, fetch and store user data + try { + const { userId } = jwt_decode(res.data.token); + const userData = await getUserById(userId); + const photo = userData.profile?.photo; + + if (photo) { + localStorage.setItem('userPhoto', photo); + setUserPhoto(photo); + } + } catch (error) { + console.error('Error fetching user photo:', error); + } }; const handleLogout = () => { localStorage.removeItem('token'); + localStorage.removeItem('userPhoto'); setToken(null); + setUserPhoto(null); + }; + + // Force a token refresh by setting the token again to trigger useEffect in other contexts + const forceTokenRefresh = () => { + const currentToken = token || localStorage.getItem('token'); + if (currentToken) { + console.log("token is found and trying to refresh, but not refreshed?"); + // Force re-render and context updates by setting token again + setToken(null); + + setTimeout(() => { + setToken(currentToken); + }, 100); + } }; const handleRegister = async (email, password) => { const res = await register(email, password); - setToken(res.data.token); + + localStorage.setItem('token', res.data.token); + setToken(res.data.token); navigate('/verification'); }; - const handleCreateProfile = async (firstName, lastName, githubUrl, bio) => { + /* eslint-disable camelcase */ + const handleCreateProfile = async (first_name, last_name, username, github_username, mobile, bio, role, specialism, cohort, start_date, end_date, photo) => { const { userId } = jwt_decode(token); - await createProfile(userId, firstName, lastName, githubUrl, bio); + try { + const response = await createProfile(userId, first_name, last_name, username, github_username, mobile, bio, role, specialism, cohort, start_date, end_date, photo); + + // Check if the backend returned a new token with updated user info + if (response.data?.token) { + // Use the new token from the response + localStorage.setItem('token', response.data.token); + setToken(response.data.token); + } else { + // Try to refresh the token to get updated user information + try { + const refreshResponse = await refreshToken(); + if (refreshResponse.token) { + localStorage.setItem('token', refreshResponse.token); + setToken(refreshResponse.token); + console.log('Token refreshed successfully after profile creation'); + } else { + // If token refresh is not available, force a refresh of contexts + forceTokenRefresh(); + } + } catch (refreshError) { + console.log('Token refresh not available, forcing context refresh'); + // Force a refresh of all contexts that depend on the token + forceTokenRefresh(); + } + } + + navigate('/'); + } catch (error) { + console.error('Error creating profile:', error); + throw error; + } + }; + + const handleCreateNewStudent = async (first_name, last_name, username, github_username, email, mobile, password, bio, role, specialism, cohort, start_date, end_date, photo) => { + + await createNewStudent(first_name, last_name, username, github_username, email, mobile, password, bio, role, specialism, cohort, start_date, end_date, photo); localStorage.setItem('token', token); navigate('/'); @@ -61,10 +133,14 @@ const AuthProvider = ({ children }) => { const value = { token, + user, + userPhoto, + setUserPhoto, onLogin: handleLogin, onLogout: handleLogout, onRegister: handleRegister, - onCreateProfile: handleCreateProfile + onCreateProfile: handleCreateProfile, + onCreateNewStudent: handleCreateNewStudent }; return {children}; diff --git a/src/context/comments.js b/src/context/comments.js new file mode 100644 index 00000000..75898ff4 --- /dev/null +++ b/src/context/comments.js @@ -0,0 +1,74 @@ +import { createContext, useContext } from 'react'; +import { del, postTo } from '../service/apiClient'; + +const CommentsContext = createContext(); + +export const CommentsProvider = ({ children }) => { + // Add a comment to a specific post + const addComment = async (postId, commentData) => { + try { + const response = await postTo(`posts/${postId}/comments`, commentData); + return response; + } catch (error) { + console.error('Error adding comment:', error); + throw error; + } + }; + + // Update a comment + const updateComment = async (postId, commentId, commentData) => { + try { + // Assuming there's a PATCH endpoint for updating comments + const response = await fetch(`/api/posts/${postId}/comments/${commentId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('token')}` + }, + body: JSON.stringify(commentData) + }); + + if (!response.ok) { + throw new Error('Failed to update comment'); + } + + return response.json(); + } catch (error) { + console.error('Error updating comment:', error); + throw error; + } + }; + + // Delete a comment + const deleteComment = async (postId, commentId) => { + try { + await del(`posts/${postId}/comments/${commentId}`); + return true; + } catch (error) { + console.error('Error deleting comment:', error); + return false; + } + }; + + const value = { + addComment, + updateComment, + deleteComment + }; + + return ( + + {children} + + ); +}; + +export const useComments = () => { + const context = useContext(CommentsContext); + if (!context) { + throw new Error('useComments must be used within a CommentsProvider'); + } + return context; +}; + +export default CommentsContext; \ No newline at end of file diff --git a/src/context/form.js b/src/context/form.js new file mode 100644 index 00000000..c602786e --- /dev/null +++ b/src/context/form.js @@ -0,0 +1,15 @@ +import React, { createContext, useContext, useState } from 'react'; + +const FormContext = createContext(); + +export const FormProvider = ({ children }) => { + const [formData, setFormData] = useState({ email: '', password: '' }); + + return ( + + {children} + + ); +}; + +export const useFormData = () => useContext(FormContext); diff --git a/src/context/loading.js b/src/context/loading.js new file mode 100644 index 00000000..37dbf410 --- /dev/null +++ b/src/context/loading.js @@ -0,0 +1,58 @@ +import { createContext, useContext, useState, useCallback, useRef } from 'react'; + +const LoadingContext = createContext(); + +export const LoadingProvider = ({ children }) => { + const [isGlobalLoading, setIsGlobalLoading] = useState(false); + const [loadingMessage, setLoadingMessage] = useState('Loading...'); + const dashboardInitializedRef = useRef(false); // Track dashboard initialization globally + + const showGlobalLoading = useCallback((message = 'Loading...') => { + console.log('Global loading started:', message); + setLoadingMessage(message); + setIsGlobalLoading(true); + }, []); + + const hideGlobalLoading = useCallback(() => { + console.log('Global loading ended'); + setIsGlobalLoading(false); + }, []); + + const isDashboardInitialized = useCallback(() => { + return dashboardInitializedRef.current; + }, []); + + const setDashboardInitialized = useCallback((value) => { + console.log('Dashboard initialization status:', value); + dashboardInitializedRef.current = value; + }, []); + + const resetDashboardInitialization = useCallback(() => { + console.log('Resetting dashboard initialization'); + dashboardInitializedRef.current = false; + }, []); + + const value = { + isGlobalLoading, + loadingMessage, + showGlobalLoading, + hideGlobalLoading, + isDashboardInitialized, + setDashboardInitialized, + resetDashboardInitialization + }; + + return ( + + {children} + + ); +}; + +export const useLoading = () => { + const context = useContext(LoadingContext); + if (!context) { + throw new Error('useLoading must be used within LoadingProvider'); + } + return context; +}; \ No newline at end of file diff --git a/src/context/posts.js b/src/context/posts.js new file mode 100644 index 00000000..2b9d4a4c --- /dev/null +++ b/src/context/posts.js @@ -0,0 +1,173 @@ +import { createContext, useContext, useEffect, useState } from 'react'; +import { del, get, getPosts, patch, postTo } from '../service/apiClient'; +import useAuth from '../hooks/useAuth'; +import jwtDecode from 'jwt-decode'; + +const PostsContext = createContext(); + +export const PostsProvider = ({ children }) => { + const [posts, setPosts] = useState([]); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const { token } = useAuth(); + + // Fetch posts and user data when token changes + useEffect(() => { + if (token) { + fetchPosts(); + fetchUser(); + } else { + // Clear data when no token (user logged out) + setPosts([]); + setUser(null); + setLoading(false); + } + }, [token]); // Re-run when token changes + + const fetchPosts = async () => { + try { + setLoading(true); + const fetchedPosts = await getPosts(); + setPosts(fetchedPosts.reverse()); // Reverse so newest are first + } catch (error) { + console.error('Error fetching posts:', error); + setPosts([]); + } finally { + setLoading(false); + } + }; + + const fetchUser = async () => { + // Re-decode token to get current user info + let currentDecodedToken = {}; + try { + if (token || localStorage.getItem('token')) { + currentDecodedToken = jwtDecode(token || localStorage.getItem('token')) || {}; + } + } catch (error) { + console.error('Invalid token in fetchUser:', error); + setUser(null); + return; + } + + const userId = currentDecodedToken.userId; + if (userId) { + try { + const userData = await get(`users/${userId}`); + setUser(userData); + } catch (error) { + console.error('Error fetching user:', error); + setUser(null); + } + } else { + setUser(null); + } + }; + + // Add a new post + const addPost = (newPost) => { + setPosts(prevPosts => [newPost, ...prevPosts]); + }; + + // Update a post + const updatePost = (updatedPost) => { + setPosts(prevPosts => + prevPosts.map(post => + post.id === updatedPost.id ? { ...post, ...updatedPost } : post + ) + ); + }; + + // Delete a post + const deletePost = async (postId) => { + try { + await del(`posts/${postId}`); + setPosts(prevPosts => prevPosts.filter(post => post.id !== postId)); + return true; + } catch (error) { + console.error('Error deleting post:', error); + return false; + } + }; + + // Like/unlike a post + const toggleLike = async (postId, currentlyLiked) => { + try { + // Re-decode token to get current user info + let currentDecodedToken = {}; + try { + if (token || localStorage.getItem('token')) { + currentDecodedToken = jwtDecode(token || localStorage.getItem('token')) || {}; + } + } catch (error) { + console.error('Invalid token in toggleLike:', error); + return false; + } + + if (currentlyLiked) { + await postTo(`posts/${postId}/like`); + } else { + await del(`posts/${postId}/like`); + } + + // Update user's liked posts + await patch(`users/${currentDecodedToken.userId}/like`, { post_id: postId }); + + // Refresh user data to get updated liked posts + await fetchUser(); + + return true; + } catch (error) { + console.error('Error updating like state:', error); + return false; + } + }; + + // Get user's liked posts + const getUserLikedPosts = () => { + return user?.data?.user?.likedPosts || []; + }; + + // Reset all data (useful for logout) + const resetData = () => { + setPosts([]); + setUser(null); + setLoading(false); + }; + + // Force refresh user data (useful after profile updates) + const refreshUserData = async () => { + await fetchUser(); + }; + + const value = { + posts, + user, + loading, + addPost, + updatePost, + deletePost, + toggleLike, + getUserLikedPosts, + fetchPosts, + fetchUser, + resetData, + refreshUserData + }; + + return ( + + {children} + + ); +}; + +export const usePosts = () => { + const context = useContext(PostsContext); + if (!context) { + throw new Error('usePosts must be used within a PostsProvider'); + } + return context; +}; + +export default PostsContext; \ No newline at end of file diff --git a/src/context/searchResults.js b/src/context/searchResults.js new file mode 100644 index 00000000..e76dd5e9 --- /dev/null +++ b/src/context/searchResults.js @@ -0,0 +1,15 @@ +import { createContext, useContext, useState } from "react"; + +const SearchResultsContext = createContext(); + +export const SearchResultsProvider = ({ children }) => { + const [searchResults, setSearchResults] = useState([]); + + return ( + + {children} + + ); +} + +export const useSearchResults = () => useContext(SearchResultsContext); \ No newline at end of file diff --git a/src/context/selectedCohort.js b/src/context/selectedCohort.js new file mode 100644 index 00000000..ab68c1db --- /dev/null +++ b/src/context/selectedCohort.js @@ -0,0 +1,15 @@ +import React, { createContext, useContext, useState } from 'react'; + +const CohortContext = createContext(); + +export const CohortProvider = ({ children }) => { + const [cohortId, setCohortId] = useState({ cohort: ''}); + + return ( + + {children} + + ); +}; + +export const useSelectedCohortId = () => useContext(CohortContext); diff --git a/src/context/userRole..js b/src/context/userRole..js new file mode 100644 index 00000000..acd5bab3 --- /dev/null +++ b/src/context/userRole..js @@ -0,0 +1,18 @@ +import { createContext, useContext, useState } from "react"; + +const UserRoleContext = createContext({ + userRole: null, + setUserRole: () => {}, +}) + +export const UserRoleProvider = ({ children }) => { + const [userRole, setUserRole] = useState(null) + + return ( + + {children} + + ) +} + +export const useUserRoleData = () => useContext(UserRoleContext) \ No newline at end of file diff --git a/src/pages/addCohort/index.js b/src/pages/addCohort/index.js new file mode 100644 index 00000000..2c94c285 --- /dev/null +++ b/src/pages/addCohort/index.js @@ -0,0 +1,109 @@ +import { useNavigate } from "react-router-dom" +import ExitIcon from "../../assets/icons/exitIcon" +import "./style.css" +import StepperCohort from "./steps" +import StepOneCohort from "./stepOne" +import { useEffect, useState } from "react" +import { get } from "../../service/apiClient" +import StepTwoCohort from "./stepTwo" +import StepThreeCohort from "./stepThree" + + +const AddCohort = () =>{ + const [students, setStudents] = useState([]) + const [courses, setCourses] = useState([]) + + const [cohortName, setCohortName] = useState("") + const[startDate, setStartDate] = useState("") + const[endDate, setEndDate] = useState("") + const [selectedCourse, setSelectedCourse] = useState("") + const [selectedStudents, setSelectedStudents] = useState([]); + + + + + useEffect(() => { + async function fetchStudents() { + try { + const response = await get("students"); + setStudents(response.data.profiles); + } catch (error) { + console.error("Error fetching students:", error); + } + } + + async function fetchCourses() { + try { + const response = await get("courses"); + setCourses(response.data.courses); + } catch (error) { + console.error("Error fetching courses:", error); + } + } + fetchStudents(); + fetchCourses() + }, []); + + return ( + <> + } + cohortName={cohortName} + setCohortName={setCohortName} + startDa={startDate} + setStartDate={setStartDate} + endDa={endDate} + setEndDate={setEndDate} + courses={courses} + selectedCourse={selectedCourse} + setSelectedCourse={setSelectedCourse} + selectedStudents={selectedStudents}> + + + + + + + ) +} + + +const CohortHeader = () => { + const navigate = useNavigate() + return ( + <> +
+

Add cohort

+
+ +
+
+

Create a new cohort

+
+ + ) +} +export default AddCohort \ No newline at end of file diff --git a/src/pages/addCohort/stepOne/index.js b/src/pages/addCohort/stepOne/index.js new file mode 100644 index 00000000..63b2b941 --- /dev/null +++ b/src/pages/addCohort/stepOne/index.js @@ -0,0 +1,85 @@ + +import ArrowDownIcon from "../../../assets/icons/arrowDownIcon" +import CoursesMenu from "../../addStudent/coursesMenu" +import { useState } from "react" + + + + +const StepOneCohort = ( {setCohortName, setStartDate, setEndDate, cohortName, startDate, endDate, courses, setSelectedCourse, selectedCourse}) => { + const [courseIsOpen, setCourseIsOpen] = useState(false) + + + + const handleChangeCohortName = (event) => { + setCohortName(event.target.value) + } + + const handleSelectCourse = (course) => { + console.log("selected course" + course) + setCourseIsOpen(false) + setSelectedCourse(course) + } + + const handleStartDate = (event) => { + setStartDate(event.target.value) + } + + const handleEndDate = (event) => { + setEndDate(event.target.value) + } + + + return ( + <> +
+
+ + + +
+ +
+ + +
+ + {courseIsOpen && ()} + + +
+ + + +
+
+ + + +
+ + + +
+ + + ) +} + +export default StepOneCohort \ No newline at end of file diff --git a/src/pages/addCohort/stepThree/index.js b/src/pages/addCohort/stepThree/index.js new file mode 100644 index 00000000..0c4bc167 --- /dev/null +++ b/src/pages/addCohort/stepThree/index.js @@ -0,0 +1,73 @@ +import { useState } from "react"; +import ArrowDownIcon from "../../../assets/icons/arrowDownIcon"; +import MultipleStudentsMenu from "../stepTwo/multipleStudentsMenu"; +import SearchBarMultiple from "../stepTwo/SearchBarMultiple"; +import CourseIcon from "../../../components/courseIcon"; + +const StepThreeCohort = ({cohortName, selectedCourse, students, selectedStudents, setSelectedStudents, endDate, startDate}) => { + const [isOpenStudents, setIsOpenStudents] = useState(false); +const [isOpenSearchBar, setIsOpenSearchBar] = useState(false); + + + +const handleSelectStudent = (student) => { + + + setSelectedStudents((prevSelected) => { + const alreadySelected = prevSelected.find((s) => s.id === student.id); + if (alreadySelected) { + // Fjern student hvis allerede valgt + return prevSelected.filter((s) => s.id !== student.id); + } else { + // Legg til student + return [...prevSelected, student]; + } + }) + + setTimeout(()=> { + setIsOpenSearchBar(false) + }, 500) + + console.log(selectedStudents); +}; + + return ( + <> +
+ + +

Or select students:

+ +
+ +
+ + {isOpenStudents && ()} +
+ + +
+
+

Cohort details

+
+ +
+ +
+ +
+
+ + + ) +} + +export default StepThreeCohort \ No newline at end of file diff --git a/src/pages/addCohort/stepTwo/SearchBarMultiple/index.js b/src/pages/addCohort/stepTwo/SearchBarMultiple/index.js new file mode 100644 index 00000000..d7049d25 --- /dev/null +++ b/src/pages/addCohort/stepTwo/SearchBarMultiple/index.js @@ -0,0 +1,72 @@ +import { useRef, useState } from "react"; +import TextInput from "../../../../components/form/textInput"; +import SearchIcon from "../../../../assets/icons/searchIcon"; +import { get } from "../../../../service/apiClient"; +import '../../style.css'; + +import MultipleStudentsSearch from "../multipleStudentsMenu/searchMultiple"; + + + +const SearchBarMultiple = ({handleSelectStudent, isOpenSearchBar, setIsOpenSearchBar, selectedStudents}) => { + const [query, setQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const popupRef = useRef(); + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!query.trim()) return; + try { + const response = await get(`search/profiles/${query}`); + setSearchResults(response.data.profiles); + setIsOpenSearchBar(true); + } catch (error) { + console.error("Error fetching search results:", error); + } + }; + + return ( + <> +
+
+ } + value={query} + name="Search" + onChange={(e) => setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSubmit(e); + }} + /> + + + {isOpenSearchBar && ( +
+ {searchResults.length > 0 ? ( + + ) : ( +

No students with this name found

+ )} +
+ )} +
+ + ) +} + +export default SearchBarMultiple \ No newline at end of file diff --git a/src/pages/addCohort/stepTwo/index.js b/src/pages/addCohort/stepTwo/index.js new file mode 100644 index 00000000..1e13d62f --- /dev/null +++ b/src/pages/addCohort/stepTwo/index.js @@ -0,0 +1,63 @@ +import { useState } from "react"; +import ArrowDownIcon from "../../../assets/icons/arrowDownIcon"; +import MultipleStudentsMenu from "./multipleStudentsMenu"; +import SearchBarMultiple from "./SearchBarMultiple"; + +const StepTwoCohort = ({students, selectedStudents, setSelectedStudents}) => { + +const [isOpenStudents, setIsOpenStudents] = useState(false); +const [isOpenSearchBar, setIsOpenSearchBar] = useState(false); + + + +const handleSelectStudent = (student) => { + + console.log("Klikket pΓ₯ student:", student); + + setSelectedStudents((prevSelected) => { + const alreadySelected = prevSelected.find((s) => s.id === student.id); + if (alreadySelected) { + // Fjern student hvis allerede valgt + return prevSelected.filter((s) => s.id !== student.id); + } else { + // Legg til student + return [...prevSelected, student]; + } + }) + + setTimeout(()=> { + setIsOpenSearchBar(false) + }, 500) + +}; + + return ( + <> +
+ + + +

Or select students:

+ +
+ +
+ + {isOpenStudents && ()} +
+ + + + + ) +} + +export default StepTwoCohort \ No newline at end of file diff --git a/src/pages/addCohort/stepTwo/multipleStudentsMenu/index.js b/src/pages/addCohort/stepTwo/multipleStudentsMenu/index.js new file mode 100644 index 00000000..f7043e0b --- /dev/null +++ b/src/pages/addCohort/stepTwo/multipleStudentsMenu/index.js @@ -0,0 +1,29 @@ + + +import MultipleStudentsSearch from "./searchMultiple"; + +const MultipleStudentsMenu = ({ students, handleSelectStudent, selectedStudents }) => { + return ( + <> + + {students.length > 0 ? ( +
+
+ +
+
+ ) : ( +
+

No students selected

+
+ )} + + ); + +}; + +export default MultipleStudentsMenu; diff --git a/src/pages/addCohort/stepTwo/multipleStudentsMenu/searchMultiple/index.js b/src/pages/addCohort/stepTwo/multipleStudentsMenu/searchMultiple/index.js new file mode 100644 index 00000000..5b81f434 --- /dev/null +++ b/src/pages/addCohort/stepTwo/multipleStudentsMenu/searchMultiple/index.js @@ -0,0 +1,63 @@ +import CheckIcon from "../../../../../assets/icons/checkIcon"; +import "./style.css" + +const MultipleStudentsSearch = ({ students, handleSelectStudent , selectedStudents }) => { + + const styleGuideColors = [ + "#28C846", + "#A0E6AA", + "#46DCD2", + "#82E6E6", + "#5ABEDC", + "#46C8FA", + "#46A0FA", + "#666EDC" + ]; + + const getColorFromInitials = (initials) => { + let hash = 0; + for (let i = 0; i < initials.length; i++) { + hash = initials.charCodeAt(i) + ((hash << 5) - hash); + } + + const index = Math.abs(hash) % styleGuideColors.length; + return styleGuideColors[index]; + }; + return ( + +<> + + + + ); +}; + +export default MultipleStudentsSearch; + + + diff --git a/src/pages/addCohort/stepTwo/multipleStudentsMenu/searchMultiple/style.css b/src/pages/addCohort/stepTwo/multipleStudentsMenu/searchMultiple/style.css new file mode 100644 index 00000000..a31c4790 --- /dev/null +++ b/src/pages/addCohort/stepTwo/multipleStudentsMenu/searchMultiple/style.css @@ -0,0 +1,49 @@ +.avatar-list-item { + display: flex; + align-items: center; + width: 100%; + height: 72px; + padding: 8px 16px; + gap: 16px; + background-color: #fff; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + transition: background-color 0.2s ease; +} + + + +.avatar-list-item:hover { + background-color: #f9f9f9; +} + + +.avatar-list-item.selected { + background: #F5FAFF; + +} + +.avatar-icon { + width: 36px; + height: 36px; + border-radius: 50%; + background-color: #ccc; /* Dynamisk farge via JS */ + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + color: #fff; + font-size: 14px; +} + +.avatar-name { + font-size: 15px; + font-weight: 500; + color: #333; +} + +.avatar-checkmark { + margin-left: auto; + font-size: 16px; + color: #28C846; +} diff --git a/src/pages/addCohort/steps/index.js b/src/pages/addCohort/steps/index.js new file mode 100644 index 00000000..9eafe77a --- /dev/null +++ b/src/pages/addCohort/steps/index.js @@ -0,0 +1,132 @@ +import { Snackbar, SnackbarContent } from "@mui/material"; +import { useState } from "react"; +import CheckCircleIcon from "../../../assets/icons/checkCircleIcon"; +import { patch, post } from "../../../service/apiClient"; +import { useNavigate } from "react-router-dom"; + + +const StepperCohort = ({ header, children, cohortName, startDa, endDa, selectedCourse, selectedStudents, setSelectedCourse,setEndDate,setStartDate,setCohortName }) => { + const [currentStep, setCurrentStep] = useState(0); + const [snackbarOpen, setSnackbarOpen] = useState(false); + const navigate = useNavigate() + + + + const onBackClick = () => { + if (currentStep > 0) { + setCurrentStep(currentStep - 1); + } + }; + + const onNextClick = () => { + setCurrentStep(currentStep + 1); + + }; + + const onSkipClick = () => { + setCurrentStep(currentStep + 1); + }; + + const onCancel = () => { + setSelectedCourse("") + setEndDate("") + setStartDate("") + setCohortName("") + navigate(-1) + + + } + + const onComplete = () =>{ + async function addNewCohort() { + try { + const response = await post("cohorts", + { + name: cohortName, + courseId: selectedCourse.id, + startDate: startDa, + endDate: endDa + }); + console.log(response) + + const studentIds = selectedStudents.map(student => student.id); + const response2 = await patch(`cohorts/${response.id}`, + { + name: cohortName, + courseId: selectedCourse.id, + startDate: startDa, + endDate: endDa, + profileIds: studentIds + }); + console.log(response2) + } catch (error) { + console.error("Error adding new cohort:", error); + } + } addNewCohort() + + setSnackbarOpen(true) + setTimeout(()=> { + navigate("/cohorts") + }, 3000) + } + + return ( +
+ {header} + + {children[currentStep]} +
+ {currentStep === 0 ? + (
+ + +
+ ) : + currentStep === 1 ? ( +
+ + +
+ +
+
+ ) : ( +
+ + + + + + + New cohort created + + } + /> + + +
+ ) + } +
+
+ ); +}; + +export default StepperCohort; \ No newline at end of file diff --git a/src/pages/addCohort/style.css b/src/pages/addCohort/style.css new file mode 100644 index 00000000..1688010e --- /dev/null +++ b/src/pages/addCohort/style.css @@ -0,0 +1,72 @@ +.add-cohort-card { + width: 700px !important; + height: auto; + background-color: #FFFFFF; + border: 1px solid #E6EBF5; + border-radius: 8px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.25); + padding: 24px; + box-sizing: border-box; + margin-left: 50px; + margin-top: 50px; +} + +.cohort-name-input, +.cohort-start-date-input { +width: 100%; + margin: 0; + height: 56px; + display: flex; + justify-content: space-between; + align-items: center; + border-radius: 8px; + opacity: 1; + background-color: #E6EBF5; + border: 1px solid #E6EBF5; + font-size: 18px; + color: #000046; + flex: 1; + box-sizing: border-box; + font-family: 'Lexend', sans-serif; + font-weight: 400; +} + +.s, +.selected-students-view { + overflow-y: auto; + height: auto; + height: 350px; + width: 100%; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px 0; +} + +.three-buttons { + margin-top: 50px; + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 20px; + justify-content: space-between; +} + +.cohort-details-group{ + margin-top:20px; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 20px; +} + +.cohort-details-title { + font-size:32px; + margin-left:10px; +} + +.selected-students { + margin-left:50px; + margin-top:10px; + font-size: 18px; +} \ No newline at end of file diff --git a/src/pages/addStudent/cohortsMenu/index.js b/src/pages/addStudent/cohortsMenu/index.js new file mode 100644 index 00000000..ef1f3591 --- /dev/null +++ b/src/pages/addStudent/cohortsMenu/index.js @@ -0,0 +1,38 @@ +const CohortsMenu = ({cohorts, onSelect}) => { + return ( + <> + +
+ {cohorts.length > 0 ? ( +
    + {cohorts.map((cohort) => ( +
  • onSelect(cohort)}> +
    + Cohort {cohort.id} +
    +
  • + ))} + +
+ ) : ( +

Please pick a course

+ )} +
+ + ) +} + + +export default CohortsMenu \ No newline at end of file diff --git a/src/pages/addStudent/coursesMenu/index.js b/src/pages/addStudent/coursesMenu/index.js new file mode 100644 index 00000000..0055bed9 --- /dev/null +++ b/src/pages/addStudent/coursesMenu/index.js @@ -0,0 +1,44 @@ +const CoursesMenu = ({courses, onSelect}) => { + return ( + <> + +
+ {courses.length > 0 ? ( +
    + {courses.map((course) => ( +
  • onSelect(course)}> +
    + {course.name} +
    +
  • + ))} + +
+ ) : ( +
+
+

Loading...

+
+ +
+
+
+ )} +
+ + ) +} + +export default CoursesMenu \ No newline at end of file diff --git a/src/pages/addStudent/index.js b/src/pages/addStudent/index.js new file mode 100644 index 00000000..2a806b82 --- /dev/null +++ b/src/pages/addStudent/index.js @@ -0,0 +1,197 @@ +import ExitIcon from "../../assets/icons/exitIcon"; +import "./style.css"; +import SearchBar from "./searchBar"; +import { useEffect, useState } from "react"; +import { get, patch } from "../../service/apiClient"; +import ArrowDownIcon from "../../assets/icons/arrowDownIcon"; +import StudentsMenu from "./studentsMenu"; +import CoursesMenu from "./coursesMenu"; +import { useNavigate } from "react-router-dom"; +import CohortsMenu from "./cohortsMenu"; +import { Snackbar, SnackbarContent } from '@mui/material'; +import CheckCircleIcon from "../../assets/icons/checkCircleIcon"; + + + +const AddStudent = () => { + const [students, setStudents] = useState([]) + const [courses, setCourses] = useState([]) + const [cohorts, setCohorts] = useState([]) + + const [isOpenCourses, setIsOpenCourses] = useState(false); + const [isOpenStudents, setIsOpenStudents] = useState(false); + const [isOpenCohorts, setIsOpenCohorts] = useState(false) + const [snackbarOpen, setSnackbarOpen] = useState(false); + + const [selectedStudent, setSelectedStudent] = useState(null) + const [selectedCourse, setSelectedCourse] = useState(null) + const [selectedCohort, setSelectedCohort] = useState(null) + const navigate = useNavigate() + + + useEffect(() => { + async function fetchStudents() { + try { + const response = await get("students"); + setStudents(response.data.profiles); + } catch (error) { + console.error("Error fetching students:", error); + } + } + + async function fetchCourses() { + try { + const response = await get("courses"); + setCourses(response.data.courses); + } catch (error) { + console.error("Error fetching courses:", error); + } + } + fetchStudents(); + fetchCourses() + }, []); + + + + + const handleSelectStudent = (student) => { + setIsOpenStudents(false); + setSelectedStudent(student) + }; + + + const handleSelectCourse = (course) => { + setIsOpenCourses(false) + setSelectedCourse(course) + setCohorts(course.cohorts) + } + + const handleSelectCohort = (cohort) => { + setIsOpenCohorts(false) + setSelectedCohort(cohort) + } + + const handleAdd = () => { + async function addStudentToCohort() { + try { + const response = await patch(`cohorts/teacher/${selectedCohort.id}`, {profileId: parseInt(selectedStudent.id)}); + console.log(response) + } catch (error) { + console.error("Error adding student to cohort:", error); + } + } addStudentToCohort() + setSnackbarOpen(true); + + setTimeout(()=> { + navigate(-1) + }, 3000) + } + return ( + <> +
+
+

Add student to cohort

+
+ +
+
+

Add a student to an existing cohort

+
+ + + + +
+

Or

+ +
+ +
+ + {isOpenStudents && ()} + +

Add to

+
+ + +
+ + {isOpenCourses && ()} + +
+ + +
+ + {isOpenCohorts && ()} + + + + +
+ +
+ + + + + + + Student added to cohort + + } + /> + + + +
+ +
+
+

Or

+ +
+ +
+ + ); +}; + +export default AddStudent; diff --git a/src/pages/addStudent/searchBar/index.js b/src/pages/addStudent/searchBar/index.js new file mode 100644 index 00000000..33666834 --- /dev/null +++ b/src/pages/addStudent/searchBar/index.js @@ -0,0 +1,97 @@ +import { useEffect, useRef, useState } from "react"; +import { get } from "../../../service/apiClient"; +import TextInput from "../../../components/form/textInput"; +import SearchIcon from "../../../assets/icons/searchIcon"; +import SearchResultsStudents from "../searchResults"; + +const SearchBar = ({setSelectedStudent, selectedStudent}) => { + const [query, setQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [isOpen, setIsOpen] = useState(false); + const popupRef = useRef(); + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!query.trim()) return; + try { + const response = await get(`search/profiles/${query}`); + console.log(response); + setSearchResults(response.data.profiles); + setIsOpen(true); + } catch (error) { + console.error("Error fetching search results:", error); + } + }; + + const handleSelectStudent = (student) => { + console.log("Selected student:", student); + setSelectedStudent(student); + setQuery(` ${student.firstName} ${student.lastName}`); + setIsOpen(false); + }; + + useEffect(() => { + if (selectedStudent) { + setQuery(`${selectedStudent.firstName} ${selectedStudent.lastName}`); + } + }, [selectedStudent]); + + + return ( + <> +
+
+ setQuery(e.target.value)} + icon={} + iconRight={true} + onKeyDown={(e) => { + if (e.key === "Enter") handleSubmit(e); + }} + iconRight={true} + /> + + + {isOpen && ( +
+ {searchResults.length > 0 ? ( + + ) : ( +

No students with this name found

+ )} +
+ )} +
+ + ) +} + +export default SearchBar + +/** + * placeholder="Search for people" + value={query} + name="Search" + onChange={(e) => setQuery(e.target.value)} + icon={} + iconRight={true} + onKeyDown={(e) => { + if (e.key === "Enter") handleSubmit(e); + }} + */ \ No newline at end of file diff --git a/src/pages/addStudent/searchResults/index.js b/src/pages/addStudent/searchResults/index.js new file mode 100644 index 00000000..ea67125c --- /dev/null +++ b/src/pages/addStudent/searchResults/index.js @@ -0,0 +1,53 @@ + + + +const SearchResultsStudents = ({ students, onSelect }) => { + + + const styleGuideColors = [ + "#28C846", + "#A0E6AA", + "#46DCD2", + "#82E6E6", + "#5ABEDC", + "#46C8FA", + "#46A0FA", + "#666EDC" + ]; + + const getColorFromInitials = (initials) => { + let hash = 0; + for (let i = 0; i < initials.length; i++) { + hash = initials.charCodeAt(i) + ((hash << 5) - hash); + } + + const index = Math.abs(hash) % styleGuideColors.length; + return styleGuideColors[index]; + }; + + + return ( +
    + {students.map((student) => ( +
  • onSelect(student)} + > +
    +
    +
    +

    {student.firstName.charAt(0) + student.lastName.charAt(0)}

    +
    +
    +
    +

    {student.firstName} {student.lastName}

    +
    +
    +
  • + ))} +
+ ); +}; + +export default SearchResultsStudents; diff --git a/src/pages/addStudent/studentsMenu/index.js b/src/pages/addStudent/studentsMenu/index.js new file mode 100644 index 00000000..0ad797fb --- /dev/null +++ b/src/pages/addStudent/studentsMenu/index.js @@ -0,0 +1,37 @@ +import SearchResultsStudents from "../searchResults" + +const StudentsMenu = ({students, handleSelectStudent}) => { + return ( + <> +
+ {students.length > 0 ? ( + + ) : ( +
+
+

Loading...

+
+ +
+
+
+ )} +
+ ) + + +} + +export default StudentsMenu \ No newline at end of file diff --git a/src/pages/addStudent/style.css b/src/pages/addStudent/style.css new file mode 100644 index 00000000..d2c362dd --- /dev/null +++ b/src/pages/addStudent/style.css @@ -0,0 +1,218 @@ +.add-student-card { + width: 700px !important; + height: 1108px !important; + background-color: #FFFFFF; + border: 1px solid #E6EBF5; + border-radius: 8px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.25); + padding: 24px; + box-sizing: border-box; + margin-left: 50px; + margin-top: 50px; +} + +.add-cohort-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.add-title { + font-size: 40px; + color: #000046; + margin: 0; +} + +.add-under-title { + font-size: 18px; + color: #64648C; + margin-top: 8px; +} + +.exit-button { + width: 48px; + height: 48px; + background-color: #F0F5FA; + border: none; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: #64648C; + +} + +.exit-button svg { + width: 24px; + height: 24px; + fill: currentColor; +} + +.line { + border-bottom: 1px solid var(--color-blue5); + margin-top: 10px + +} + +.add-search { + margin-top: 25px; + font-family: 'Lexend', sans-serif; + +} + +.dropdown-section { + width: 100%; +} + +.inputwrapper { + position: relative; + width: 100%; + margin-top: 16px; + font-family: 'Lexend', sans-serif; +} + +.dropbtn { + width: 100%; + padding: 14px 16px; + font-size: 16px; + font-weight: 500; + color: #000046; + background-color: #F0F5FA; + border: 1px solid #E6EBF5; + border-radius: 8px; + cursor: pointer; + text-align: left; + transition: background-color 0.2s ease; +} + +.dropbtn:hover { + background-color: #e0e6f0; +} + +.dropdown-menu { + position: absolute; + top: 100%; + left: 0; + width: 100%; + background-color: #FFFFFF; + border: 1px solid #E6EBF5; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + max-height: 300px; + overflow-y: auto; + z-index: 10; + margin-top: -54px; + font-family: inherit; +} + +.dropdown-menu li { + padding: 12px 16px; + font-size: 16px; + color: #64648C; + cursor: pointer; + transition: background-color 0.2s ease;; +} + +.dropdown-menu li:hover { + background-color: #F0F5FA; +} + +.dropdown-menu li.selected { + background-color: #E6EBF5; + font-weight: bold; +} + +.add-student-loading { + font-size: 20px; +} + +.add-student-students-button button, +.select-course-button button, +.select-cohort-button button { + width: 100%; + margin: 0; + height: 56px; + display: flex; + justify-content: space-between; + align-items: center; + border-radius: 8px; + opacity: 1; + background-color: #E6EBF5; + border: 1px solid #E6EBF5; + font-size: 16px; + color: #000046; + flex: 1; + box-sizing: border-box; +} + + +.add-student-button-title, +.select-course-title, +.select-cohort-title { + font-size: 18px; + color: #64648C; +} + +.dropdown-section { + margin-top: 70px; + display: flex; + flex-direction: column; + gap: 60px; /* gir jevn avstand mellom alle barn */ +} + +.the-label { + color: #64648C; + font-size: 16px; + margin-left: 15px; +} + +.paragraph { + color: #64648C; + font-size: 16px; +} + +.required-label { + color: #96A0BE; + font-size: 16px; + +} + +.buttons-at-bottom{ + margin-top: 50px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 30px; + margin-bottom: 50px; +} + +.bottom{ + display: grid; + grid-template-columns: auto; + +} + + +button.offwhite-button, +.button.offwhite-button { + background-color: var(--color-offwhite); + color: var(--color-blue1); + width: 35% !important; + margin-left:60px; +} +button.offwhite-button:hover, +.button.offwhite-button:hover, +button.offwhite-button:focus, +.button.offwhite-button:focus { + background-color: var(--color-blue); + color: white; +} + +.no-course { + margin-bottom: 100px; +} + +.select-course-title-selected{ + font-size: 18px; + font-family: 'Lexend', sans-serif; + font-weight: 400; +} \ No newline at end of file diff --git a/src/pages/cohort/exercises/exercises.css b/src/pages/cohort/exercises/exercises.css new file mode 100644 index 00000000..e8764745 --- /dev/null +++ b/src/pages/cohort/exercises/exercises.css @@ -0,0 +1,27 @@ +.value { + color: var(--color-blue1); + margin-bottom: 15px; +} + +.label { + color: var(--color-blue1); + margin-bottom: 15px; +} + +.see-more-button { + background-color: var(--color-blue5); +} + +.exercise-row { + display: flex; + justify-content: space-between; + margin-bottom: 8px; +} + +.label { + font-weight: 500; +} + +.value { + color: var(--color-blue1); +} diff --git a/src/pages/cohort/exercises/index.js b/src/pages/cohort/exercises/index.js new file mode 100644 index 00000000..e1b6a573 --- /dev/null +++ b/src/pages/cohort/exercises/index.js @@ -0,0 +1,32 @@ +import Card from "../../../components/card"; +import './exercises.css' + +const Exercises = () => { + return ( + <> + +

My Exercises

+
+ +
+ Modules: + 2/7 completed +
+ +
+ Units: + 4/10 completed +
+ +
+ Exercise: + 34/58 completed +
+ + +
+ + ) +} + +export default Exercises; \ No newline at end of file diff --git a/src/pages/cohort/index.js b/src/pages/cohort/index.js new file mode 100644 index 00000000..947ed7b3 --- /dev/null +++ b/src/pages/cohort/index.js @@ -0,0 +1,159 @@ +import Students from "./students"; + +import Teachers from './teachers'; +import Exercises from "./exercises"; +import { useUserRoleData } from "../../context/userRole."; +import TeacherCohort from "./teacherCohort"; +import jwtDecode from "jwt-decode"; +import useAuth from "../../hooks/useAuth"; +import { get, getUserById } from "../../service/apiClient"; +import { useEffect, useState } from "react"; + + + +const Cohort = () => { + const {userRole, setUserRole} = useUserRoleData() + const { token } = useAuth(); + + // Safely decode token with fallback + let decodedToken = {}; + try { + decodedToken = jwtDecode(token || localStorage.getItem('token')) || {}; + setUserRole(decodedToken.roleId); + } catch (error) { + console.error('Invalid token in Cohort component:', error); + } + + const [studentsLoading, setStudentsLoading] = useState(true); + const [teachersLoading, setTeachersLoading] = useState(true); + const [cohortsLoading, setCohortsLoading] = useState(true); + + const [teachers, setTeachers] = useState([]); + + const [students, setStudents] = useState([]); + const [course, setcourse] = useState([]); + const [cohort, setCohort] = useState(""); + const [cohorts, setCohorts] = useState([]) + const [refresh, setRefresh] = useState(false) + + useEffect(() => { + setCohortsLoading(true) + async function fetchCohorts() { + try { + const response = await get("cohorts"); + setCohorts(response.data.cohorts); + } catch (error) { + console.error("Error fetching cohorts:", error); + } finally { + setCohortsLoading(false) + } + } + + fetchCohorts(); + }, [refresh]); + + + useEffect(() => { + setTeachersLoading(true); + setStudentsLoading(true); + async function fetchData() { + try { + const token = localStorage.getItem('token'); + if (!token) { + console.error('No token found'); + return; + } + + let userId; + try { + const decodedToken = jwtDecode(token); + userId = decodedToken.userId; + } catch (decodeError) { + console.error('Invalid token:', decodeError); + return; + } + + const user = await getUserById(userId); + if (user.profile.cohort === null) { + return; + } + const data = await get(`cohorts/${user.profile.cohort.id}`); + + // set cohort + const cohort = data.data.cohort; + setCohort(cohort); + + // set teachers + const teachers = data.data.cohort.profiles.filter((userid) => userid?.role?.name === "ROLE_TEACHER"); + setTeachers(teachers || []); + + console.log(teachers, "teachers in cohort"); + + // students + const students = data.data.cohort.profiles.filter((profileid) => profileid?.role?.name === "ROLE_STUDENT"); + setStudents(students || []); + console.log(students, "students in cohort"); + + // course + const course = data.data.cohort.course; + setcourse(course || ""); + + } catch (error) { + console.error('fetchData() in cohort/teachers/index.js:', error); + } finally { + setStudentsLoading(false); + setTeachersLoading(false); + } + } + + fetchData(); + }, []); + + function getInitials(profile) { + if (!profile.firstName || !profile.lastName) return "NA"; + const firstNameParts = profile.firstName.trim().split(/\s+/) || ''; // split by any number of spaces + const lastNameInitial = profile.lastName.trim().charAt(0); + + const firstNameInitials = firstNameParts.map(name => name.charAt(0)); + + return (firstNameInitials.join('') + lastNameInitial).toUpperCase(); + } + + if (studentsLoading || teachersLoading || cohortsLoading) { + return ( +
+
+

Loading...

+
+ +
+
+
+ ) + } + + return ( + <> + {userRole === 2 ? ( + <> +
+ +
+ + + ):( + + ) + } + + + ) + +} + +export default Cohort; + + diff --git a/src/pages/cohort/newStudent/index.js b/src/pages/cohort/newStudent/index.js new file mode 100644 index 00000000..9ebb6691 --- /dev/null +++ b/src/pages/cohort/newStudent/index.js @@ -0,0 +1,124 @@ +import { useState } from 'react'; +import NewStudentStepOne from './newStudentStepOne'; +import NewStudentStepTwo from './newStudentStepTwo'; +import NewStudentStepFour from './newStudentStepFour'; +import './style.css'; +import NewStudentStepThree from './newStudentStepThree'; +import Stepper from '../../../components/stepper'; +import useAuth from '../../../hooks/useAuth'; +import { useFormData } from '../../../context/form'; +import { validateEmail, validatePassword } from '../../register'; + +const NewStudent = () => { + const { onRegister, onCreateNewStudent } = useAuth(); + const { formData } = useFormData(); + + const [profile, setProfile] = useState({ + first_name: '', + last_name: '', + username: '', + github_username: '', + email: '', + mobile: '', + password: '', + bio: '', + role: 'ROLE_STUDENT', + specialism: '', + cohort: '', + start_date: '', + end_date: '', + photo: '' + }); + + const onChange = (event) => { + const { name, value } = event.target; + + setProfile({ + ...profile, + [name]: value + }); + + }; + + const onComplete = async () => { + const ok = await validateEP(profile.email, profile.password); + if (!ok) return; + + onCreateNewStudent( + profile.first_name, + profile.last_name, + profile.username, + profile.github_username, + profile.email, + profile.mobile, + profile.password, + profile.bio, + profile.role, + profile.specialism, + profile.cohort, + profile.start_date, + profile.end_date, + profile.photo + ); + }; + + + const handleFileChange = (event, close) => { + + const file = event.target.files[0]; + if (file) { + const url = URL.createObjectURL(file) + setProfile(prevProfile => ({ + ...prevProfile, + photo: url + })); + close() + } + } + + const validateE = (email) => { + if (!validateEmail(email)) { + return false; + } + + } + const validateP = (password) => { + if (!validatePassword(password)) { + return false; + } + } + + const validateEP = async (email, password) => { + + try { + await onRegister(email, password); + return true; + } catch (err) { + if (err.status === 400) { + alert("Email is already in use"); + } + } +}; + + return ( +
+ } onComplete={onComplete}> + + + + + +
+ ); +}; + +const WelcomeHeader = () => { + return ( +
+

Add new student

+

Create a new student profile

+
+ ); +}; + +export default NewStudent; diff --git a/src/pages/cohort/newStudent/newStudentStepFour/index.js b/src/pages/cohort/newStudent/newStudentStepFour/index.js new file mode 100644 index 00000000..3d48d39c --- /dev/null +++ b/src/pages/cohort/newStudent/newStudentStepFour/index.js @@ -0,0 +1,28 @@ +import Form from '../../../../components/form'; + +const NewStudentStepFour = ({ data, setData }) => { + return ( + + <> +
+

Bio

+
+
+
+ +
+ {data.bio.length}/{300} +
+
+
+ + ); +}; + +export default NewStudentStepFour diff --git a/src/pages/cohort/newStudent/newStudentStepOne/index.js b/src/pages/cohort/newStudent/newStudentStepOne/index.js new file mode 100644 index 00000000..831a08f8 --- /dev/null +++ b/src/pages/cohort/newStudent/newStudentStepOne/index.js @@ -0,0 +1,105 @@ +import Popup from 'reactjs-popup'; +import ProfileIcon from '../../../../assets/icons/profileIcon'; + +import Form from '../../../../components/form'; +import TextInput from '../../../../components/form/textInput'; +import Card from '../../../../components/card'; + + +const StepOne = ({ data, setData, handleFileChange }) => { + + + return ( + <> +
+

Basic info

+
+
+
+

Photo

+ +
+ {data.photo ? ( + profile photo + ) : ( )} + + {data.photo ? "Replace headshot" : "Add headshot"} + } modal> + {close => ( + +
+

+ {data.photo ? "Replace Photo" : "Upload Photo"} +

+

Choose a file to upload your headshot

+ +
+ + + handleFileChange(e, close)} + /> + +
+
+
+ )} +
+ +
+ +

Please upload a valid image file

+
+
+ + + + +

*Required

+
+
+ + ); +}; + +export default StepOne; diff --git a/src/pages/cohort/newStudent/newStudentStepThree/index.js b/src/pages/cohort/newStudent/newStudentStepThree/index.js new file mode 100644 index 00000000..0d0127a1 --- /dev/null +++ b/src/pages/cohort/newStudent/newStudentStepThree/index.js @@ -0,0 +1,120 @@ +import { useEffect, useState } from 'react'; +import Form from '../../../../components/form'; +import TextInput from '../../../../components/form/textInput'; +import { get } from '../../../../service/apiClient'; +import ArrowDownIcon from '../../../../assets/icons/arrowDownIcon'; +import CoursesMenu from '../../../addStudent/coursesMenu'; +import CohortsMenu from '../../../addStudent/cohortsMenu'; +import LockIcon from '../../../../assets/icons/lockIcon'; + +const NewStudentStepThree = ({ data, setData, setProfile }) => { + + const [ courses, setCourses ] = useState([]) + const [ cohorts, setCohorts ] = useState([]) + + const [ isOpenCourses, setIsOpenCourses ] = useState(false); + const [ isOpenCohorts, setIsOpenCohorts ] = useState(false); + + const [ selectedCourse, setSelectedCourse ] = useState(null) + const [ selectedCohort, setSelectedCohort ] = useState(null) + + useEffect(() => { + + async function fetchCourses() { + try { + const response = await get("courses"); + console.log(response) + setCourses(response.data.courses); + } catch (error) { + console.error("Error fetching courses:", error); + } + } + fetchCourses() + }, []); + + + const handleSelectCourse = (course) => { + setSelectedCourse(course) + setCohorts(course.cohorts) + + setProfile(prev => ({ + ...prev, + specialism: course?.name, + start_date: course?.startDate, + end_date: course?.endDate, + })); + } + + const handleSelectCohort = (cohort) => { + setIsOpenCohorts(false) + setSelectedCohort(cohort) + + setProfile(prev => ({ + ...prev, + cohort: cohort?.id + })); + } + + return ( + <> +
+

Training info

+
+
+
+ + } + iconRight={true} + /> +
+ + + + {isOpenCourses && ()} +
+
+ + +
+ + {isOpenCohorts && ()} + + +
+
+ + ) +} + +export default NewStudentStepThree; \ No newline at end of file diff --git a/src/pages/cohort/newStudent/newStudentStepTwo/index.js b/src/pages/cohort/newStudent/newStudentStepTwo/index.js new file mode 100644 index 00000000..f29dbe9c --- /dev/null +++ b/src/pages/cohort/newStudent/newStudentStepTwo/index.js @@ -0,0 +1,48 @@ +import Form from '../../../../components/form'; +import NumberInput from '../../../../components/form/numberInput'; +import TextInput from '../../../../components/form/textInput'; + +const NewStudentStepTwo = ({ data, setData, validateEmail, validatePassword }) => { + + return ( + <> +
+

Basic info

+
+
+
+ validateEmail(data.email)} + required + + /> + + validatePassword(data.password)} + type={'password'} + /> +

*Required

+
+
+ + ); +}; + +export default NewStudentStepTwo; \ No newline at end of file diff --git a/src/pages/cohort/newStudent/style.css b/src/pages/cohort/newStudent/style.css new file mode 100644 index 00000000..d40eaa65 --- /dev/null +++ b/src/pages/cohort/newStudent/style.css @@ -0,0 +1,244 @@ +.welcome-titleblock { + margin-bottom: 32px; +} +.welcome-titleblock h1 { + margin-bottom: 16px; +} +.welcome-cardheader h2 { + margin-bottom: 16px; +} +.welcome-cardheader p { + margin-bottom: 24px; +} +.welcome-formheader h3 { + margin-bottom: 32px; +} +.welcome-form-profileimg-input { + display: grid; + grid-template-columns: 40px auto; + gap: 16px; + align-items: center; +} +.welcome-form-profileimg-error { + color: transparent; +} +.welcome-form-inputs { + display: grid; + grid-template-rows: repeat(auto, auto); + gap: 5px; + margin-bottom: 48px; +} + +.welcome-form-popup-wrapper { + width: 470px; + max-width: 100%; +} +.welcome-form-popup { + display: grid; + grid-template-rows: repeat(auto, auto); + gap: 24px; +} +.welcome-form-popup-buttons { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; +} + +.welcome-counter { + margin-left: 10px; + font-size: 12px; + color:grey +} + +.bio-label { + font-size: 10px; +} + +.bio-heading { + font-size: 25px; +} + +.addHeadshot { + height: 55px; + color:#64648c; + display: flex; +} + + +.upload-label { + background-color: var(--color-blue); + color: white; + padding: 14px 24px; + border-radius: 4px; + cursor: pointer; + text-align: center; + font-size: 20px; +} + + +.line { + border-bottom: 1px solid var(--color-blue5); + margin-top: 10px + +} + +.dropdown-section { + width: 100%; +} + +.inputwrapper { + position: relative; + width: 100%; + margin-top: 16px; +} + +.dropbtn { + width: 100%; + padding: 14px 16px; + font-size: 16px; + font-weight: 500; + color: #000046; + background-color: #F0F5FA; + border: 1px solid #E6EBF5; + border-radius: 8px; + cursor: pointer; + text-align: left; + transition: background-color 0.2s ease; +} + +.dropbtn:hover { + background-color: #e0e6f0; +} + +.dropdown-menu { + position: absolute; + top: 100%; + left: 0; + width: 100%; + background-color: #FFFFFF; + border: 1px solid #E6EBF5; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + max-height: 300px; + overflow-y: auto; + z-index: 10; + margin-top: -54px; +} + +.dropdown-menu li { + padding: 12px 16px; + font-size: 16px; + color: #64648C; + cursor: pointer; + transition: background-color 0.2s ease;; +} + +.dropdown-menu li:hover { + background-color: #F0F5FA; +} + + +.dropdown-menu li.selected { + background-color: #E6EBF5; + font-weight: bold; +} + +.password-wrapper { + font-family: + 'Lexend' !important; +} + +.select-course-button button, +.select-cohort-button button { + width: 100% !important; + margin: 0; + height: 56px; + display: flex; + justify-content: space-between; + align-items: center; + border-radius: 8px; + opacity: 1; + background-color: #E6EBF5; + border: 1px solid #E6EBF5; + font-size: 16px; + color: #000046; + flex: 1; + box-sizing: border-box; +} + + +.select-course-title, +.select-cohort-title { + font-size: 16px; + color: #64648C; + font-family: + 'Lexend'; + +} + +.select-course-title-selected-selected, +.select-cohort-title-selected-selected { + font-size: 16px; + color: #000046; + font-family: + 'Lexend'; + +} + +.dropdown-section { + margin-top: 70px; + display: flex; + flex-direction: column; + gap: 60px; +} + +.the-label { + color: #64648C; + font-size: 16px; + width: 100% !important; +} + +.paragraph { + color: #64648C; + font-size: 16px; +} + +.required-label { + color: #96A0BE; + font-size: 16px; + +} + +.buttons-at-bottom{ + margin-top: 50px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 30px; + margin-bottom: 50px; +} + +.bottom{ + display: grid; + grid-template-columns: auto; + +} + + +button.offwhite-button, +.button.offwhite-button { + background-color: var(--color-offwhite); + color: var(--color-blue1); + width: 35% ; +} +button.offwhite-button:hover, +.button.offwhite-button:hover, +button.offwhite-button:focus, +.button.offwhite-button:focus { + background-color: var(--color-blue); + color: white; +} + + +.no-course { + margin-bottom: 100px; +} \ No newline at end of file diff --git a/src/pages/cohort/students/index.js b/src/pages/cohort/students/index.js new file mode 100644 index 00000000..b56805b9 --- /dev/null +++ b/src/pages/cohort/students/index.js @@ -0,0 +1,66 @@ +import Card from "../../../components/card"; +import Student from "./student"; +import './students.css'; +import SoftwareLogo from "../../../assets/icons/software-logo"; +import FrontEndLogo from "../../../assets/icons/frontEndLogo"; +import DataAnalyticsLogo from "../../../assets/icons/dataAnalyticsLogo"; +import '../../../components/profileCircle/style.css'; +import '../../../components/fullscreenCard/fullscreenCard.css'; +// import { useState } from "react"; + + +function Students({ students, getInitials, course, cohort }) { + + return ( + +
+
+

My cohort

+
+ + {course && ( +
+
+ {course.name === "Software Development" && } + {course.name === "Front-End Development" && } + {course.name === "Data Analytics" && } +
+ +
+

{course.name}, Cohort {cohort.id}

+
+ +
+

{`${cohort.course.startDate} - ${cohort.course.endDate}`}

+
+
+ )} + +
+ {students.map((student) => ( + + ))} +
+
+
+ ); +} + +export default Students; \ No newline at end of file diff --git a/src/pages/cohort/students/student/index.js b/src/pages/cohort/students/student/index.js new file mode 100644 index 00000000..e887f76e --- /dev/null +++ b/src/pages/cohort/students/student/index.js @@ -0,0 +1,20 @@ +import UserIcon from "../../../../components/profile-icon"; + +const Student = ({ id, initials, firstName, lastName, role, photo=null }) => { + return ( + <> +
+ +
+ + ); +}; + +export default Student; diff --git a/src/pages/cohort/students/students.css b/src/pages/cohort/students/students.css new file mode 100644 index 00000000..4a8a80ab --- /dev/null +++ b/src/pages/cohort/students/students.css @@ -0,0 +1,115 @@ +.cohort { + display: grid; + row-gap: 20px; +} + + +/* FOR THE COURSE AND DATE SECTON */ +.cohort-course-date-wrapper { + display: grid; + grid-template-columns: 56px 1fr 144px; + align-items: center; + column-gap: 20px; + padding: 15px; + background: #fff; +} + +.cohort-course-date { + display: flex; + align-items: center; + gap: 1rem; +} + + +.cohort-title { + grid-column: 2; + grid-row: 1; +} + +.cohort-title p { + font-weight: 600; + font-size: 1.1rem; +} + +.cohort-dates { + grid-column: 2; + grid-row: 2; +} + +/* FOR THE EDIT ICON!! DONT KNOW WHY BUT WE NEED IT */ +.edit-icon { + border-radius: 50%; + width: 40px; + height: 40px; + background: #f0f5fa; +} + +.edit-icon p { + text-align: center; + font-size: 20px; +} + +.edit-icon:hover { + background: #e1e8ef; + cursor: pointer; +} + +/* FOR THE STUDENTS COLUMNS */ +.cohort-students-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +.student-details { + display: grid; + grid-template-columns: 56px 1fr 48px; + align-items: center; + column-gap: 20px; + padding: 15px; + background: #fff; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +.border-top { + border-top: 1px solid #e6ebf5; + padding-top: 20px; + padding-bottom: 10px; +} + +/* FOR THE COURSE ICONS */ +.software-icon { + background: #28C846; +} + +.front-icon { + background: #6E6EDC; +} + +.data-icon { + background: #46A0FA; +} + +.course-icon svg { + width: 24px; + height: 24px; +} + +/* FOR THE COURSE NAV BUTTONS */ +.course-nav-buttons { + display: flex; + gap: 0.5rem; +} + +.course-nav-buttons button { + padding: 0.5rem 0.75rem; + font-size: 1rem; + cursor: pointer; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +.course-nav-buttons button:hover { + background-color: #e1e8ef; +} \ No newline at end of file diff --git a/src/pages/cohort/teacherCohort/cohortsList/index.js b/src/pages/cohort/teacherCohort/cohortsList/index.js new file mode 100644 index 00000000..ff035cfd --- /dev/null +++ b/src/pages/cohort/teacherCohort/cohortsList/index.js @@ -0,0 +1,61 @@ + +import SoftwareLogo from "../../../../assets/icons/software-logo"; +import FrontEndLogo from "../../../../assets/icons/frontEndLogo"; +import DataAnalyticsLogo from "../../../../assets/icons/dataAnalyticsLogo"; +import './style.css'; +import { useState } from "react"; +import { useSelectedCohortId } from "../../../../context/selectedCohort"; + + +const CohortsList= ({ onSelect, setSelectedCohort , cohorts}) => { + const [selectedCohortId, setSelectedCohortId] = useState(null); + const {setCohortId} = useSelectedCohortId(); + + const handleClick = (cohort) => { + setSelectedCohortId(cohort.id); + setSelectedCohort(cohort) + setCohortId(cohort.id); + if (onSelect) { + onSelect(cohort.profiles); + } + }; + + + return ( +
    + {cohorts.map((cohort) => ( +
  • handleClick(cohort)} + > +
    + {cohort.course.name === "Software Development" && } + {cohort.course.name === "Front-End Development" && } + {cohort.course.name === "Data Analytics" && } +
    +
    +
    {cohort.course.name}
    +
    {cohort.name}
    +
    +
  • + )) + } +
+ ); +}; + +export default CohortsList; + + + diff --git a/src/pages/cohort/teacherCohort/cohortsList/style.css b/src/pages/cohort/teacherCohort/cohortsList/style.css new file mode 100644 index 00000000..8c2d27c4 --- /dev/null +++ b/src/pages/cohort/teacherCohort/cohortsList/style.css @@ -0,0 +1,71 @@ + + + +.course-icon { + width: 56px; + height: 56px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +/* Farger per kurs */ +.software-icon { + background: #28C846; +} + +.front-icon { + background: #6E6EDC; +} + +.data-icon { + background: #46A0FA; +} + +.course-icon svg { + width: 30px; + height: 30px; +} + +.cohort-name-course { + font-size: 16px; + color: #64648C; + margin-top: 4px; + margin-right: 10px; +} + +.cohort-info { + margin-top: 10px; +} + +.course-name { + font-size: 20px; + font-weight: bold; + +} + + +.cohort-course-row { + display: flex; + align-items: center; + padding: 12px; + cursor: pointer; + border-radius: 6px; + transition: background-color 0.2s ease; + width: 380px; + box-sizing: border-box; + gap: 12px; + margin-bottom: 8px; + font-size: 20px + +} + +.cohort-course-row:hover { + background-color: #f0f5fa; /* lys blΓ₯grΓ₯ ved hover */ +} + +.cohort-course-row.selected { + background-color: #E6EBF5; + +} diff --git a/src/pages/cohort/teacherCohort/index.js b/src/pages/cohort/teacherCohort/index.js new file mode 100644 index 00000000..8d722601 --- /dev/null +++ b/src/pages/cohort/teacherCohort/index.js @@ -0,0 +1,103 @@ +import { useEffect, useState } from "react" +// import SearchIcon from "../../../assets/icons/searchIcon" +import EditIconCohortTeacher from "../../../components/editIconCohortTeacher" +// import TextInput from "../../../components/form/textInput" +import CohortsList from "./cohortsList" +import './style.css'; +import StudentList from "./studentList" +import EditIconCouse from "../../../components/editIconCourse" +import CourseIcon from "../../../components/courseIcon" +import { useNavigate } from "react-router-dom" +import SearchTeacher from "./searchTeacher"; + + +const TeacherCohort = ({cohorts, setRefresh}) => { + // const [searchVal, setSearchVal] = useState(''); + const [selectedProfiles, setSelectedProfiles] = useState([]); + const[selectedCohort, setSelectedCohort] = useState(null); + const navigate = useNavigate() + + // const onChange = (e) => { + // setSearchVal(e.target.value); + // }; + + useEffect(() => {}, [selectedProfiles]); + + return ( + <> + {cohorts.length > 0 ? (
+
+
+

Cohorts

+

Students

+
+ +
+ +
+
+ + +
+
+
+
+ +
+
+ +
+
+ +
+ + +
+ setSelectedProfiles(profiles)} /> +
+
+ +
+
+
+ {selectedCohort !== null ? ( + <> + + + + + ): (<>

Select a course

)} + +
+ +
+
+ +
+
+ + +
+
+
+
+ + +
+
+
):( +
+
+

Loading...

+
+ +
+
+
+ )} + + + ) +} + +export default TeacherCohort diff --git a/src/pages/cohort/teacherCohort/searchTeacher/index.js b/src/pages/cohort/teacherCohort/searchTeacher/index.js new file mode 100644 index 00000000..115a77e5 --- /dev/null +++ b/src/pages/cohort/teacherCohort/searchTeacher/index.js @@ -0,0 +1,123 @@ +import { useEffect, useRef, useState } from "react"; +import { useSearchResults } from "../../../../context/searchResults"; +import { useNavigate } from "react-router-dom"; +import { get } from "../../../../service/apiClient"; +import TextInput from "../../../../components/form/textInput"; +import SearchIcon from "../../../../assets/icons/searchIcon"; +import ProfileIconTeacher from "../../../../components/profile-icon-teacherView"; +import Card from "../../../../components/card"; + +const SearchTeacher = () => { + const [query, setQuery] = useState(""); + const {searchResults, setSearchResults} = useSearchResults(); + const [isOpen, setIsOpen] = useState(false); + const navigate = useNavigate(); + const popupRef = useRef(); + + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!query.trim()) return; + try { + const response = await get(`search/profiles/${query}`); + setSearchResults(response.data.profiles); + setIsOpen(true); + } catch (error) { + console.error("Error fetching search results:", error); + } + } + + useEffect(() => { + function handleClickOutside(e) { + if (popupRef.current && !popupRef.current.contains(e.target)) { + setIsOpen(false); + } + } + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("touchstart", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("touchstart", handleClickOutside); + }; + }, [isOpen]); + + + return ( + +
+ +
+ } + value={query} + name="Search" + onChange={(e) => setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSubmit(e); + }} + /> + + + {isOpen && ( +
+ +

People

+ {searchResults?.length > 0 ? ( +
    + {searchResults.slice(0, 10).map((student) => ( +
  • + +
  • + ))} +
+ ) : ( +

Sorry, no results found

+ )} + + + {searchResults?.length > 10 && ( +
+ +
+ )} +
+
+ )} +
+ ); + +} + +export default SearchTeacher; diff --git a/src/pages/cohort/teacherCohort/studentList/index.js b/src/pages/cohort/teacherCohort/studentList/index.js new file mode 100644 index 00000000..78ad0d08 --- /dev/null +++ b/src/pages/cohort/teacherCohort/studentList/index.js @@ -0,0 +1,41 @@ +import { useEffect, useState } from "react"; +import ProfileIconTeacher from "../../../../components/profile-icon-teacherView"; +import { get } from "../../../../service/apiClient"; + +const StudentList = ({ profiles, setSelectedProfiles }) => { + const [refresh, setRefresh] = useState(false); + + + useEffect(() => { + if (!profiles || profiles.length === 0) return; + async function fetchStudents() { + try { + const response = await get("profiles"); + const studs = response.data.profiles; + const filteredStuds = studs.filter(stud => profiles.some(p => p.id === stud.id)); + setSelectedProfiles(filteredStuds); + } catch (error) { + console.error("Error fetching students:", error); + } + } fetchStudents(); + }, [refresh]); + + if (!profiles || profiles.length === 0) { + return

; + } + return ( +
    + {profiles.map((student) => ( +
  • +
    + +
    +
  • + ))} +
+ ); +}; + +export default StudentList; diff --git a/src/pages/cohort/teacherCohort/style.css b/src/pages/cohort/teacherCohort/style.css new file mode 100644 index 00000000..1a48326b --- /dev/null +++ b/src/pages/cohort/teacherCohort/style.css @@ -0,0 +1,203 @@ + +.cohort-card { + width: 88%; + height: 100%; + position: absolute; + top: 120px; + left: 175px; + background-color: #FFFFFF; + border: 1px solid #E6EBF5; + border-radius: 8px; + padding: 24px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + display: flex; + flex-direction: column; + + +} + +.cohort-card-header { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 16px; + border-bottom: 1px solid var(--color-blue5); + +} + +.header-titles { + display: flex; + gap: 350px; +} + +.header-titles h3 { + font-size: 32px; + color: #000046; + +} + +.search-bar { + margin-bottom: 10px; +} + + +.sections-wrapper { + display: flex; + flex-direction: row; + height: calc(100vh - 80px); /* justerer for header-hΓΈyden */ + width: 100%; +} + +.cohorts-section { + position: relative; + width: 500px; + padding: 24px; + border-right: 1px solid var(--color-blue5); + display: flex; + flex-direction: column; + gap: 24px; + height: 100%; /* viktig for at linjen skal dekke hele hΓΈyden */ +} + +.cohort-list { + overflow-y: auto; + height: 100%; + width: 106%; + box-sizing: border-box; + display: flex; + flex-direction: column; + scrollbar-width: thin; +} + + + +.student-list { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + width: 101%; + min-height: auto; + overflow-y: auto; + scrollbar-width: thin; + +} + +.students-section { + position: relative; + width: 100%; + padding: 24px; + display: flex; + flex-direction: column; + gap: 24px; +} + +.students { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + +} + + + +.selected-course { + flex: 1; /* tar opp all tilgjengelig plass til venstre */ +} + + +.actions { + display: flex; + align-items: center; + gap: 16px; /* mellomrom mellom knapp og ikon */ +} + + +.add-student-button button { + height: 56px; + width: 166px; + padding: 0 24px; + background-color: #F0F5FA; + border: none; + color: #64648C; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + box-sizing: border-box; + margin-right: 40px; +} + + +.edit-icon-course { + font-size: 24px; + color: #64648C; +} + + +.add-cohort { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; /* gir luft mellom knapp og ikon */ + + +} + +.add-cohort-button { + width: auto; + flex-grow: 2; +} + + +.add-cohort-button button{ + width: 199px; + height: 56px; + padding: 14px 24px; + gap: 8px; /* hvis du har ikon og tekst inni */ + border-radius: 8px; + background-color: #F0F5FA; + border: none; + cursor: pointer; + font-size: 20px; + color: #64648C; + display: flex; + align-items: center; + justify-content: center; + opacity: 1; + transform: rotate(0deg); /* angle: 0 deg */ + position: relative; /* ikke absolute med top/left med mindre nΓΈdvendig */ +} + + +.edit-icon { + display: flex; + align-items: center; + justify-content: center; +} + + +.divider { + border-bottom: 1px solid var(--color-blue5); + +} + +.cohort-teacher-loading { + margin-top: 20px; + margin-left: 20px; +} + +.search-bar-in-cohorts { + margin-bottom:10px; + overflow: visible; +} + +.profile-icon-cohorts { + position: absolute !important; + top: 100%; + left: 0; + width: "100%"; +} \ No newline at end of file diff --git a/src/pages/cohort/teachers/index.js b/src/pages/cohort/teachers/index.js new file mode 100644 index 00000000..a8147025 --- /dev/null +++ b/src/pages/cohort/teachers/index.js @@ -0,0 +1,33 @@ +import Card from "../../../components/card"; +import './style.css'; +import Teacher from "./teacher"; + + +const Teachers = ({ teachers, getInitials }) => { + console.log(teachers, "teachers in teachers component"); + return ( + +
+
+

Teachers

+
+ +
+ {teachers.map((teacher) => ( + + ))} +
+
+
+ ); +} + +export default Teachers; diff --git a/src/pages/cohort/teachers/style.css b/src/pages/cohort/teachers/style.css new file mode 100644 index 00000000..107be272 --- /dev/null +++ b/src/pages/cohort/teachers/style.css @@ -0,0 +1,35 @@ +.card { + background: white; + padding: 24px; + border-radius: 8px; + width: 50%; + margin-bottom: 25px; + border: 1px #e6ebf5 solid; +} + +.cohort { + display: grid; + row-gap: 20px; +} + +.cohort-teachers-container { + display: grid; + gap: 20px; +} + +.teacher-details { + display: grid; + grid-template-columns: 56px 1fr 48px; + align-items: center; + column-gap: 20px; + padding: 15px; + background: #fff; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +.border-top { + border-top: 1px solid var(--color-blue5); + padding-top: 20px; + padding-bottom: 10px; +} \ No newline at end of file diff --git a/src/pages/cohort/teachers/teacher/index.js b/src/pages/cohort/teachers/teacher/index.js new file mode 100644 index 00000000..17f06fd3 --- /dev/null +++ b/src/pages/cohort/teachers/teacher/index.js @@ -0,0 +1,21 @@ +import UserIcon from "../../../../components/profile-icon"; + +const Teacher = ({ id, initials, firstName, lastName, role, photo=null }) => { + + return ( + <> +
+ +
+ + ); +}; + +export default Teacher; diff --git a/src/pages/dashboard/cohorts/index.js b/src/pages/dashboard/cohorts/index.js new file mode 100644 index 00000000..40fd5aae --- /dev/null +++ b/src/pages/dashboard/cohorts/index.js @@ -0,0 +1,58 @@ + +import Card from "../../../components/card" +import SoftwareLogo from "../../../assets/icons/software-logo" +import FrontEndLogo from "../../../assets/icons/frontEndLogo" +import DataAnalyticsLogo from "../../../assets/icons/dataAnalyticsLogo" +import './style.css'; + +const Cohorts = ({cohorts}) => { + + return ( + <> + +

Cohorts

+
+ {cohorts !== null ? ( +
    + {cohorts.map((cohort, index) => { + return ( +
  • + {cohort.course === null ? <> : +
    + +
    + {cohort.course.name === "Software Development" && } + {cohort.course.name === "Front-End Development" && } + {cohort.course.name === "Data Analytics" && } +
    +
    + {cohort.course.name} +

    Cohort {cohort.id}

    +
    +
    + } +
  • + ); + })} +
+ ) : ( +
+
+

Loading...

+
+ +
+
+
+ )} +
+
+ + ) + } + +export default Cohorts \ No newline at end of file diff --git a/src/pages/dashboard/cohorts/style.css b/src/pages/dashboard/cohorts/style.css new file mode 100644 index 00000000..f9c27bcc --- /dev/null +++ b/src/pages/dashboard/cohorts/style.css @@ -0,0 +1,105 @@ +main { + padding: 30px; +} + +aside { + padding: 30px 60px 30px 0; +} + +.create-post-input { + display: grid; + grid-template-columns: 70px auto; +} + +.create-post-input button { + color: var(--color-blue1); + font-size: 1rem !important; + padding-left: 15px !important; + text-align: left !important; + max-width: 100% !important; + background-color: var(--color-blue5); +} + + + +.dashboard-cohort-item { + margin-top: 10px; + +} + +.dashboard-cohort-info { + display: flex; + flex-direction: column; + min-width: 0; +} + +.course-text { + margin-left: -35px +} + + +.course-icon { + width: 56px; + height: 56px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} +/* Farger per kurs */ +.software-icon { + background: #28C846; +} + +.front-icon { + background: #6E6EDC; +} + +.data-icon { + background: #46A0FA; +} + +.course-icon svg { + width: 30px; + height: 30px; + +} + +.dashboard-cohort-name { + margin-left: 50px; + font-size: 16px; + color: #64648C; + margin-top: 4px; + +} + + +.dashboard-course-name { + font-size: 20px; + font-weight: bold; + margin-left: 50px; + +} + +.student-button { + margin-top: 20px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + width: 199px; + height: 56px; + padding: 14px 24px; + gap: 8px; + border-radius: 8px; + background: #F0F5FA; + color: #64648C; + border: none; + cursor: pointer; + font-size: 16px; + +} + +.loading-cohorts { + font-size: 20px; +} \ No newline at end of file diff --git a/src/pages/dashboard/index.js b/src/pages/dashboard/index.js index 54606849..8a12aeb7 100644 --- a/src/pages/dashboard/index.js +++ b/src/pages/dashboard/index.js @@ -1,19 +1,85 @@ -import { useState } from 'react'; -import SearchIcon from '../../assets/icons/searchIcon'; + + +import { useState, useEffect } from 'react'; + + + import Button from '../../components/button'; import Card from '../../components/card'; import CreatePostModal from '../../components/createPostModal'; -import TextInput from '../../components/form/textInput'; import Posts from '../../components/posts'; import useModal from '../../hooks/useModal'; import './style.css'; +import Cohorts from './cohorts'; +import { useUserRoleData } from '../../context/userRole.'; +import Students from './students'; +import TeachersDashboard from './teachers'; +import useAuth from '../../hooks/useAuth'; +import jwtDecode from 'jwt-decode'; +import Search from './search'; + +import { getUserById, get } from '../../service/apiClient'; +import UserIcon from '../../components/profile-icon'; +import SimpleProfileCircle from '../../components/simpleProfileCircle'; const Dashboard = () => { - const [searchVal, setSearchVal] = useState(''); + const { token } = useAuth(); + const [students, setStudents] = useState([]); + const [cohort, setCohort] = useState([]); + const [course, setCourse] = useState([]); + const [cohorts, setCohorts] = useState(null) + + // Safely decode token with fallback + let decodedToken = {}; + try { + if (token || localStorage.getItem('token')) { + decodedToken = jwtDecode(token || localStorage.getItem('token')) || {}; + } + } catch (error) { + console.error('Invalid token in Dashboard:', error); + } + + // to view people My Cohort + useEffect(() => { + async function fetchCohortData() { + try { + const token = localStorage.getItem('token'); + if (!token) { + console.error('No token found.'); + return; + } + + let userId; + try { + const decodedToken = jwtDecode(token); + userId = decodedToken.userId; + } catch (decodeError) { + console.error('Invalid token:', decodeError); + return; + } + + const user = await getUserById(userId); + if (user.profile.cohort === null) { + return; + } + const data = await get(`cohorts/${user.profile.cohort.id}`); + setCohort(data.data.cohort) + setCourse(data.data.cohort.course); + setStudents(data.data.cohort.profiles) + + } catch (error) { + console.error('fetchCohortData() in dashboard/index.js:', error); + } + } + fetchCohortData(); + }, []); + + const fullName = `${decodedToken.firstName || decodedToken.first_name || 'Current'} ${decodedToken.lastName || decodedToken.last_name || 'User'}`; + const initials = fullName?.match(/\b(\w)/g)?.join('') || 'NO'; + const { userRole, setUserRole } = useUserRoleData(); + + - const onChange = (e) => { - setSearchVal(e.target.value); - }; // Use the useModal hook to get the openModal and setModal functions const { openModal, setModal } = useModal(); @@ -27,31 +93,135 @@ const Dashboard = () => { openModal(); }; + const [refresh, setRefresh] = useState(false); + + useEffect(() => { + async function fetchAndSetUserRole() { + const storedToken = token || localStorage.getItem('token'); + if (!storedToken) return; + try { + const decoded = jwtDecode(storedToken); + const user = await getUserById(decoded.userId); + // check the role from backend + const roleName = user.profile.role.name; + if (roleName === 'ROLE_TEACHER') setUserRole(1); + else if (roleName === 'ROLE_STUDENT') setUserRole(2); + else setUserRole(null); + } catch (error) { + console.error('Error fetching user role from backend:', error); + } + } + fetchAndSetUserRole(); + }, [token, setUserRole]); + + useEffect(() => { + async function fetchCohorts() { + try { + const response = await get("cohorts"); + setCohorts(response.data.cohorts); + } catch (error) { + console.error("Error fetching cohorts:", error); + } + } + fetchCohorts(); + }, []); + + function getInitials(profile) { + if (!profile.firstName || !profile.lastName) return "NA"; + const firstNameParts = profile.firstName.trim().split(/\s+/) || ''; // split by any number of spaces + const lastNameInitial = profile.lastName.trim().charAt(0); + + const firstNameInitials = firstNameParts.map(name => name.charAt(0)); + + return (firstNameInitials.join('') + lastNameInitial).toUpperCase(); + } + return ( <>
-
-

AJ

-
+ {/*
*/} + + +{/* */} + {/*

{initials}

*/} + {/*
*/} +
- +
); diff --git a/src/pages/dashboard/search/index.js b/src/pages/dashboard/search/index.js new file mode 100644 index 00000000..bb61c5c7 --- /dev/null +++ b/src/pages/dashboard/search/index.js @@ -0,0 +1,111 @@ +import { useNavigate } from "react-router-dom" +import { useState, useRef, useEffect } from "react" +import Card from "../../../components/card" +import TextInput from "../../../components/form/textInput" +import SearchIcon from "../../../assets/icons/searchIcon" +import { get } from "../../../service/apiClient" +import UserIcon from "../../../components/profile-icon" +import { useSearchResults } from "../../../context/searchResults" + +const Search = () => { + const [query, setQuery] = useState(""); + const {searchResults, setSearchResults} = useSearchResults(); + const [isOpen, setIsOpen] = useState(false); + const navigate = useNavigate(); + const popupRef = useRef(); + + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!query.trim()) return; + try { + const response = await get(`search/profiles/${query}`); + setSearchResults(response.data.profiles); + setIsOpen(true); + } catch (error) { + console.error("Error fetching search results:", error); + } + } + + useEffect(() => { + function handleClickOutside(e) { + if (popupRef.current && !popupRef.current.contains(e.target)) { + setIsOpen(false); + } + } + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("touchstart", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("touchstart", handleClickOutside); + }; + }, [isOpen]); + + + return ( +
+
+ } + value={query} + name="Search" + onChange={(e) => setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSubmit(e); + }} + /> + + + {isOpen && ( +
+ +

People

+ {searchResults?.length > 0 ? ( +
    + {searchResults.slice(0, 10).map((student) => ( +
  • + +
  • + ))} +
+ ) : ( +

Sorry, no results found

+ )} + + + {searchResults?.length > 10 && ( +
+ +
+ )} +
+
+ )} +
+ ); + +} + +export default Search; diff --git a/src/pages/dashboard/students/index.js b/src/pages/dashboard/students/index.js new file mode 100644 index 00000000..31f417f8 --- /dev/null +++ b/src/pages/dashboard/students/index.js @@ -0,0 +1,70 @@ +import { useEffect, useState } from "react"; +import { get } from "../../../service/apiClient"; + +import Card from "../../../components/card" + +// import UserIcon from "../../../components/profile-icon"; + +import ProfileIconTeacher from "../../../components/profile-icon-teacherView"; + +const Students = ({refresh, setRefresh }) => { + const [students, setStudents] = useState(null) + + + useEffect(() => { + async function fetchStudents() { + try { + const response = await get("students"); + setStudents(response.data.profiles); + } catch (error) { + console.error("Error fetching students:", error); + } + } + fetchStudents(); + }, [refresh]); + + return( + <> + +

Students

+
+ {students !== null ? ( +
+
    + {students.map((student, index) => ( +
  • +
    + word[0].toUpperCase()) + .join('')} + firstname={student.firstName} + lastname={student.lastName} + role={"Student"} + setRefresh={setRefresh} + /> +
    +
  • + ))} +
+ +
+
+ ):( +
+

Loading...

+
+ +
+
+ )} +
+
+ + ) +} +export default Students \ No newline at end of file diff --git a/src/pages/dashboard/style.css b/src/pages/dashboard/style.css index f55ef0a7..8ee9a57a 100644 --- a/src/pages/dashboard/style.css +++ b/src/pages/dashboard/style.css @@ -8,7 +8,14 @@ aside { .create-post-input { display: grid; - grid-template-columns: 70px auto; + grid-template-columns: 48px auto; + gap: 8px; +} + +/* Override UserIcon padding in create post area */ +.create-post-input .user { + padding-left: 0 !important; + padding-right: 0 !important; } .create-post-input button { @@ -19,3 +26,133 @@ aside { max-width: 100% !important; background-color: var(--color-blue5); } + + +.dashboard-cohort-item { + margin-bottom: 20px; +} + +.cohort-header { + display: flex; + align-items: center; +} + +.course-icon { + width: 56px; + height: 56px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +/* Farger per kurs */ +.software-icon { + background: #28C846; +} + +.front-icon { + background: #6E6EDC; +} + +.data-icon { + background: #46A0FA; +} + +.course-icon svg { + width: 30px; + height: 30px; +} + + +.cohort-name { + font-size: 16px; + color: #64648C; + margin-top: 4px; +} + +.course-text { + display: flex; + flex-direction: column; +} + + +.dashboard-course-name { + font-size: 20px; + font-weight: bold; + margin: 0; +} + +.student-button { + margin-top: 20px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + width: 199px; + height: 56px; + padding: 14px 24px; + gap: 8px; + border-radius: 8px; + background: #F0F5FA; + color: #64648C; + border: none; + cursor: pointer; + font-size: 16px; + +} + +.people { + font-size: 16px; + color: #64648C; + border-bottom: 1px solid var(--color-blue5); + padding: 10px 10px; + +} + +.cohort-teachers-container { + display: grid; + gap: 20px; +} + +.border-top { + border-top: 1px solid var(--color-blue5); + padding-top: 20px; + padding-bottom: 10px; +} + +.padding-top { + padding-top: 10px; + padding-bottom: 10px; +} + +.cohort-name-student { + margin-left: 50px; + font-size: 16px; + color: #64648C; + margin-top: 4px; +} + +.snackbar { + position: fixed; + bottom: 32px; + left: 50%; + transform: translateX(-50%); + background: #333; + color: #fff; + padding: 16px 32px; + border-radius: 8px; + z-index: 9999; + font-size: 1rem; + box-shadow: 0 2px 8px rgba(0,0,0,0.2); + opacity: 1; + transition: opacity 0.3s; +} + +.students-list-teacher-view { + max-height: 300px; + overflow-y: auto; + padding: 0; + margin: 0; + list-style: none; +} \ No newline at end of file diff --git a/src/pages/dashboard/teachers/index.js b/src/pages/dashboard/teachers/index.js new file mode 100644 index 00000000..c8c3000c --- /dev/null +++ b/src/pages/dashboard/teachers/index.js @@ -0,0 +1,61 @@ +import { useEffect, useState } from "react" +import { get } from "../../../service/apiClient" +import Card from "../../../components/card" +import UserIcon from "../../../components/profile-icon" + +const TeachersDashboard = () => { + const [teachers, setTeachers] = useState(null) + + useEffect(() => { + async function fetchTeachers() { + try { + const response = await get("teachers") + setTeachers(Array.isArray(response.data.profiles) ? response.data.profiles : []) + } catch (error) { + console.error("Error fetching teachers: ", error) + } + } + fetchTeachers() + }, []) + + return ( + <> + +

Teachers

+
+ {teachers !== null ? ( +
    + {teachers?.map((teacher, index) => ( +
  • +
    + word[0].toUpperCase()) + .join('')} + firstname={teacher.firstName} + lastname={teacher.lastName} + role = {"Teacher"} + /> +
    +
  • + ))} +
+ ) : ( +
+

Loading...

+
+ +
+
+ )} +
+
+ + ) +} + +export default TeachersDashboard \ No newline at end of file diff --git a/src/pages/edit/edit.css b/src/pages/edit/edit.css new file mode 100644 index 00000000..483aa929 --- /dev/null +++ b/src/pages/edit/edit.css @@ -0,0 +1,164 @@ +.edit-profile-form { + width: 120%; + margin: 2rem auto; + padding: 2rem; + background-color: #fff; + border: 1px solid #e6ebf5; + border-radius: 12px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.05); + font-family: 'Inter', sans-serif; + display: flex; + flex-direction: column; +} + +.edit-profile-form h2 { + font-size: 2rem; + margin-bottom: 2rem; + color: #333; +} + +.section h3 { + font-size: 1.25rem; + margin-bottom: 1rem; + color: #444; +} + +.row { + display: flex; + gap: 2rem; + margin-bottom: 2rem; + flex-wrap: wrap; +} + +.section { + flex: 1; + min-width: 300px; +} + +.half { + width: 100%; +} + +@media (min-width: 768px) { + .half { + width: 48%; + } +} + +.section > *:not(h3):not(.photo-placeholder):not(.char-count) { + margin-bottom: 1.5rem; +} + +.photo-placeholder { + width: 80px; + height: 80px; + background-color: #ddd; + border-radius: 50%; + font-size: 1.5rem; + font-weight: bold; + color: #555; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1.5rem; +} + +input, +select, +textarea { + width: 100%; + padding: 01.6rem; + font-size: 1rem; + font-family: 'Inter', sans-serif; + background-color: #fff; +} + +.char-count { + text-align: right; + font-size: 0.85rem; + color: #666; + margin-top: -1rem; + margin-bottom: 1.5rem; +} + +.save-button { + align-self: flex-end; + background-color: #0077cc; + color: white; + padding: 0.75rem 1.5rem; + border: none; + border-radius: 8px; + font-size: 1rem; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.save-button:hover { + background-color: #005fa3; +} + +.bio-area { + background-color: #e6ebf5 +} + +.save { + background-color: var(--color-blue); + color: white; +} + +.cancel { + background-color: var(--color-blue5); +} + +.bottom-buttons { + display: flex; + justify-content: flex-end; + gap: 20px; + padding: 20px; +} + +.change-password-button { + width: 100%; + padding: 0.6rem; + font-size: 1rem; + font-family: 'Inter', sans-serif; + background-color: var(--color-blue); + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.photo-row { + display: flex !important; + flex-direction: row !important; + align-items: center !important; + gap: 20px !important; + flex-wrap: nowrap !important; +} + +.photo-wrapper .profile-photo { + width: 60px !important; + height: 60px !important; + object-fit: cover !important; + display: block !important; +} + +.profile-photo { + width: 100px; + height: 100px; +} + +.profile-container .info-section .info-row .label { + color: #333333; +} + +.profile-container .info-section .info-row .value { + color: #111111; +} + +.profile-container .info-section .info-row .value a { + color: #0077cc; + text-decoration: underline; +} diff --git a/src/pages/edit/index.js b/src/pages/edit/index.js new file mode 100644 index 00000000..ee014b68 --- /dev/null +++ b/src/pages/edit/index.js @@ -0,0 +1,402 @@ +import { useEffect, useState } from "react"; +import "./edit.css"; +import Popup from "reactjs-popup"; +import imageCompression from "browser-image-compression"; +import { getUserById, updateUserProfile, refreshToken } from "../../service/apiClient"; +import useAuth from "../../hooks/useAuth"; +import jwtDecode from "jwt-decode"; +import TextInput from "../../components/form/textInput"; +import Card from "../../components/card"; +import { validatePassword, validateEmail } from '../register'; +import LockIcon from '../../assets/icons/lockIcon' +import SimpleProfileCircle from "../../components/simpleProfileCircle"; + +const EditPage = () => { + const [formData, setFormData] = useState(null); + const { token } = useAuth(); + + let userId; + try { + const decodedToken = jwtDecode(token || localStorage.getItem('token')); + userId = decodedToken?.userId; + } catch (error) { + console.error('Invalid token:', error); + userId = null; + } + + const [formValues, setFormValues] = useState({ + photo: "", + firstName: "", + lastName: "", + username: "", + githubUsername: "", + email: "", + mobile: "", + password: "", + bio: "", + }); + const [showPasswordFields, setShowPasswordFields] = useState(false); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + + useEffect(() => { + async function fetchUser() { + try { + const data = await getUserById(userId); + setFormData(data); + + const profile = data.profile || {}; + setFormValues({ + photo: profile.photo || "", + firstName: profile.firstName || "", + lastName: profile.lastName || "", + username: profile.username || "", + githubUsername: profile.githubUrl || "", + email: data.email || "", + mobile: profile.mobile || "", + password: data.password || "", + bio: profile.bio || "", + }); + } catch (error) { + console.error("Error in EditPage", error); + } + } + if (userId) fetchUser(); + }, [userId]); + + if (!formData || !formData.profile) { + return ( +
+

Loading...

+
+
+ ); + } + + const firstName = formData.profile.firstName; + const lastName = formData.profile.lastName; + const name = `${firstName} ${lastName}`; + + const getReadableRole = (role) => { + switch (role) { + case 'ROLE_STUDENT': return 'Student'; + case 'ROLE_TEACHER': return 'Teacher'; + case 'ROLE_ADMIN': return 'Administrator'; + default: return role; + } + }; + + const handleChange = (e) => { + const { name, value } = e.target; + setFormValues((prev) => ({ ...prev, [name]: value })); + }; + + const togglePasswordFields = () => setShowPasswordFields(prev => !prev); + + const handleFileCompressionAndSet = async (file, closePopup) => { + if (!file) return; + if (!file.type.startsWith('image/')) { alert('Not an image'); return; } + + const options = { maxSizeMB: 0.5, maxWidthOrHeight: 1024, useWebWorker: true, initialQuality: 0.8 }; + + try { + const compressedFile = await imageCompression(file, options); + if (compressedFile.size > 2 * 1024 * 1024) { + alert('Bildet er fortsatt for stort etter komprimering. Velg et mindre bilde.'); + return; + } + const reader = new FileReader(); + reader.onloadend = () => { + const dataUrl = reader.result; + setFormValues(prev => ({ ...prev, photo: dataUrl })); + if (typeof closePopup === 'function') closePopup(); + }; + reader.readAsDataURL(compressedFile); + } catch (err) { + console.error('Compression error', err); + alert('Kunne ikke komprimere bildet'); + } + }; + + const resetFormToSaved = () => { + if (!formData) return; + const profile = formData.profile || {}; + setFormValues({ + photo: profile.photo || "", + firstName: profile.firstName || "", + lastName: profile.lastName || "", + username: profile.username || "", + githubUsername: profile.githubUrl || "", + email: formData.email || "", + mobile: profile.mobile || "", + password: "", + bio: profile.bio || "", + }); + alert("The changes are discarded") + setNewPassword(""); + setConfirmPassword(""); + setShowPasswordFields(false); + }; + + const handleSave = async (e) => { + e.preventDefault(); + + if (!validateEmail(formValues.email)) return; + + if (showPasswordFields) { + const isValidFormat = validatePassword(newPassword); + if (!isValidFormat) return; + if (newPassword !== confirmPassword) { + alert("The passwords do not match."); + return; + } + }; + + const updatedValues = { ...formValues, password: showPasswordFields ? newPassword : "" }; + + try { + const refreshed = await updateUserProfile(userId, updatedValues); + alert("Profile is updated!"); + setFormData(refreshed); + const refreshedProfile = refreshed.profile || {}; + + // Update localStorage with new photo + if (refreshedProfile.photo) { + localStorage.setItem('userPhoto', refreshedProfile.photo); + } + + // Refresh the token to get updated user information + try { + const refreshResponse = await refreshToken(); + if (refreshResponse.token) { + localStorage.setItem('token', refreshResponse.token); + } + } catch (tokenError) { + console.error('Token refresh failed:', tokenError); + } + + setFormValues({ + photo: refreshedProfile.photo || "", + firstName: refreshedProfile.firstName || "", + lastName: refreshedProfile.lastName || "", + username: refreshedProfile.username || "", + githubUsername: refreshedProfile.githubUrl || "", + email: refreshed.email || "", + mobile: refreshedProfile.mobile || "", + bio: refreshedProfile.bio || "", + }); + } catch (error) { + console.error("Error by update:", error); + alert("Something went wrong by the update."); + } + }; + + return ( + <> +
+

Profile

+
+
+ n[0]).join("").toUpperCase()} + /> +

{name}

+
+
+ + +
+
+

Basic Info

+ +
+
+ +
+ +
+ + {formValues.photo || formData.profile.photo ? "Replace headshot" : "Add headshot"} + + } + modal + > + {close => ( + +
+

+ {formValues.photo || formData.profile.photo ? "Replace Photo" : "Upload Photo"} +

+

Choose a file to upload your headshot

+ +
+ + + handleFileCompressionAndSet(e.target.files?.[0], close)} + /> + + +
+
+
+ )} +
+
+
+



+ + + + +
+ +
+

Training Info

+

+ } + iconRight={true} + /> + } + iconRight={true} + /> + {formData?.profile?.role?.name !== "ROLE_TEACHER" && ( + <> + } + iconRight={true} + /> + } + iconRight={true} + /> + } + iconRight={true} + /> + + )} + {formData?.profile?.role?.name === "ROLE_TEACHER" && ( + } + /> + )} +
+
+ +
+
+

Contact Info

+ + + {!showPasswordFields ? ( + + ) : ( + <> + setNewPassword(e.target.value)} + /> + setConfirmPassword(e.target.value)} + /> + + )} +
+ +
+

Bio

+

+ +
{formValues.bio.length}/300
+
+
+ +
+ + +
+
+
+ + ); +}; + +export default EditPage; diff --git a/src/pages/editCohort/index.js b/src/pages/editCohort/index.js new file mode 100644 index 00000000..08fb1a84 --- /dev/null +++ b/src/pages/editCohort/index.js @@ -0,0 +1,149 @@ +import { useNavigate, useParams } from "react-router-dom" +import ExitIcon from "../../assets/icons/exitIcon" +import "./style.css" +import StepperCohort from "./steps" +import StepOneCohort from "./stepOne" +import { useEffect, useState} from "react" +import { get } from "../../service/apiClient" +import StepTwoCohort from "./stepTwo" +import StepThreeCohort from "./stepThree" + + +const EditCohort = () =>{ + const [students, setStudents] = useState([]) + const [courses, setCourses] = useState([]) + + const [cohortName, setCohortName] = useState("") + const[startDate, setStartDate] = useState("") + const[endDate, setEndDate] = useState("") + const [selectedCourse, setSelectedCourse] = useState("") + const [cohort, setCohort] = useState(null) + + + const [selectedStudents, setSelectedStudents] = useState([]); + + const {id} = useParams() + + + + useEffect(() => { + async function fetchCohortById() { + + try { + const response = await get(`cohorts/${id}`); + setCohort(response.data.cohort); + console.log("Cohort: " , response) + } catch (error) { + console.error("Error fetching cohort by ID:", error); + } + + } + async function fetchStudents() { + try { + const response = await get("students"); + setStudents(response.data.profiles); + } catch (error) { + console.error("Error fetching students:", error); + } + } + + async function fetchCourses() { + try { + const response = await get("courses"); + setCourses(response.data.courses); + } catch (error) { + console.error("Error fetching courses:", error); + } + } + fetchStudents(); + fetchCourses(); + fetchCohortById(); + }, []); + + + // TODO + // Prelaod informasjon fra cohorten + + console.log(cohort) + + useEffect(()=>{ + + if(cohort){ + setCohortName(cohort.name) + setSelectedStudents(cohort.profiles) + setSelectedCourse(cohort.course) + setStartDate(cohort.startDate) + setEndDate(cohort.endDate) + + console.log(selectedCourse) + } + + + + },[cohort]) + + + return ( + <> + } + cohortName={cohortName} + setCohortName={setCohortName} + startDate={startDate} + setStartDate={setStartDate} + endDate={endDate} + setEndDate={setEndDate} + courses={courses} + selectedCourse={selectedCourse} + selectedStudents={selectedStudents} + setSelectedCourse={setSelectedCourse} + setSelectedStudents={setSelectedStudents} + cohortId = {id}> + + + + + + ) +} + + +const CohortHeader = () => { + const navigate = useNavigate() + return ( + <> +
+

Edit cohort

+
+ +
+
+

Update the info for this cohort

+
+ + ) +} +export default EditCohort diff --git a/src/pages/editCohort/stepOne/index.js b/src/pages/editCohort/stepOne/index.js new file mode 100644 index 00000000..f0308cfe --- /dev/null +++ b/src/pages/editCohort/stepOne/index.js @@ -0,0 +1,86 @@ + +import ArrowDownIcon from "../../../assets/icons/arrowDownIcon" +import CoursesMenu from "../../addStudent/coursesMenu" +import { useState } from "react" + + + + +const StepOneCohort = ( {setCohortName, setStartDate, setEndDate, cohortName, startDate, endDate, courses, setSelectedCourse, selectedCourse}) => { + const [courseIsOpen, setCourseIsOpen] = useState(false) + + + + const handleChangeCohortName = (event) => { + setCohortName(event.target.value) + } + + const handleSelectCourse = (course) => { + console.log("selected course", course) + setCourseIsOpen(false) + setSelectedCourse(course) + } + + const handleStartDate = (event) => { + setStartDate(event.target.value) + } + + const handleEndDate = (event) => { + setEndDate(event.target.value) + } + + + return ( + <> +
+
+ + + +
+ +
+ + +
+ + {courseIsOpen && ()} + + +
+ + + +
+ +
+ + + +
+ + + +
+ + + ) +} + +export default StepOneCohort diff --git a/src/pages/editCohort/stepThree/index.js b/src/pages/editCohort/stepThree/index.js new file mode 100644 index 00000000..7dd516c1 --- /dev/null +++ b/src/pages/editCohort/stepThree/index.js @@ -0,0 +1,79 @@ +import { useState } from "react"; +import ArrowDownIcon from "../../../assets/icons/arrowDownIcon"; +import MultipleStudentsMenu from "../stepTwo/multipleStudentsMenu"; +import SearchBarMultiple from "../stepTwo/SearchBarMultiple"; +import CourseIcon from "../../../components/courseIcon"; + +const StepThreeCohort = ({cohortName, selectedCourse, students, selectedStudents, setSelectedStudents, endDate, startDate}) => { + const [isOpenStudents, setIsOpenStudents] = useState(false); +const [isOpenSearchBar, setIsOpenSearchBar] = useState(false); + + + +const handleSelectStudent = (student) => { + + console.log("Klikket pΓ₯ student:", student); + + setSelectedStudents((prevSelected) => { + const alreadySelected = prevSelected.find((s) => s.id === student.id); + if (alreadySelected) { + // Fjern student hvis allerede valgt + return prevSelected.filter((s) => s.id !== student.id); + } else { + // Legg til student + return [...prevSelected, student]; + } + }) + + setTimeout(()=> { + setIsOpenSearchBar(false) + }, 500) + + console.log(selectedStudents); +}; + + return ( + <> +
+ + +

Or select students:

+ +
+ +
+ + {isOpenStudents && ()} +
+ + +
+
+

Cohort details

+
+ {console.log(selectedCourse)} + {console.log(cohortName)} + {console.log(startDate)} + {console.log(endDate)} + + +
+ +
+ +
+
+ + + ) +} + +export default StepThreeCohort \ No newline at end of file diff --git a/src/pages/editCohort/stepTwo/SearchBarMultiple/index.js b/src/pages/editCohort/stepTwo/SearchBarMultiple/index.js new file mode 100644 index 00000000..f8082c79 --- /dev/null +++ b/src/pages/editCohort/stepTwo/SearchBarMultiple/index.js @@ -0,0 +1,73 @@ +import { useRef, useState } from "react"; +import TextInput from "../../../../components/form/textInput"; +import SearchIcon from "../../../../assets/icons/searchIcon"; +import { get } from "../../../../service/apiClient"; +import '../../style.css'; + +import MultipleStudentsSearch from "../multipleStudentsMenu/searchMultiple"; + + + +const SearchBarMultiple = ({handleSelectStudent, isOpenSearchBar, setIsOpenSearchBar, selectedStudents}) => { + const [query, setQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const popupRef = useRef(); + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!query.trim()) return; + try { + const response = await get(`search/profiles/${query}`); + console.log(response); + setSearchResults(response.data.profiles); + setIsOpenSearchBar(true); + } catch (error) { + console.error("Error fetching search results:", error); + } + }; + + return ( + <> +
+
+ } + value={query} + name="Search" + onChange={(e) => setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSubmit(e); + }} + /> + + + {isOpenSearchBar && ( +
+ {searchResults.length > 0 ? ( + + ) : ( +

No students with this name found

+ )} +
+ )} +
+ + ) +} + +export default SearchBarMultiple \ No newline at end of file diff --git a/src/pages/editCohort/stepTwo/index.js b/src/pages/editCohort/stepTwo/index.js new file mode 100644 index 00000000..9b889ee6 --- /dev/null +++ b/src/pages/editCohort/stepTwo/index.js @@ -0,0 +1,64 @@ +import { useState } from "react"; +import ArrowDownIcon from "../../../assets/icons/arrowDownIcon"; +import MultipleStudentsMenu from "./multipleStudentsMenu"; +import SearchBarMultiple from "./SearchBarMultiple"; + +const StepTwoCohort = ({students, selectedStudents, setSelectedStudents}) => { + +const [isOpenStudents, setIsOpenStudents] = useState(false); +const [isOpenSearchBar, setIsOpenSearchBar] = useState(false); + + + +const handleSelectStudent = (student) => { + + console.log("Klikket pΓ₯ student:", student); + + setSelectedStudents((prevSelected) => { + const alreadySelected = prevSelected.find((s) => s.id === student.id); + if (alreadySelected) { + // Fjern student hvis allerede valgt + return prevSelected.filter((s) => s.id !== student.id); + } else { + // Legg til student + return [...prevSelected, student]; + } + }) + + setTimeout(()=> { + setIsOpenSearchBar(false) + }, 500) + + console.log(selectedStudents); +}; + + return ( + <> +
+ + + +

Or select students:

+ +
+ +
+ + {isOpenStudents && ()} +
+ + + + + ) +} + +export default StepTwoCohort \ No newline at end of file diff --git a/src/pages/editCohort/stepTwo/multipleStudentsMenu/index.js b/src/pages/editCohort/stepTwo/multipleStudentsMenu/index.js new file mode 100644 index 00000000..de0ce84f --- /dev/null +++ b/src/pages/editCohort/stepTwo/multipleStudentsMenu/index.js @@ -0,0 +1,32 @@ + + +import MultipleStudentsSearch from "./searchMultiple"; + +const MultipleStudentsMenu = ({ students, handleSelectStudent, selectedStudents }) => { + return ( + <> + + {students.length > 0 ? ( +
+
+ +
+
+ ) : ( +
+

Loading...

+
+ +
+
+ )} + + ); + +}; + +export default MultipleStudentsMenu; diff --git a/src/pages/editCohort/stepTwo/multipleStudentsMenu/searchMultiple/index.js b/src/pages/editCohort/stepTwo/multipleStudentsMenu/searchMultiple/index.js new file mode 100644 index 00000000..5f1d2da6 --- /dev/null +++ b/src/pages/editCohort/stepTwo/multipleStudentsMenu/searchMultiple/index.js @@ -0,0 +1,63 @@ +import "./style.css" + +const MultipleStudentsSearch = ({ students, handleSelectStudent , selectedStudents }) => { + + const styleGuideColors = [ + "#28C846", + "#A0E6AA", + "#46DCD2", + "#82E6E6", + "#5ABEDC", + "#46C8FA", + "#46A0FA", + "#666EDC" + ]; + + const getColorFromInitials = (initials) => { + let hash = 0; + for (let i = 0; i < initials.length; i++) { + hash = initials.charCodeAt(i) + ((hash << 5) - hash); + } + + const index = Math.abs(hash) % styleGuideColors.length; + return styleGuideColors[index]; + }; + return ( + +<> +
    + {students.map((student) => { + const isSelected = selectedStudents.some((s) => String(s.id) === String(student.id)) + console.log("Valgte studenter:", selectedStudents); + + + return ( +
  • handleSelectStudent(student)} + > +
    +
    +
    +

    {student.firstName.charAt(0) + student.lastName.charAt(0)}

    +
    +
    +
    +

    {student.firstName} {student.lastName}

    +
    +
    + {isSelected && } +
  • + ); + })} +
+ + + ); +}; + +export default MultipleStudentsSearch; + + + diff --git a/src/pages/editCohort/stepTwo/multipleStudentsMenu/searchMultiple/style.css b/src/pages/editCohort/stepTwo/multipleStudentsMenu/searchMultiple/style.css new file mode 100644 index 00000000..a31c4790 --- /dev/null +++ b/src/pages/editCohort/stepTwo/multipleStudentsMenu/searchMultiple/style.css @@ -0,0 +1,49 @@ +.avatar-list-item { + display: flex; + align-items: center; + width: 100%; + height: 72px; + padding: 8px 16px; + gap: 16px; + background-color: #fff; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + transition: background-color 0.2s ease; +} + + + +.avatar-list-item:hover { + background-color: #f9f9f9; +} + + +.avatar-list-item.selected { + background: #F5FAFF; + +} + +.avatar-icon { + width: 36px; + height: 36px; + border-radius: 50%; + background-color: #ccc; /* Dynamisk farge via JS */ + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + color: #fff; + font-size: 14px; +} + +.avatar-name { + font-size: 15px; + font-weight: 500; + color: #333; +} + +.avatar-checkmark { + margin-left: auto; + font-size: 16px; + color: #28C846; +} diff --git a/src/pages/editCohort/steps/index.js b/src/pages/editCohort/steps/index.js new file mode 100644 index 00000000..0f8c98b3 --- /dev/null +++ b/src/pages/editCohort/steps/index.js @@ -0,0 +1,123 @@ +/* eslint-disable object-shorthand */ +import { Snackbar, SnackbarContent } from "@mui/material"; +import { useState } from "react"; +import CheckCircleIcon from "../../../assets/icons/checkCircleIcon"; +import { patch} from "../../../service/apiClient"; +import { useNavigate } from "react-router-dom"; + + +const StepperCohort = ({ header, children, cohortName, startDate, endDate, selectedCourse, selectedStudents, setSelectedCourse,setEndDate,setStartDate,setCohortName, cohortId }) => { + const [currentStep, setCurrentStep] = useState(0); + const [snackbarOpen, setSnackbarOpen] = useState(false); + const navigate = useNavigate() + + + + const onBackClick = () => { + if (currentStep > 0) { + setCurrentStep(currentStep - 1); + } + }; + + const onNextClick = () => { + setCurrentStep(currentStep + 1); + }; + + const onSkipClick = () => { + setCurrentStep(currentStep + 1); + }; + + const onCancel = () => { + setSelectedCourse("") + setEndDate("") + setStartDate("") + setCohortName("") + navigate(-1) + } + + const onComplete = () =>{ + async function updateCohort() { + try { + console.log(selectedStudents) + const studentIds = selectedStudents.map(student => student.id); + const response2 = await patch(`cohorts/${cohortId}`, + { + name: cohortName, + courseId: selectedCourse.id, + startDate: startDate, + endDate: endDate, + profileIds: studentIds + }); + console.log(response2) + } catch (error) { + console.error("Error adding new cohort:", error); + } + } updateCohort() + + setSnackbarOpen(true) + setTimeout(()=> { + navigate("/cohorts") + }, 3000) + } + + return ( +
+ {header} + + {children[currentStep]} +
+ {currentStep === 0 ? + (
+ + +
+ ) : + currentStep === 1 ? ( +
+ + +
+ +
+
+ ) : ( +
+ + + + + + + Cohort updated + + + } + /> + + +
+ ) + } +
+
+ ); +}; + +export default StepperCohort; diff --git a/src/pages/editCohort/style.css b/src/pages/editCohort/style.css new file mode 100644 index 00000000..0c84fd98 --- /dev/null +++ b/src/pages/editCohort/style.css @@ -0,0 +1,66 @@ +.add-cohort-card { + width: 700px !important; + height: auto; + background-color: #FFFFFF; + border: 1px solid #E6EBF5; + border-radius: 8px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.25); + padding: 24px; + box-sizing: border-box; + margin-left: 50px; + margin-top: 50px; +} + +.cohort-name-input, +.cohort-start-date-input { +width: 100%; + margin: 0; + height: 56px; + display: flex; + justify-content: space-between; + align-items: center; + border-radius: 8px; + opacity: 1; + background-color: #E6EBF5; + border: 1px solid #E6EBF5; + font-size: 18px; + color: #000046; + flex: 1; + box-sizing: border-box; + font-family: 'Lexend', sans-serif; + font-weight: 400; +} + +.s, +.selected-students-view { + overflow-y: auto; + height: auto; + height: 350px; + width: 100%; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px 0; +} + +.three-buttons { + margin-top: 50px; + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 20px; + justify-content: space-between; +} + +.cohort-details-group{ + margin-top:20px; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 20px; +} + +.cohort-details-title { + font-size:32px; + margin-left:10px; +} \ No newline at end of file diff --git a/src/pages/login/index.js b/src/pages/login/index.js index 08df7d5a..53e02453 100644 --- a/src/pages/login/index.js +++ b/src/pages/login/index.js @@ -4,10 +4,17 @@ import TextInput from '../../components/form/textInput'; import useAuth from '../../hooks/useAuth'; import CredentialsCard from '../../components/credentials'; import './login.css'; +import { useUserRoleData } from '../../context/userRole.'; +import { get } from '../../service/apiClient'; +// eslint-disable-next-line camelcase +import jwt_decode from 'jwt-decode'; +import { useNavigate } from 'react-router-dom'; const Login = () => { - const { onLogin } = useAuth(); + const { onLogin} = useAuth(); const [formData, setFormData] = useState({ email: '', password: '' }); + const {setUserRole} = useUserRoleData() + const navigate = useNavigate() const onChange = (e) => { const { name, value } = e.target; @@ -36,7 +43,20 @@ const Login = () => {
diff --git a/src/pages/profile/index.js b/src/pages/profile/index.js new file mode 100644 index 00000000..b8dda618 --- /dev/null +++ b/src/pages/profile/index.js @@ -0,0 +1,17 @@ +import FullScreenCard from '../../components/fullscreenCard'; +import './profile.css'; + +const ProfilePage = () => { + return ( + <> +
+

Profile

+

+ + +
+ + ) +} + +export default ProfilePage; diff --git a/src/pages/profile/profile-data/index.js b/src/pages/profile/profile-data/index.js new file mode 100644 index 00000000..1696e553 --- /dev/null +++ b/src/pages/profile/profile-data/index.js @@ -0,0 +1,98 @@ +import SimpleProfileCircle from '../../../components/simpleProfileCircle'; +import './profile-data.css' + +const ProfileData = ({ user, initials}) => { + const {email} = user; + const roleName = user.profile.role.name; + const { firstName, lastName, githubUrl, mobile, specialism, bio, photo } = user.profile; + + console.log(user, "user in profile data"); + const getReadableRole = (role) => { + switch (role) { + case 'ROLE_STUDENT': + return 'Student'; + case 'ROLE_TEACHER': + return 'Teacher'; + case 'ROLE_ADMIN': + return 'Administrator' + default: + return role; + } + }; + + return ( +
+
+ {photo ? + + + : + +} + {(firstName || lastName) && ( +

{firstName} {lastName}

+ )} + {bio &&

{bio}

} +
+ +
+ {(firstName || lastName) && ( +
+ Full Name: + {firstName} {lastName} +
+ )} + + {email && ( +
+ Email: + {email} +
+ )} + + {mobile && ( +
+ Mobile: + {mobile} +
+ )} + + {githubUrl && githubUrl.trim() !== '' && ( +
+ Github URL: + + + {githubUrl} + + +
+ )} + + + {specialism && ( +
+ Specialism: + {specialism} +
+ )} + + {roleName && ( +
+ Role: + {getReadableRole(roleName)} +
+ )} +
+
+ ); +}; + +export default ProfileData; diff --git a/src/pages/profile/profile-data/profile-data.css b/src/pages/profile/profile-data/profile-data.css new file mode 100644 index 00000000..d6fa5f85 --- /dev/null +++ b/src/pages/profile/profile-data/profile-data.css @@ -0,0 +1,70 @@ +.profile-container { + display: flex; + flex-direction: row; + gap: 3rem; + padding: 3rem; + font-family: 'Inter', sans-serif; + font-size: 1.4rem; +} + +.info-section { + flex: 1; + display: flex; + flex-direction: column; + gap: 1.5rem; +} +.profile-container .photo-section-edit .simple-profile-circle p { + margin: 0; + line-height: 1; + font-size: 9rem; +} +.info-row { + display: flex; + flex-wrap: wrap; + align-items: center; + font-size: 1.6rem; +} + +.label { + font-weight: 600; + margin-right: 1rem; + min-width: 150px; +} + +.value { + font-weight: 400; + flex: 1; +} + +.value a { + color: #0077cc; + text-decoration: underline; + font-size: 1.6rem; +} + +.bio-text { + margin-top: 1rem; + text-align: center; + font-style: italic; + color: #555; + font-size: 1.4rem; + max-width: 450px; +} + +.photo-section-edit { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 1rem; +} + +.profile-photo-edit { + width: 300px; + height: 300px; + object-fit: cover; + border-radius: 50%; + display: block; + border: 2px solid black; +} + diff --git a/src/pages/profile/profile.css b/src/pages/profile/profile.css new file mode 100644 index 00000000..e69de29b diff --git a/src/pages/register/index.js b/src/pages/register/index.js index 5cc70e32..d518be30 100644 --- a/src/pages/register/index.js +++ b/src/pages/register/index.js @@ -1,13 +1,36 @@ -import { useState } from 'react'; import Button from '../../components/button'; import TextInput from '../../components/form/textInput'; import useAuth from '../../hooks/useAuth'; import CredentialsCard from '../../components/credentials'; import './register.css'; +import ReactPasswordChecklist from 'react-password-checklist'; +import { useFormData } from '../../context/form'; + +export const validateEmail = (email) => { + const mailFormat = /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/; + if (email.match(mailFormat)) { + return true; + } + else { + alert("You have entered an invalid email address"); + return false; + } + } + + export const validatePassword = (password) => { + const passwordFormat = /^(?=.*?[A-Z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,}$/; + if (password.match(passwordFormat)) { + return true; + } + else { + alert("Your password is not in the right format"); + return false; + } + } const Register = () => { const { onRegister } = useAuth(); - const [formData, setFormData] = useState({ email: '', password: '' }); + const {formData, setFormData} = useFormData() const onChange = (e) => { const { name, value } = e.target; @@ -31,6 +54,7 @@ const Register = () => { type="email" name="email" label={'Email *'} + required /> { name="password" label={'Password *'} type={'password'} + required /> + +

Search results

+ + + +
+ } + value={query} + name="Search" + onChange={(e) => setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSubmit(e); + }} + /> + +
+ + {searchResults && ( +
+ +

People

+ + {searchResults.length > 0 && newresults === null ? ( +
    + {searchResults.slice(0, 10).map((student, index) => ( +
  • + {userRole === 1 ? ( + + ) : userRole === 2 ? ( + + ) : null} +
  • + ))} +
+ ) : newresults !== null ? ( +
    + {newresults.slice(0, 10).map((student, index) => ( +
  • + {userRole === 1 ? ( + + ) : userRole === 2 ? ( + + ) : null} +
  • + ))} +
+ ) : ( +

Sorry, no results found

+ )} +
+
+ )} + + + + ); +}; + +export default SearchPage; diff --git a/src/pages/search/style.css b/src/pages/search/style.css new file mode 100644 index 00000000..89d52a7d --- /dev/null +++ b/src/pages/search/style.css @@ -0,0 +1,17 @@ +.search-page { + display: "flex"; + flex-direction: "column"; + + gap: "10px"; + padding: "30px"; +} + +.results-section { + width: 100%; + margin-top: 20px; +} +.inputwrapper { + position: relative; + width: 100%; + margin: 0 auto; +} diff --git a/src/pages/welcome/index.js b/src/pages/welcome/index.js index 85af11ab..43ba459b 100644 --- a/src/pages/welcome/index.js +++ b/src/pages/welcome/index.js @@ -3,16 +3,29 @@ import Stepper from '../../components/stepper'; import useAuth from '../../hooks/useAuth'; import StepOne from './stepOne'; import StepTwo from './stepTwo'; +import StepFour from './stepFour'; import './style.css'; +import { useFormData } from '../../context/form'; +import StepThree from './stepThree'; +import imageCompression from 'browser-image-compression'; const Welcome = () => { const { onCreateProfile } = useAuth(); + const { formData } = useFormData(); const [profile, setProfile] = useState({ - firstName: '', - lastName: '', - githubUsername: '', - bio: '' + first_name: '', + last_name: '', + username: '', + github_username: '', + mobile: '', + bio: '', + role: 'ROLE_STUDENT', + specialism: '', + cohort: '', + start_date: '', + end_date: '', + photo: '' }); const onChange = (event) => { @@ -25,7 +38,57 @@ const Welcome = () => { }; const onComplete = () => { - onCreateProfile(profile.firstName, profile.lastName, profile.githubUsername, profile.bio); + onCreateProfile( + profile.first_name, + profile.last_name, + profile.username, + profile.github_username, + profile.mobile, + profile.bio, + profile.role, + profile.specialism, + profile.cohort, + profile.start_date, + profile.end_date, + profile.photo + ); + }; + + const handleFileChange = async (event, close) => { + const file = event.target.files[0]; + if (!file) return; + + if (!file.type.startsWith('image/')) { + alert('Not an image'); + return; + } + + const options = { + maxSizeMB: 0.5, + maxWidthOrHeight: 1024, + useWebWorker: true, + initialQuality: 0.8 + }; + + try { + const compressedFile = await imageCompression(file, options); + + if (compressedFile.size > 2 * 1024 * 1024) { + alert('Bildet er fortsatt for stort etter komprimering. Velg et mindre bilde.'); + return; + } + + const reader = new FileReader(); + reader.onloadend = () => { + setProfile(prev => ({ ...prev, photo: reader.result })); + if (typeof close === 'function') close(); + }; + reader.readAsDataURL(compressedFile); + + } catch (err) { + console.error('Compression error', err); + alert('Kunne ikke komprimere bildet. PrΓΈv et annet bilde.'); + } }; return ( @@ -35,9 +98,11 @@ const Welcome = () => {

Create your profile to get started

- } onComplete={onComplete}> - - + } onComplete={onComplete}> + + + + ); diff --git a/src/pages/welcome/stepFour/index.js b/src/pages/welcome/stepFour/index.js new file mode 100644 index 00000000..9164e24c --- /dev/null +++ b/src/pages/welcome/stepFour/index.js @@ -0,0 +1,28 @@ +import Form from '../../../components/form'; + +const StepFour = ({ data, setData }) => { + return ( + + <> +
+

Bio

+
+
+
+ +
+ {data.bio.length}/{300} +
+
+
+ + ); +}; + +export default StepFour diff --git a/src/pages/welcome/stepOne/index.js b/src/pages/welcome/stepOne/index.js index 317940f8..b356a520 100644 --- a/src/pages/welcome/stepOne/index.js +++ b/src/pages/welcome/stepOne/index.js @@ -1,8 +1,10 @@ +import Popup from 'reactjs-popup'; import ProfileIcon from '../../../assets/icons/profileIcon'; import Form from '../../../components/form'; import TextInput from '../../../components/form/textInput'; +import Card from '../../../components/card'; -const StepOne = ({ data, setData }) => { +const StepOne = ({ data, setData, handleFileChange }) => { return ( <>
@@ -11,25 +13,84 @@ const StepOne = ({ data, setData }) => {

Photo

+
- -

Add headshot

+ {data.photo ? ( + profile photo + + ) : ( )} + + {data.photo ? "Replace headshot" : "Add headshot"} + } modal> + {close => ( + +
+

+ {data.photo ? "Replace Photo" : "Upload Photo"} +

+

Choose a file to upload your headshot

+ +
+ + + handleFileChange(e, close)} + /> + +
+
+
+ )} +
+
+

Please upload a valid image file

+ + -

*Required

diff --git a/src/pages/welcome/stepThree/index.js b/src/pages/welcome/stepThree/index.js new file mode 100644 index 00000000..8bff21b8 --- /dev/null +++ b/src/pages/welcome/stepThree/index.js @@ -0,0 +1,95 @@ +import { useEffect, useState } from 'react'; +import LockIcon from '../../../assets/icons/lockIcon'; +import Form from '../../../components/form'; +import TextInput from '../../../components/form/textInput'; +import { get } from '../../../service/apiClient'; + +const StepThree = ({ data, setData, setProfile }) => { + + const [selectedCourse, setSelectedCourse] = useState(null); + + useEffect(() => { + async function fetchCourses() { + try { + const response = await get("courses"); + const allCourses = response.data.courses; + + if (allCourses.length > 0) { + const randomIndex = Math.floor(Math.random() * allCourses.length); + const randomCourse = allCourses[randomIndex]; + setSelectedCourse(randomCourse); + + setProfile(prev => ({ + ...prev, + specialism: randomCourse.name, + start_date: randomCourse.startDate, + end_date: randomCourse.endDate, + cohort: randomCourse.cohorts?.[0]?.id || null + })); + } + } catch (error) { + console.error("Error fetching courses:", error); + } + } + + fetchCourses(); + }, []); + + + + return ( + <> +
+

Training info

+
+ +
+ + } + iconRight={true} + /> + } + iconRight={true} + /> + } + iconRight={true} + /> + {console.log("Cohort: ", selectedCourse?.cohorts?.[0]?.id)} + } + iconRight={true} + /> + } + iconRight={true} + /> +
+
+ + ) +} + +export default StepThree; diff --git a/src/pages/welcome/stepTwo/index.js b/src/pages/welcome/stepTwo/index.js index f40dad3e..82ea8610 100644 --- a/src/pages/welcome/stepTwo/index.js +++ b/src/pages/welcome/stepTwo/index.js @@ -1,14 +1,41 @@ + import Form from '../../../components/form'; +import NumberInput from '../../../components/form/numberInput'; +import TextInput from '../../../components/form/textInput'; -const StepTwo = ({ data, setData }) => { +const StepTwo = ({ data, setData, formData }) => { return ( <>
-

Bio

+

Basic info

- + + +

*Required

@@ -16,4 +43,4 @@ const StepTwo = ({ data, setData }) => { ); }; -export default StepTwo; +export default StepTwo; \ No newline at end of file diff --git a/src/pages/welcome/style.css b/src/pages/welcome/style.css index 7ff35605..e38e15a2 100644 --- a/src/pages/welcome/style.css +++ b/src/pages/welcome/style.css @@ -1,44 +1,207 @@ -.welcome-titleblock { - margin-bottom: 32px; +.add-student-card { + width: 700px !important; + height: 1108px !important; + background-color: #FFFFFF; + border: 1px solid #E6EBF5; + border-radius: 8px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.25); + padding: 24px; + box-sizing: border-box; + margin-left: 50px; + margin-top: 50px; } -.welcome-titleblock h1 { - margin-bottom: 16px; + +.add-cohort-header { + display: flex; + justify-content: space-between; + align-items: center; } -.welcome-cardheader h2 { - margin-bottom: 16px; + +.add-title { + font-size: 40px; + color: #000046; + margin: 0; } -.welcome-cardheader p { - margin-bottom: 24px; + +.add-under-title { + font-size: 18px; + color: #64648C; + margin-top: 8px; } -.welcome-formheader h3 { - margin-bottom: 32px; + +.exit-button { + width: 48px; + height: 48px; + background-color: #F0F5FA; + border: none; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: #64648C; } -.welcome-form-profileimg-input { - display: grid; - grid-template-columns: 40px auto; - gap: 16px; + +.exit-button svg { + width: 24px; + height: 24px; + fill: currentColor; +} + +.line { + border-bottom: 1px solid var(--color-blue5); + margin-top: 10px +} + +.add-search { + margin-top: 25px; +} + +.dropdown-section { + width: 100%; +} + +.inputwrapper { + position: relative; + width: 100%; + margin-top: 16px; + font-family: inherit; +} + +.dropbtn { + width: 100%; + padding: 14px 16px; + font-size: 16px; + font-weight: 500; + color: #000046; + background-color: #F0F5FA; + border: 1px solid #E6EBF5; + border-radius: 8px; + cursor: pointer; + text-align: left; + transition: background-color 0.2s ease; +} + +.dropbtn:hover { + background-color: #e0e6f0; +} + +.dropdown-menu { + position: absolute; + top: 100%; + left: 0; + width: 100%; + background-color: #FFFFFF; + border: 1px solid #E6EBF5; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + max-height: 300px; + overflow-y: auto; + z-index: 10; + margin-top: -54px; + font-family: inherit; +} + +.dropdown-menu li { + padding: 12px 16px; + font-size: 16px; + color: #64648C; + cursor: pointer; + transition: background-color 0.2s ease;; +} + +.dropdown-menu li:hover { + background-color: #F0F5FA; +} + +.dropdown-menu li.selected { + background-color: #E6EBF5; + font-weight: bold; +} + +.add-student-loading { + font-size: 20px; +} + + +.add-student-students-button button, +.select-course-button button, +.select-cohort-button button { + width: 100%; + margin: 0; + height: 56px; + display: flex; + justify-content: space-between; align-items: center; + border-radius: 8px; + opacity: 1; + background-color: #E6EBF5; + border: 1px solid #E6EBF5; + font-size: 16px; + color: #000046; + flex: 1; + box-sizing: border-box; } -.welcome-form-profileimg-error { - color: transparent; + + +.add-student-button-title, +.select-course-title, +.select-cohort-title { + font-size: 18px; + color: #64648C; } -.welcome-form-inputs { - display: grid; - grid-template-rows: repeat(auto, auto); - gap: 58px; - margin-bottom: 48px; + +.dropdown-section { + margin-top: 70px; + display: flex; + flex-direction: column; + gap: 60px; /* gir jevn avstand mellom alle barn */ } -.welcome-form-popup-wrapper { - width: 470px; - max-width: 100%; + +.the-label { + color: #64648C; + font-size: 16px; + margin-left: 15px; } -.welcome-form-popup { - display: grid; - grid-template-rows: repeat(auto, auto); - gap: 24px; + +.paragraph { + color: #64648C; + font-size: 16px; +} + +.required-label { + color: #96A0BE; + font-size: 16px; } -.welcome-form-popup-buttons { + +.buttons-at-bottom{ + margin-top: 50px; display: grid; grid-template-columns: 1fr 1fr; - gap: 24px; + gap: 30px; + margin-bottom: 50px; +} + +.bottom{ + display: grid; + grid-template-columns: auto; +} + +button.offwhite-button, +.button.offwhite-button { + background-color: var(--color-offwhite); + color: var(--color-blue1); + width: 35% !important; + margin-left:60px; +} + +button.offwhite-button:hover, +.button.offwhite-button:hover, +button.offwhite-button:focus, +.button.offwhite-button:focus { + background-color: var(--color-blue); + color: white; } + +.no-course { + margin-bottom: 100px; +} \ No newline at end of file diff --git a/src/service/apiClient.js b/src/service/apiClient.js index 5f3cdbcf..cfff69ce 100644 --- a/src/service/apiClient.js +++ b/src/service/apiClient.js @@ -1,26 +1,174 @@ import { API_URL } from './constants'; +// eslint-disable-next-line camelcase +import jwt_decode from 'jwt-decode'; + async function login(email, password) { return await post('login', { email, password }, false); } async function register(email, password) { - await post('users', { email, password }, false); + await post('signup', { email, password }, false); return await login(email, password); } -async function createProfile(userId, firstName, lastName, githubUrl, bio) { - return await patch(`users/${userId}`, { firstName, lastName, githubUrl, bio }); +// Refresh token to get updated user information +async function refreshToken() { + try { + return await post('auth/refresh', {}, true); // Assuming there's a refresh endpoint + } catch (error) { + console.error('No refresh endpoint available or token refresh failed:', error); + throw error; + } +} + +/* eslint-disable camelcase */ + +async function createProfile(userId, + first_name, + last_name, + username, + github_username, + mobile, + bio, + role, + specialism, + cohort, + start_date, + end_date, + photo +) { + + cohort = parseInt(cohort) + + return await post(`profiles`, { userId, + first_name, + last_name, + username, + github_username, + mobile, + bio, + role, + specialism, + cohort, + start_date, + end_date, + photo } + ); +} + + +async function createNewStudent( + first_name, + last_name, + username, + github_username, + email, + mobile, + password, + bio, + role, + specialism, + cohort, + start_date, + end_date, + photo) { + + cohort = parseInt(cohort) + photo = JSON.stringify(photo) + + return await post(`students/create`, { + first_name, + last_name, + username, + github_username, + email, + mobile, + password, + bio, + role, + specialism, + cohort, + start_date, + end_date, + photo } + ); } async function getPosts() { const res = await get('posts'); return res.data.posts; } +async function getComments(postId) { + const res = await get(`posts/${String(postId)}/comments`); + return res.data.comments; +} + +async function getUserById(id) { + const res = await get(`users/${id}`); + return res.data.user; +} + +async function getMyCohortProfiles(role) { + const token = localStorage.getItem('token'); + + if (!token) { + console.error('No token found'); + } + + const { userId } = jwt_decode(token); + const user = await getUserById(userId); + const res = await get(`cohorts/${user.profile.cohort.id}`); + + if (role === "teacher") { + const teachers = res.data.cohort.profiles.filter((userid) => userid?.role?.name === "ROLE_TEACHER"); + return teachers; + } + else if (role === "student") { + const students = res.data.cohort.profiles.filter((userid) => userid?.role?.name === "ROLE_STUDENT"); + return students; + } +} + +async function updateUserProfile(userId, formValues) { + const payload = { + photo: formValues.photo || "", + first_name: formValues.firstName || "", + last_name: formValues.lastName || "", + username: formValues.username || "", + github_username: formValues.githubUsername || "", + email: formValues.email || "", + mobile: formValues.mobile || "", + password: formValues.password || "", + bio: formValues.bio || "" + }; + + const token = localStorage.getItem('token'); + const decoded = jwt_decode(token); + const role = decoded?.roleId; // eller hent fra formData.profile.role.name hvis du har det + console.log(role) + let endpoint = ''; + if (role === 2) { + endpoint = `students/${userId}`; + } else { + endpoint = `teachers/${userId}`; // for ROLE_TEACHER og andre + } + + return await patch(endpoint, payload); +} async function post(endpoint, data, auth = true) { return await request('POST', endpoint, data, auth); } +async function postTo(endpoint, data, auth = true) { + return await request('POST', endpoint, data, auth); +} +async function del(endpoint, data, auth = true) { + return await request('DELETE', endpoint, data, auth); +} +async function put(endpoint, data, auth = true) { + return await request('PUT', endpoint, data, auth); +} async function patch(endpoint, data, auth = true) { return await request('PATCH', endpoint, data, auth); @@ -49,7 +197,16 @@ async function request(method, endpoint, data, auth = true) { const response = await fetch(`${API_URL}/${endpoint}`, opts); + if (!response.ok) { + const error = new Error(response.message || `Request failed with status ${response.status}`); + error.status = response.status; + throw error; + } + return response.json(); } -export { login, getPosts, register, createProfile }; + +export { login, getPosts, register, createProfile, get, getUserById, getComments, post, patch, put, getMyCohortProfiles, updateUserProfile, postTo, del, refreshToken, createNewStudent }; + + diff --git a/src/service/mockData.js b/src/service/mockData.js index d49e98a4..6b93d5f8 100644 --- a/src/service/mockData.js +++ b/src/service/mockData.js @@ -5,8 +5,8 @@ const user = { email: 'test@email.com', cohortId: 1, role: 'STUDENT', - firstName: 'Joe', - lastName: 'Bloggs', + first_name: 'Joe', + last_name: 'Bloggs', bio: 'Lorem ipsum dolor sit amet.', githubUrl: 'https://github.com/vherus' } @@ -23,8 +23,8 @@ const posts = [ id: 1, cohortId: 1, role: 'STUDENT', - firstName: 'Sam', - lastName: 'Fletcher', + first_name: 'Sam', + last_name: 'Fletcher', bio: 'Lorem ipsum dolor sit amet.', githubUrl: 'https://github.com/vherus' } @@ -39,8 +39,8 @@ const posts = [ id: 2, cohortId: 1, role: 'STUDENT', - firstName: 'Dolor', - lastName: 'Lobortis', + first_name: 'Dolor', + last_name: 'Lobortis', bio: 'Lorem ipsum dolor sit amet.', githubUrl: 'https://github.com/vherus' }, diff --git a/src/styles/_buttons.css b/src/styles/_buttons.css index fc31b363..2a1b2c0a 100644 --- a/src/styles/_buttons.css +++ b/src/styles/_buttons.css @@ -13,6 +13,7 @@ button:disabled:hover { background-color: var(--color-offwhite) !important; color: var(--color-blue1) !important; } + button:not(.formbutton):not(.socialbutton), .button { font-family: 'Lexend'; @@ -31,7 +32,7 @@ button:not(.formbutton):not(.socialbutton), @media only screen and (min-width: 568px) { button:not(.formbutton):not(.socialbutton):not(.width-full), .button:not(.formbutton):not(.socialbutton):not(.width-full) { - max-width: 200px; + max-width: 10000px; } } diff --git a/src/styles/_fonts.css b/src/styles/_fonts.css index 2ca28cb2..2dc1eb0e 100644 --- a/src/styles/_fonts.css +++ b/src/styles/_fonts.css @@ -9,3 +9,4 @@ font-style: normal; font-display: swap; } + diff --git a/src/styles/_form.css b/src/styles/_form.css index 419cfb63..b8edcff3 100644 --- a/src/styles/_form.css +++ b/src/styles/_form.css @@ -6,6 +6,9 @@ form input { font-size: 18px; line-height: 1.33; width: 100%; + font-family: 'Lexend', sans-serif; + + } form input:focus { border: 1px solid var(--color-blue2); @@ -19,6 +22,7 @@ form label { .inputwrapper { position: relative; + } .input-icon { @@ -26,11 +30,21 @@ form label { left: 16px; padding-top: 15px; } +.input-icon-right { + position: absolute; + right: 20px; + padding-top: 15px; +} .input-has-icon { padding-left: 56px; padding-right: 10px; } +.input-has-icon-right { + padding-right: 10px; + cursor: auto; +} + .showpasswordbutton { position: absolute; bottom: 28px; diff --git a/src/styles/_globals.css b/src/styles/_globals.css index eb9772c6..9539bdef 100644 --- a/src/styles/_globals.css +++ b/src/styles/_globals.css @@ -35,6 +35,7 @@ body { sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + background-color: var(--color-offwhite); } code { diff --git a/src/styles/_text.css b/src/styles/_text.css index 29f78613..b673e755 100644 --- a/src/styles/_text.css +++ b/src/styles/_text.css @@ -42,4 +42,4 @@ p { .text-large { font-size: 20px; line-height: 28px; -} +} \ No newline at end of file