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..7f440adf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,17 +11,20 @@ "@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", @@ -5576,6 +5579,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", @@ -7360,9 +7372,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": { @@ -12246,7 +12258,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 +14987,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 +15106,19 @@ } } }, + "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", @@ -17103,6 +17138,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..80a21787 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,15 @@ "@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 +46,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..dc1bfc18 100644 --- a/public/index.html +++ b/public/index.html @@ -2,7 +2,7 @@ - + - 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..fdc436b8 100644 --- a/src/App.js +++ b/src/App.js @@ -7,37 +7,104 @@ 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'; 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/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..97142a34 --- /dev/null +++ b/src/components/comment/dropdown/menu/index.js @@ -0,0 +1,81 @@ +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'; + +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..4dc33789 100644 --- a/src/components/comment/index.js +++ b/src/components/comment/index.js @@ -1,10 +1,44 @@ -const Comment = ({ name, content }) => { +import MenuComment from './dropdown'; +import { useComments } from '../../context/comments'; +import './style.css'; + +const Comment = ({ id,userId, name, content, postId, commentId, onCommentDeleted }) => { + const { deleteComment } = useComments(); + + const initials = name?.match(/\b(\w)/g); + + 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..9b3b584f --- /dev/null +++ b/src/components/createComment/index.js @@ -0,0 +1,126 @@ +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'; + +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?.id || Date.now(), + body: commentText, + user: { + id: userId, + profile: { + firstName, + lastName + } + }, + 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..bf0439df --- /dev/null +++ b/src/components/createComment/style.css @@ -0,0 +1,97 @@ +.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 +} + +.create-comment__input { + width: 100%; + padding: 12px 60px 12px 16px; + border: none; + border-radius: 9px; + background-color: #e6ebf5; + font-size: 14px; + outline: none; + transition: background-color #e6ebf5 ease; + color: #1c1e21; +} + +.create-comment__input:focus { + background-color: #e6ebf5; +} + +.create-comment__input::placeholder { + color: #64648c; +} + +.create-comment__submit { + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); + width: 36px; + height: 36px; + border: none; + border-radius: 50%; + background-color: #e6ebf5; + color: white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + opacity: 0.6; +} + +.create-comment__submit:enabled { + background-color: #e6ebf5; + color: white; + opacity: 1; +} + +.create-comment__submit:enabled:hover { + background-color: #e6ebf5; +} + +.create-comment__submit:disabled { + background-color: #e4e6ea; + color: #e6ebf5; + 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: #e6ebf5; +} \ No newline at end of file diff --git a/src/components/createPostModal/index.js b/src/components/createPostModal/index.js index f19e5f1d..e5eb0c0d 100644 --- a/src/components/createPostModal/index.js +++ b/src/components/createPostModal/index.js @@ -1,37 +1,114 @@ 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'; -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 + } + }, + 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

+ + {/* TODO: TO THIS SO THAT IT WORKS WIHT CORRECT NAMES */} +

{initials}

-

Alex J

+

{fullName}

+{/* + */}
@@ -43,7 +120,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..b77618e3 --- /dev/null +++ b/src/components/editCommentModal/index.js @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import useModal from '../../hooks/useModal'; +import './style.css'; +import Button from '../button'; +import jwt_decode from 'jwt-decode'; +import useAuth from '../../hooks/useAuth'; +import { put } from '../../service/apiClient'; + + + +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..3d190737 --- /dev/null +++ b/src/components/editIconCohortTeacher/cascadingMenuCohort/index.js @@ -0,0 +1,20 @@ + +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" /> + } 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..2cbc635b --- /dev/null +++ b/src/components/editIconCourse/cascadingMenuCourse/index.js @@ -0,0 +1,22 @@ + +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" + +const CascadingMenuCourse = () => { + return ( + <> + + } text="Add student to cohort" linkTo="/cohorts/add" /> + } text = "Add new student"/> + } text="Edit cohort"/> + } text="Delete cohort"/> + + + ) +} + +export default CascadingMenuCourse \ No newline at end of file diff --git a/src/components/editIconCourse/index.js b/src/components/editIconCourse/index.js new file mode 100644 index 00000000..f34f9cd2 --- /dev/null +++ b/src/components/editIconCourse/index.js @@ -0,0 +1,42 @@ +import { useState, useRef, useEffect } from 'react'; +import './style.css'; +import CascadingMenuCourse from './cascadingMenuCourse'; + + +const EditIconCouse = ({ 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 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..bbc74c3d 100644 --- a/src/components/editPostModal/index.js +++ b/src/components/editPostModal/index.js @@ -2,33 +2,57 @@ import { useState } from 'react'; import useModal from '../../hooks/useModal'; import './style.css'; import Button from '../button'; +import jwt_decode from 'jwt-decode'; +import useAuth from '../../hooks/useAuth'; +import { put } from '../../service/apiClient'; -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); 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..55de4189 100644 --- a/src/components/form/textInput/index.js +++ b/src/components/form/textInput/index.js @@ -1,8 +1,8 @@ import { useState } from 'react'; -const TextInput = ({ value, onChange, name, label, icon, type = 'text' }) => { - const [input, setInput] = useState(''); +const TextInput = ({ value, onChange, name, label, icon,iconRight, type = 'text', placeholder, readOnly = false }) => { const [showpassword, setShowpassword] = useState(false); + const [input, setInput] = useState(value); if (type === 'password') { return (
@@ -11,10 +11,12 @@ const TextInput = ({ value, onChange, name, label, icon, type = 'text' }) => { type={type} name={name} value={value} + placeholder = {placeholder} onChange={(e) => { onChange(e); - setInput(e.target.value); + setInput(e.target.value) }} + readOnly={readOnly} /> {showpassword && }
); } -}; +} const EyeLogo = () => { return ( 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..384877a8 --- /dev/null +++ b/src/components/fullscreenCard/index.js @@ -0,0 +1,76 @@ +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 ProfileCircle from '../../components/profileCircle'; +import '../../pages/loading'; + +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; + + 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; + + + return ( +
+
n[0]).join("").toUpperCase()}/> +
+

{name}

+
+ +
+
+ + +
+ ); +}; + +export default FullScreenCard; diff --git a/src/components/header/index.js b/src/components/header/index.js index c591f1e1..d4a9fe0f 100644 --- a/src/components/header/index.js +++ b/src/components/header/index.js @@ -3,14 +3,30 @@ 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'; + 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 +36,17 @@ const Header = () => { return null; } + let userIdFromToken = null; + + const decoded = jwtDecode(token || localStorage.getItem('token')); + userIdFromToken = decoded.userId; + return ( -
+
-

AJ

+

{initials}

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

AJ

+

{initials}

-

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..aadefacc 100644 --- a/src/components/menu/menuItem/index.js +++ b/src/components/menu/menuItem/index.js @@ -1,7 +1,133 @@ 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'; + +const MenuItem = ({ icon, text, children, linkTo = '#nogo', clickable, postText, postId, name, isMenuVisible, setIsMenuVisible, commentText, commentId, onCommentDeleted, onPostDeleted }) => { + const { openModal, setModal, closeModal } = useModal(); + const { deletePost } = usePosts(); + const { deleteComment } = useComments(); + + const showModal = () => { + setModal('Edit post', ); + setIsMenuVisible(false); + openModal(); + }; + + const showCommentModal = () => { + setModal('Edit comment', ); + setIsMenuVisible(false); + openModal(); + }; + + const handleDeletePost = async () => { + setIsMenuVisible(false); + console.log('deletePost function called'); + setModal(`The post is being deleted!`, + <> +

    The post is being deleted!

    + + ); + openModal(); + + setTimeout(() => { + closeModal(); + }, 2500); + try { + const success = await deletePost(postId); + + + if (success) { + console.log('Post deleted successfully'); + + } else { + console.error('Failed to delete post'); + } + } catch (error) { + console.error('Error deleting post:', error); + } + }; + + const handleDeleteComment = async () => { + setIsMenuVisible(false); + console.log('deleteComment function called'); + + // If there's a callback provided, use it instead of calling the API directly + if (onCommentDeleted) { + onCommentDeleted(commentId); + return; + } + + // Only call the API directly if no callback is provided + 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); + } + }; + + const handleReport = () => { + setIsMenuVisible(false); + console.log('reportComment function called, and reported'); + setModal(`Reported`, + <> + +

    Thank you for reporting this. Our team will review it shortly.

    + + ); + setIsMenuVisible(false); + openModal(); + + setTimeout(() => { + closeModal(); + }, 1500); + + }; + + 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; + default: + return undefined; + } + }; + + + + + + if (clickable) { + return ( +
  • + + {children &&
      {children}
    } +
  • + ); + } -const MenuItem = ({ icon, text, children, linkTo = '#nogo' }) => { return (
  • 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..d18299bd --- /dev/null +++ b/src/components/post/dropdown/menu/index.js @@ -0,0 +1,40 @@ +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'; + +const ProfileCirclePost = ({ initials, menuVisible, postText, postId, name }) => { + const [isMenuVisible, setIsMenuVisible] = useState(menuVisible || false); + + + + return ( +
    setIsMenuVisible(!isMenuVisible)}> + {isMenuVisible && } + +
    +

    {initials}

    +
    +
    + ); +}; + +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..2caf22d0 100644 --- a/src/components/post/index.js +++ b/src/components/post/index.js @@ -1,55 +1,203 @@ -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'; + +const Post = ({ post }) => { + 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 authorName = post.user.profile + ? `${post.user.profile.firstName || 'Unknown'} ${post.user.profile.lastName || 'User'}` + : 'Unknown User'; + const userInitials = authorName.match(/\b(\w)/g); + + 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 (
    -
    - +
    + -
    -

    {name}

    - {date} +
    +

    {authorName}

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

    Edited

    )} +
    + +
    -
    -

    ...

    -
    -
    - -
    -

    {content}

    +
    +

    {post.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..827bf876 100644 --- a/src/components/post/style.css +++ b/src/components/post/style.css @@ -1,50 +1,159 @@ .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; +.post__meta { + display: grid; + row-gap: 2px; } -.post-user-name p { +.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__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; } -.post-interactions { +.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..a1523f54 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, 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..c08abfa2 --- /dev/null +++ b/src/components/profile-icon-searchStudentView/index.js @@ -0,0 +1,82 @@ + +import Popup from 'reactjs-popup'; +import './style.css'; +import SeeProfile from '../seeProfile'; +import { useNavigate } from 'react-router-dom'; +const UserIconStudentView = ({ id, initials, firstname, lastname, role}) => { + 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..06c672ec --- /dev/null +++ b/src/components/profile-icon-searchTeacherView/cascadinuMenuSearch/index.js @@ -0,0 +1,22 @@ + +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" + +const CascadingMenuSearch = () => { + return ( + <> + + } text="Profile" /> + } text = "Add note"/> + } text="Move to cohort"/> + } text="Delete student"/> + + + ) +} + +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..f1fa0044 --- /dev/null +++ b/src/components/profile-icon-searchTeacherView/index.js @@ -0,0 +1,89 @@ + +import './style.css'; +import { useNavigate } from 'react-router-dom'; +import CascadingMenuSearch from './cascadinuMenuSearch'; +import { useEffect, useRef, useState } from 'react'; +const UserIconTeacherView = ({ id, initials, firstname, lastname, role, menuVisible}) => { + + 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 ( +
+ +
+
+

{initials}

+
+
+
+

{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..50535c8d --- /dev/null +++ b/src/components/profile-icon-teacherView/editIconTeacher/index.js @@ -0,0 +1,40 @@ +import { useState, useRef, useEffect } from 'react'; +import { CascadingMenu } from '../../profileCircle'; + +const EditIconTeacher = ({ id, 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 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..68dffe59 --- /dev/null +++ b/src/components/profile-icon-teacherView/index.js @@ -0,0 +1,52 @@ +import EditIconTeacher from './editIconTeacher'; +import './style.css'; + + +const ProfileIconTeacher = ({id, initials, firstname, lastname, role}) => { + + 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..e44613d1 --- /dev/null +++ b/src/components/profile-icon/index.js @@ -0,0 +1,73 @@ + +import Popup from 'reactjs-popup'; +import './style.css'; +import SeeProfile from '../seeProfile'; +const UserIcon = ({ id, initials, firstname, lastname, role}) => { + + 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}

+
+ +
+ + β€’ + β€’ + β€’ + +
+
+ + } position="left center" + closeOnDocumentClick + arrow={false}> + + + + + ) +} + + +export default UserIcon; + + + \ No newline at end of file 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..2d7ef425 100644 --- a/src/components/profileCircle/index.js +++ b/src/components/profileCircle/index.js @@ -10,12 +10,12 @@ import Menu from '../menu'; import MenuItem from '../menu/menuItem'; import './style.css'; -const ProfileCircle = ({ initials }) => { - const [isMenuVisible, setIsMenuVisible] = useState(false); +const ProfileCircle = ({ id, initials, menuVisible }) => { + const [isMenuVisible, setIsMenuVisible] = useState(menuVisible || false); return (
setIsMenuVisible(!isMenuVisible)}> - {isMenuVisible && } + {isMenuVisible && }

{initials}

@@ -24,10 +24,11 @@ const ProfileCircle = ({ initials }) => { ); }; -const CascadingMenu = () => { +export const CascadingMenu = ({ id }) => { + return ( - } text="Profile" /> + } text="Profile" linkTo={`/profile/${id}`} /> } text="Add note" /> } text="Move to cohort"> diff --git a/src/components/profileCircle/style.css b/src/components/profileCircle/style.css index 47391fe7..9d9da818 100644 --- a/src/components/profileCircle/style.css +++ b/src/components/profileCircle/style.css @@ -5,4 +5,4 @@ .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..fac80f83 --- /dev/null +++ b/src/components/seeProfile/index.js @@ -0,0 +1,37 @@ +import Card from '../card'; +import './style.css'; +import { NavLink } from 'react-router-dom'; +import ProfileIcon from '../../assets/icons/profileIcon'; + + +const SeeProfile = ({initials, firstname, lastname, role, id}) => { + return ( +
+ +
+
+

{initials}

+
+ +
+

{firstname} {lastname}

+ {role}, Cohort 3 +
+
+ +
+
    +
  • + +

    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/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..3f745c15 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..f9469c1e 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 { createProfile, login, register, refreshToken } from '../service/apiClient'; // eslint-disable-next-line camelcase import jwt_decode from 'jwt-decode'; @@ -19,7 +19,7 @@ const AuthProvider = ({ children }) => { useEffect(() => { const storedToken = localStorage.getItem('token'); - if (storedToken) { + if (storedToken && !token) { setToken(storedToken); navigate(location.state?.from?.pathname || '/'); } @@ -34,8 +34,8 @@ 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 || '/'); }; const handleLogout = () => { @@ -43,20 +43,65 @@ const AuthProvider = ({ children }) => { setToken(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); - - localStorage.setItem('token', token); - navigate('/'); + 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 value = { @@ -64,7 +109,8 @@ const AuthProvider = ({ children }) => { onLogin: handleLogin, onLogout: handleLogout, onRegister: handleRegister, - onCreateProfile: handleCreateProfile + onCreateProfile: handleCreateProfile, + forceTokenRefresh }; 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/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/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/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..31108a61 --- /dev/null +++ b/src/pages/addStudent/index.js @@ -0,0 +1,168 @@ +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"; + + +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 [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"); + console.log(response) + setCourses(response.data.courses); + } catch (error) { + console.error("Error fetching courses:", error); + } + } + fetchStudents(); + fetchCourses() + }, []); + + + + + const handleSelectStudent = (student) => { + console.log("Selected student:", student); + setIsOpenStudents(false); + setSelectedStudent(student) + }; + + + const handleSelectCourse = (course) => { + console.log("selected course" + course) + setIsOpenCourses(false) + setSelectedCourse(course) + setCohorts(course.cohorts) + } + + const handleSelectCohort = (cohort) => { + console.log("selected course" + cohort) + setIsOpenCohorts(false) + setSelectedCohort(cohort) + } + + const handleAdd = () => { + async function addStudentToCohort() { + try { + console.log(selectedCohort.id) + 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() + navigate(-1) + + + } + return ( + <> +
+
+

Add student to cohort

+
+ +
+
+

Add a student to an existing cohort

+
+ + + + +
+

Or

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

Add to

+
+ + +
+ + {isOpenCourses && ()} + +
+ + +
+ + {isOpenCohorts && ()} + + + + +
+ +
+ + +
+ +
+
+

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..e928bb2f --- /dev/null +++ b/src/pages/addStudent/searchBar/index.js @@ -0,0 +1,83 @@ +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 ( + <> +
+
+ } + value={query} + name="Search" + onChange={(e) => setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSubmit(e); + }} + /> + + + {isOpen && ( +
+ {searchResults.length > 0 ? ( + + ) : ( +

No students with this name found

+ )} +
+ )} +
+ + ) +} + +export default SearchBar \ 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..ddfbc6cf --- /dev/null +++ b/src/pages/addStudent/style.css @@ -0,0 +1,212 @@ +.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; +} + +.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; +} + + +.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; +} \ 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..ad4f9917 --- /dev/null +++ b/src/pages/cohort/index.js @@ -0,0 +1,152 @@ +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([]) + + 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(); + }, []); + + + 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); + 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 || []); + + // students + const students = data.data.cohort.profiles.filter((profileid) => profileid?.role?.name === "ROLE_STUDENT"); + setStudents(students || []); + + // 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/students/index.js b/src/pages/cohort/students/index.js new file mode 100644 index 00000000..69d02185 --- /dev/null +++ b/src/pages/cohort/students/index.js @@ -0,0 +1,65 @@ +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.startDate} - ${cohort.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..51277c6b --- /dev/null +++ b/src/pages/cohort/students/student/index.js @@ -0,0 +1,19 @@ +import UserIcon from "../../../../components/profile-icon"; + +const Student = ({ id, initials, firstName, lastName, role }) => { + 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..2250c10c --- /dev/null +++ b/src/pages/cohort/teacherCohort/cohortsList/index.js @@ -0,0 +1,59 @@ + +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"; + + +const CohortsList= ({ onSelect, setSelectedCohort , cohorts}) => { + const [selectedCohortId, setSelectedCohortId] = useState(null); + + + const handleClick = (cohort) => { + setSelectedCohortId(cohort.id); + setSelectedCohort(cohort) + 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 {cohort.id}
    +
    +
  • + )) + } +
+ ); +}; + +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..25b2f0f6 --- /dev/null +++ b/src/pages/cohort/teacherCohort/index.js @@ -0,0 +1,102 @@ +import { 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" + + +const TeacherCohort = ({cohorts}) => { + const [searchVal, setSearchVal] = useState(''); + const [selectedProfiles, setSelectedProfiles] = useState([]); + const[selectedCohort, setSelectedCohort] = useState(null); + const navigate = useNavigate() + + + + const onChange = (e) => { + setSearchVal(e.target.value); + }; + + + return ( + <> + {cohorts.length > 0 ? (
+
+
+

Cohorts

+

Students

+
+ +
e.preventDefault()}> + } value={searchVal} name="Search" onChange={onChange} /> + +
+ + +
+
+
+
+ +
+
+ +
+
+ +
+ + +
+ setSelectedProfiles(profiles)} /> +
+
+ +
+
+
+ {selectedCohort !== null ? ( + <> + + + ): (<>

Select a course

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

Loading...

+
+ +
+
+
+ )} + + + ) +} + +export default TeacherCohort \ No newline at end of file diff --git a/src/pages/cohort/teacherCohort/studentList/index.js b/src/pages/cohort/teacherCohort/studentList/index.js new file mode 100644 index 00000000..efa362a4 --- /dev/null +++ b/src/pages/cohort/teacherCohort/studentList/index.js @@ -0,0 +1,21 @@ +import ProfileIconTeacher from "../../../../components/profile-icon-teacherView"; + +const StudentList = ({ profiles }) => { + 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..7efe1ed6 --- /dev/null +++ b/src/pages/cohort/teacherCohort/style.css @@ -0,0 +1,191 @@ + +.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; +} \ 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..8be4ebd1 --- /dev/null +++ b/src/pages/cohort/teachers/index.js @@ -0,0 +1,31 @@ +import Card from "../../../components/card"; +import './style.css'; +import Teacher from "./teacher"; + + +const Teachers = ({ teachers, getInitials }) => { + + return ( + +
+
+

Teachers

+
+ +
+ {teachers.map((teacher, index) => ( + + ))} +
+
+
+ ); +} + +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..1311a5ad --- /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 #ccc; + 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..00ca77fa --- /dev/null +++ b/src/pages/cohort/teachers/teacher/index.js @@ -0,0 +1,19 @@ +import UserIcon from "../../../../components/profile-icon"; + +const Teacher = ({ initials, firstName, lastName, role }) => { + + 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..e741f805 100644 --- a/src/pages/dashboard/index.js +++ b/src/pages/dashboard/index.js @@ -1,19 +1,84 @@ -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'; + 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); + const data = await get(`cohorts/${user.profile.cohort.id}`); + + setCohort(data.data.cohort) + setCourse(data.data.cohort); + 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,14 +92,47 @@ const Dashboard = () => { openModal(); }; + 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(); + }, []); + return ( <>
-

AJ

+

{initials}

+
@@ -43,15 +141,37 @@ const Dashboard = () => {
); diff --git a/src/pages/dashboard/search/index.js b/src/pages/dashboard/search/index.js new file mode 100644 index 00000000..01200646 --- /dev/null +++ b/src/pages/dashboard/search/index.js @@ -0,0 +1,112 @@ +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, index) => ( +
  • + +
  • + ))} +
+ ) : ( +

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..2df3b589 --- /dev/null +++ b/src/pages/dashboard/students/index.js @@ -0,0 +1,76 @@ +import { useEffect, useState } from "react"; +import { get } from "../../../service/apiClient"; + +import { useNavigate } from "react-router-dom"; +import Card from "../../../components/card" +import UserIcon from "../../../components/profile-icon"; +import ProfileIconTeacher from "../../../components/profile-icon-teacherView"; + +const Students = () => { + 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(); + }, []); + + const navigate = useNavigate() + + const handleClick = () => { + navigate("/") + // navigate("/students") + } + + return( + <> + +

Students

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

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..2e3bef39 100644 --- a/src/pages/dashboard/style.css +++ b/src/pages/dashboard/style.css @@ -19,3 +19,109 @@ 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 #ccc; + 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; +} \ 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..a9dc5a39 --- /dev/null +++ b/src/pages/dashboard/teachers/index.js @@ -0,0 +1,59 @@ +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?.slice(0,10).map((teacher, index) => ( +
  • +
    + word[0].toUpperCase()) + .join('')} + firstname={teacher.firstName} + lastname={teacher.lastName} + /> +
    +
  • + ))} +
+ ) : ( +
+

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..6d9701c5 --- /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: 10px; + 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..e29e142a --- /dev/null +++ b/src/pages/edit/index.js @@ -0,0 +1,356 @@ +import { useEffect, useState } from "react"; +import "./edit.css"; +import Popup from "reactjs-popup"; +import imageCompression from "browser-image-compression"; +import { getUserById, updateUserProfile } from "../../service/apiClient"; +import useAuth from "../../hooks/useAuth"; +import jwtDecode from "jwt-decode"; +import TextInput from "../../components/form/textInput"; +import ProfileCircle from "../../components/profileCircle"; +import Card from "../../components/card"; +import { validatePassword, validateEmail } from '../register'; +import LockIcon from '../../assets/icons/lockIcon'; + +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: "", + 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 { + await updateUserProfile(userId, updatedValues); + alert("Profile is updated!"); + const refreshed = await getUserById(userId); + setFormData(refreshed); + setFormValues(prev => ({ ...prev, photo: refreshed.profile.photo || prev.photo })); + } 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' && ( + } + iconRight={true} + /> + )} +
+
+ +
+
+

Contact Info

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

Bio

+

+ +
{formValues.bio.length}/300
+
+
+ +
+ + + +
+
+
+ + ); +}; + +export default EditPage; 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..c52e8af2 --- /dev/null +++ b/src/pages/profile/profile-data/index.js @@ -0,0 +1,91 @@ +import './profile-data.css' + +const ProfileData = ({ user }) => { + const {email} = user; + const roleName = user.profile.role.name; + const { firstName, lastName, githubUrl, mobile, specialism, bio, photo } = user.profile; + + const getReadableRole = (role) => { + switch (role) { + case 'ROLE_STUDENT': + return 'Student'; + case 'ROLE_TEACHER': + return 'Teacher'; + case 'ROLE_ADMIN': + return 'Administrator' + default: + return role; + } + }; + + return ( +
+
+ + {(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..a04d18f1 --- /dev/null +++ b/src/pages/profile/profile-data/profile-data.css @@ -0,0 +1,66 @@ +.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; +} + +.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 /> +
diff --git a/src/pages/search/index.js b/src/pages/search/index.js new file mode 100644 index 00000000..6763edd1 --- /dev/null +++ b/src/pages/search/index.js @@ -0,0 +1,146 @@ +import { useState } from "react"; +import { get } from "../../service/apiClient"; +import Card from "../../components/card"; +import TextInput from "../../components/form/textInput"; +import SearchIcon from "../../assets/icons/searchIcon"; +import './style.css'; +import ArrowBack from "../../assets/icons/arrowBack"; +import { useNavigate } from "react-router-dom"; +import { useSearchResults } from "../../context/searchResults"; +import UserIconTeacherView from "../../components/profile-icon-searchTeacherView"; +import UserIconStudentView from "../../components/profile-icon-searchStudentView"; +import { useUserRoleData } from "../../context/userRole."; +import useAuth from "../../hooks/useAuth"; +import jwtDecode from "jwt-decode"; + +const SearchPage = () => { + const [query, setQuery] = useState(""); + const [newresults, setNewResults] = useState(null); + const {searchResults} = useSearchResults(); + const navigate = useNavigate(); + const { token } = useAuth(); + + + // 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); + } + + const { userRole, setUserRole } = useUserRoleData(); + setUserRole(decodedToken.roleId) + + const handleGoBack = () => { + navigate("/"); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!query.trim()) return; + try { + const response = await get(`search/profiles/${query}`); + setNewResults(response.data.profiles); + } catch (error) { + console.error("Error fetching search results:", error); + } + }; + + return ( +
+ +
+
+ +

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..fb7ee354 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: 'Software Development', + cohort: 1, + start_date: '2025-09-14', + end_date: '2025-10-15', + 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..8577c8b2 --- /dev/null +++ b/src/pages/welcome/stepThree/index.js @@ -0,0 +1,61 @@ +import LockIcon from '../../../assets/icons/lockIcon'; +import Form from '../../../components/form'; +import TextInput from '../../../components/form/textInput'; + +const StepThree = ({ data, setData }) => { + + return ( + <> +
+

Training info

+
+ +
+ + } + iconRight={true} + /> + } + iconRight={true} + /> + } + iconRight={true} + /> + } + 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..16604ca8 100644 --- a/src/pages/welcome/style.css +++ b/src/pages/welcome/style.css @@ -25,9 +25,10 @@ .welcome-form-inputs { display: grid; grid-template-rows: repeat(auto, auto); - gap: 58px; + gap: 5px; margin-bottom: 48px; } + .welcome-form-popup-wrapper { width: 470px; max-width: 100%; @@ -42,3 +43,33 @@ 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; +} diff --git a/src/service/apiClient.js b/src/service/apiClient.js index 5f3cdbcf..f813c4a2 100644 --- a/src/service/apiClient.js +++ b/src/service/apiClient.js @@ -1,26 +1,126 @@ 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 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 || "" + }; + + return await patch(`students/${userId}`, 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 +149,18 @@ 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 }; + + 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..f1d8f646 100644 --- a/src/styles/_buttons.css +++ b/src/styles/_buttons.css @@ -31,7 +31,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: 1000px; } } diff --git a/src/styles/_form.css b/src/styles/_form.css index 419cfb63..ed1a1ffe 100644 --- a/src/styles/_form.css +++ b/src/styles/_form.css @@ -26,11 +26,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 {