Auth redirects, language switch, basic profile

This commit is contained in:
Kilian Hofmann 2024-07-26 01:14:12 +02:00
parent 2091bdb4e3
commit 36a4659915
31 changed files with 5638 additions and 331 deletions

1
exam/dist/assets/index-BF6aZoB4.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

102
exam/dist/assets/vendor_mui-DqRwP0O9.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,11 @@
<link rel="icon" type="image/svg+xml" href="/phpCourse/exam/dist/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/phpCourse/exam/dist/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title> <title>Vite + React + TS</title>
<script type="module" crossorigin src="/phpCourse/exam/dist/assets/index-BJXidBC9.js"></script> <script type="module" crossorigin src="/phpCourse/exam/dist/assets/index-BF6aZoB4.js"></script>
<link rel="stylesheet" crossorigin href="/phpCourse/exam/dist/assets/index-D83Ey19k.css"> <link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/vendor_mui-DqRwP0O9.js">
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/vendor_react-BnzSvkeI.js">
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/vendor_tanstack-BjOH7Cun.js">
<link rel="stylesheet" crossorigin href="/phpCourse/exam/dist/assets/vendor_react-D83Ey19k.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -12,5 +12,11 @@
"Log in": "Anmelden", "Log in": "Anmelden",
"Log out": "Abmelden", "Log out": "Abmelden",
"Profile": "Profil" "Profile": "Profil",
"Updating": "Aktualisiert",
"Username": "Benutzername",
"Member since": "Mitglied seit",
"Post count": "Anzahl Posts"
} }

View File

@ -12,5 +12,11 @@
"Log in": "Log in", "Log in": "Log in",
"Log out": "Log out", "Log out": "Log out",
"Profile": "Profile" "Profile": "Profile",
"Updating": "Updating",
"Username": "Username",
"Member since": "Member since",
"Post count": "Post count"
} }

4842
exam/dist/stats.html vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -43,6 +43,7 @@
"eslint-plugin-react-refresh": "^0.4.7", "eslint-plugin-react-refresh": "^0.4.7",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^3.2.4", "prettier-plugin-organize-imports": "^3.2.4",
"rollup-plugin-visualizer": "^5.0.0",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^5.3.4" "vite": "^5.3.4"
} }

View File

@ -102,6 +102,9 @@ importers:
prettier-plugin-organize-imports: prettier-plugin-organize-imports:
specifier: ^3.2.4 specifier: ^3.2.4
version: 3.2.4(prettier@3.3.3)(typescript@5.5.3) version: 3.2.4(prettier@3.3.3)(typescript@5.5.3)
rollup-plugin-visualizer:
specifier: ^5.0.0
version: 5.12.0(rollup@4.18.1)
typescript: typescript:
specifier: ^5.2.2 specifier: ^5.2.2
version: 5.5.3 version: 5.5.3
@ -1020,6 +1023,10 @@ packages:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'} engines: {node: '>= 8.10.0'}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
clsx@2.1.1: clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -1091,6 +1098,10 @@ packages:
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
define-lazy-prop@2.0.0:
resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
engines: {node: '>=8'}
dir-glob@3.0.1: dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -1105,6 +1116,9 @@ packages:
electron-to-chromium@1.4.830: electron-to-chromium@1.4.830:
resolution: {integrity: sha512-TrPKKH20HeN0J1LHzsYLs2qwXrp8TF4nHdu4sq61ozGbzMpWhI7iIOPYPPkxeq1azMT9PZ8enPFcftbs/Npcjg==} resolution: {integrity: sha512-TrPKKH20HeN0J1LHzsYLs2qwXrp8TF4nHdu4sq61ozGbzMpWhI7iIOPYPPkxeq1azMT9PZ8enPFcftbs/Npcjg==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
error-ex@1.3.2: error-ex@1.3.2:
resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
@ -1237,6 +1251,10 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-intrinsic@1.2.4: get-intrinsic@1.2.4:
resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -1359,10 +1377,19 @@ packages:
resolution: {integrity: sha512-Dd+Lb2/zvk9SKy1TGCt1wFJFo/MWBPMX5x7KcvLajWTGuomczdQX61PvY5yK6SVACwpoexWo81IfFyoKY2QnTA==} resolution: {integrity: sha512-Dd+Lb2/zvk9SKy1TGCt1wFJFo/MWBPMX5x7KcvLajWTGuomczdQX61PvY5yK6SVACwpoexWo81IfFyoKY2QnTA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
is-docker@2.2.1:
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
engines: {node: '>=8'}
hasBin: true
is-extglob@2.1.1: is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-generator-function@1.0.10: is-generator-function@1.0.10:
resolution: {integrity: sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==} resolution: {integrity: sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -1383,6 +1410,10 @@ packages:
resolution: {integrity: sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==} resolution: {integrity: sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
is-wsl@2.2.0:
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
engines: {node: '>=8'}
isexe@2.0.0: isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@ -1492,6 +1523,10 @@ packages:
once@1.4.0: once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
open@8.4.2:
resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
engines: {node: '>=12'}
optionator@0.9.4: optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@ -1626,6 +1661,10 @@ packages:
regenerator-runtime@0.14.1: regenerator-runtime@0.14.1:
resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
resolve-from@4.0.0: resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -1643,6 +1682,16 @@ packages:
deprecated: Rimraf versions prior to v4 are no longer supported deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true hasBin: true
rollup-plugin-visualizer@5.12.0:
resolution: {integrity: sha512-8/NU9jXcHRs7Nnj07PF2o4gjxmm9lXIrZ8r175bT9dK8qoLlvKTwRMArRCMgpMGlq8CTLugRvEmyMeMXIU2pNQ==}
engines: {node: '>=14'}
hasBin: true
peerDependencies:
rollup: 2.x || 3.x || 4.x
peerDependenciesMeta:
rollup:
optional: true
rollup@4.18.1: rollup@4.18.1:
resolution: {integrity: sha512-Elx2UT8lzxxOXMpy5HWQGZqkrQOtrVDDa/bm9l10+U4rQnVzbL/LgZ4NOM1MPIDyHk69W4InuYDF5dzRh4Kw1A==} resolution: {integrity: sha512-Elx2UT8lzxxOXMpy5HWQGZqkrQOtrVDDa/bm9l10+U4rQnVzbL/LgZ4NOM1MPIDyHk69W4InuYDF5dzRh4Kw1A==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@ -1704,6 +1753,10 @@ packages:
stream-slice@0.1.2: stream-slice@0.1.2:
resolution: {integrity: sha512-QzQxpoacatkreL6jsxnVb7X5R/pGw9OUv2qWTYWnmLpg4NdN31snPy/f3TdQE1ZUXaThRvj1Zw4/OGg0ZkaLMA==} resolution: {integrity: sha512-QzQxpoacatkreL6jsxnVb7X5R/pGw9OUv2qWTYWnmLpg4NdN31snPy/f3TdQE1ZUXaThRvj1Zw4/OGg0ZkaLMA==}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
strip-ansi@6.0.1: strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -1867,9 +1920,17 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
wrappy@1.0.2: wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yallist@3.1.1: yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@ -1877,6 +1938,14 @@ packages:
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
yocto-queue@0.1.0: yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -2871,6 +2940,12 @@ snapshots:
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 fsevents: 2.3.3
cliui@8.0.1:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
clsx@2.1.1: {} clsx@2.1.1: {}
color-convert@1.9.3: color-convert@1.9.3:
@ -2933,6 +3008,8 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
gopd: 1.0.1 gopd: 1.0.1
define-lazy-prop@2.0.0: {}
dir-glob@3.0.1: dir-glob@3.0.1:
dependencies: dependencies:
path-type: 4.0.0 path-type: 4.0.0
@ -2948,6 +3025,8 @@ snapshots:
electron-to-chromium@1.4.830: {} electron-to-chromium@1.4.830: {}
emoji-regex@8.0.0: {}
error-ex@1.3.2: error-ex@1.3.2:
dependencies: dependencies:
is-arrayish: 0.2.1 is-arrayish: 0.2.1
@ -3122,6 +3201,8 @@ snapshots:
gensync@1.0.0-beta.2: {} gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
get-intrinsic@1.2.4: get-intrinsic@1.2.4:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@ -3249,8 +3330,12 @@ snapshots:
dependencies: dependencies:
hasown: 2.0.2 hasown: 2.0.2
is-docker@2.2.1: {}
is-extglob@2.1.1: {} is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
is-generator-function@1.0.10: is-generator-function@1.0.10:
dependencies: dependencies:
has-tostringtag: 1.0.2 has-tostringtag: 1.0.2
@ -3267,6 +3352,10 @@ snapshots:
dependencies: dependencies:
which-typed-array: 1.1.15 which-typed-array: 1.1.15
is-wsl@2.2.0:
dependencies:
is-docker: 2.2.1
isexe@2.0.0: {} isexe@2.0.0: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
@ -3349,6 +3438,12 @@ snapshots:
dependencies: dependencies:
wrappy: 1.0.2 wrappy: 1.0.2
open@8.4.2:
dependencies:
define-lazy-prop: 2.0.0
is-docker: 2.2.1
is-wsl: 2.2.0
optionator@0.9.4: optionator@0.9.4:
dependencies: dependencies:
deep-is: 0.1.4 deep-is: 0.1.4
@ -3460,6 +3555,8 @@ snapshots:
regenerator-runtime@0.14.1: {} regenerator-runtime@0.14.1: {}
require-directory@2.1.1: {}
resolve-from@4.0.0: {} resolve-from@4.0.0: {}
resolve@1.22.8: resolve@1.22.8:
@ -3474,6 +3571,15 @@ snapshots:
dependencies: dependencies:
glob: 7.2.3 glob: 7.2.3
rollup-plugin-visualizer@5.12.0(rollup@4.18.1):
dependencies:
open: 8.4.2
picomatch: 2.3.1
source-map: 0.7.4
yargs: 17.7.2
optionalDependencies:
rollup: 4.18.1
rollup@4.18.1: rollup@4.18.1:
dependencies: dependencies:
'@types/estree': 1.0.5 '@types/estree': 1.0.5
@ -3542,6 +3648,12 @@ snapshots:
stream-slice@0.1.2: {} stream-slice@0.1.2: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
strip-ansi@6.0.1: strip-ansi@6.0.1:
dependencies: dependencies:
ansi-regex: 5.0.1 ansi-regex: 5.0.1
@ -3669,12 +3781,32 @@ snapshots:
word-wrap@1.2.5: {} word-wrap@1.2.5: {}
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrappy@1.0.2: {} wrappy@1.0.2: {}
y18n@5.0.8: {}
yallist@3.1.1: {} yallist@3.1.1: {}
yaml@1.10.2: {} yaml@1.10.2: {}
yargs-parser@21.1.1: {}
yargs@17.7.2:
dependencies:
cliui: 8.0.1
escalade: 3.1.2
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}
zod@3.23.8: {} zod@3.23.8: {}

View File

@ -12,5 +12,11 @@
"Log in": "Anmelden", "Log in": "Anmelden",
"Log out": "Abmelden", "Log out": "Abmelden",
"Profile": "Profil" "Profile": "Profil",
"Updating": "Aktualisiert",
"Username": "Benutzername",
"Member since": "Mitglied seit",
"Post count": "Anzahl Posts"
} }

View File

@ -12,5 +12,11 @@
"Log in": "Log in", "Log in": "Log in",
"Log out": "Log out", "Log out": "Log out",
"Profile": "Profile" "Profile": "Profile",
"Updating": "Updating",
"Username": "Username",
"Member since": "Member since",
"Post count": "Post count"
} }

View File

@ -6,8 +6,9 @@ const BASE = 'https://khofmann.userpage.fu-berlin.de/phpCourse/exam/api/';
let instance: ApiImpl; let instance: ApiImpl;
class ApiImpl { class ApiImpl {
//FIXME: PRIVATE when reauth token exists private token?: string;
public token?: string; private refreshToken?: string;
private self?: User;
constructor() { constructor() {
if (instance) { if (instance) {
@ -19,18 +20,26 @@ class ApiImpl {
} }
public hasAuth = () => this.token !== undefined; public hasAuth = () => this.token !== undefined;
public isAdmin = () => this.hasAuth() && this.self?.isAdmin;
public getAuthenticatedUser = () => this.self;
public getCurrentSession = () => [this.token, this.refreshToken];
//FIXME: Currently returns Auth token, switch to reauth token public logIn = async (email: string, password: string): Promise<void> => {
public logIn = async (email: string, password: string): Promise<[User, string]> => {
const { user, token } = await (await this.post('login', { email, password })).json(); const { user, token } = await (await this.post('login', { email, password })).json();
this.self = user;
this.isAdmin = user.isAdmin;
this.token = token; this.token = token;
return [user, token];
}; };
public logOut = async (): Promise<boolean> => { public logOut = async (): Promise<boolean> => {
this.token = undefined; try {
return await (await this.postAuth('logout')).json(); return await (await this.postAuth('logout')).json();
} catch {
return false;
} finally {
this.self = undefined;
this.token = undefined;
}
}; };
public posts = async (page?: number): Promise<PostListNonAuth | PostListAuth> => { public posts = async (page?: number): Promise<PostListNonAuth | PostListAuth> => {
@ -41,6 +50,10 @@ class ApiImpl {
return await (await this.get(url)).json(); return await (await this.get(url)).json();
}; };
public user = async (id?: number): Promise<User> => {
return await (await this.getAuth(`users/${id ?? this.self?.id}`)).json();
};
private post = async ( private post = async (
endpoint: string, endpoint: string,
body: Record<string, unknown> | undefined = undefined, body: Record<string, unknown> | undefined = undefined,

View File

@ -4,13 +4,14 @@ import { useRouter } from '@tanstack/react-router';
import { FC, useState } from 'react'; import { FC, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Api from '../../../api/Api'; import Api from '../../../api/Api';
import useGuestBookStore from '../../../store/store';
import handleError from '../../../utils/errors'; import handleError from '../../../utils/errors';
const Login: FC = () => { interface Props {
const [error, setError] = useState(); handleClose: () => void;
}
const setUser = useGuestBookStore((state) => state.setUser); const LoginForm: FC<Props> = ({ handleClose }) => {
const [error, setError] = useState();
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
@ -22,9 +23,9 @@ const Login: FC = () => {
}, },
onSubmit: async ({ value }) => { onSubmit: async ({ value }) => {
try { try {
const [user, token] = await Api.logIn(value.email, value.password); await Api.logIn(value.email, value.password);
setUser(user, token);
router.invalidate(); router.invalidate();
handleClose();
//eslint-disable-next-line @typescript-eslint/no-explicit-any //eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) { } catch (error: any) {
setError(error); setError(error);
@ -126,4 +127,4 @@ const Login: FC = () => {
); );
}; };
export default Login; export default LoginForm;

View File

@ -1,22 +1,20 @@
import { AccountCircle } from '@mui/icons-material'; import { AccountCircle, Translate } from '@mui/icons-material';
import { import {
AppBar, AppBar,
Avatar, Avatar,
Box, Box,
CircularProgress, CircularProgress,
IconButton, IconButton,
Menu,
MenuItem,
Link as MUILink, Link as MUILink,
Toolbar, Toolbar,
useScrollTrigger, useScrollTrigger,
} from '@mui/material'; } from '@mui/material';
import { Link, useRouter, useRouterState } from '@tanstack/react-router'; import { Link, useRouterState } from '@tanstack/react-router';
import { cloneElement, FC, ReactElement, useState } from 'react'; import { cloneElement, FC, ReactElement, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Api from '../../api/Api'; import Api from '../../api/Api';
import useGuestBookStore from '../../store/store'; import LanguageMenu from '../Menus/Language/LanguageMenu';
import Login from '../Forms/Login/Login'; import UserMenu from '../Menus/User/UserMenu';
const ElevationScroll = ({ children }: { children: ReactElement }) => { const ElevationScroll = ({ children }: { children: ReactElement }) => {
const trigger = useScrollTrigger({ const trigger = useScrollTrigger({
@ -30,21 +28,17 @@ const ElevationScroll = ({ children }: { children: ReactElement }) => {
}; };
const Header: FC = () => { const Header: FC = () => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const [anchorUserMenu, setAnchorUserMenu] = useState<null | HTMLElement>(null);
const [anchorLanguageMenu, setAnchorLanguageMenu] = useState<null | HTMLElement>(null);
const user = useGuestBookStore((state) => state.user);
const setUser = useGuestBookStore((state) => state.setUser);
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter();
const isLoading = useRouterState({ select: (s) => s.status === 'pending' }); const isLoading = useRouterState({ select: (s) => s.status === 'pending' });
const handleMenu = (event: React.MouseEvent<HTMLElement>) => { const user = Api.getAuthenticatedUser();
setAnchorEl(event.currentTarget);
};
const handleClose = () => { const handleClose = () => {
setAnchorEl(null); setAnchorLanguageMenu(null);
setAnchorUserMenu(null);
}; };
return ( return (
@ -58,55 +52,20 @@ const Header: FC = () => {
</MUILink> </MUILink>
{isLoading && <CircularProgress size={16} thickness={10} sx={{ color: 'white' }} />} {isLoading && <CircularProgress size={16} thickness={10} sx={{ color: 'white' }} />}
</Box> </Box>
<IconButton size="large" onClick={(event) => setAnchorLanguageMenu(event.currentTarget)}>
<Translate sx={{ color: 'white' }} />
</IconButton>
{user ? ( {user ? (
<IconButton onClick={handleMenu} sx={{ p: 0 }}> <IconButton onClick={(event) => setAnchorUserMenu(event.currentTarget)} sx={{ p: 0 }}>
<Avatar alt={user.username} src={`storage/${user.image}`} /> <Avatar alt={user.username} src={`storage/${user.image}`} />
</IconButton> </IconButton>
) : ( ) : (
<IconButton size="large" onClick={handleMenu} color="inherit"> <IconButton size="large" onClick={(event) => setAnchorUserMenu(event.currentTarget)} color="inherit">
<AccountCircle /> <AccountCircle />
</IconButton> </IconButton>
)} )}
<Menu <LanguageMenu anchorEl={anchorLanguageMenu} handleClose={handleClose} />
id="menu-appbar" <UserMenu anchorEl={anchorUserMenu} handleClose={handleClose} />
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={Boolean(anchorEl)}
onClose={handleClose}
sx={{
'& .MuiMenu-paper': {
minWidth: '240px',
},
}}
>
{user ? (
[
<MenuItem key="profile" onClick={handleClose}>
{t('Profile')}
</MenuItem>,
<MenuItem
key="logout"
onClick={() => {
Api.logOut();
setUser();
router.invalidate();
}}
>
{t('Log out')}
</MenuItem>,
]
) : (
<Login />
)}
</Menu>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
<Toolbar /> <Toolbar />

View File

@ -0,0 +1,55 @@
import { Menu, MenuItem } from '@mui/material';
import { FC } from 'react';
import i18n from '../../../i18n';
interface Props {
anchorEl: HTMLElement | null;
handleClose: () => void;
}
const LanguageMenu: FC<Props> = ({ anchorEl, handleClose }) => {
return (
<Menu
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={Boolean(anchorEl)}
onClose={handleClose}
sx={{
'& .MuiMenu-paper': {
minWidth: '240px',
},
}}
>
<MenuItem
key="de"
selected={i18n.language === 'en'}
onClick={() => {
i18n.changeLanguage('en');
handleClose();
}}
>
English
</MenuItem>
<MenuItem
key="en"
selected={i18n.language === 'de'}
onClick={() => {
i18n.changeLanguage('de');
handleClose();
}}
>
Deutsch
</MenuItem>
</Menu>
);
};
export default LanguageMenu;

View File

@ -0,0 +1,69 @@
import { Menu, MenuItem } from '@mui/material';
import { useNavigate, useRouter } from '@tanstack/react-router';
import { t } from 'i18next';
import { FC } from 'react';
import Api from '../../../api/Api';
import { ROUTES } from '../../../types/Routes';
import LoginForm from '../../Forms/Login/LoginForm';
interface Props {
anchorEl: HTMLElement | null;
handleClose: () => void;
}
const UserMenu: FC<Props> = ({ anchorEl, handleClose }) => {
const navigate = useNavigate();
const router = useRouter();
const user = Api.getAuthenticatedUser();
return (
<Menu
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={Boolean(anchorEl)}
onClose={handleClose}
sx={{
'& .MuiMenu-paper': {
minWidth: '240px',
},
}}
>
{user ? (
[
<MenuItem
key="profile"
onClick={() => {
navigate({ to: ROUTES.PROFILE });
handleClose();
}}
>
{t('Profile')}
</MenuItem>,
<MenuItem
key="logout"
onClick={() => {
Api.logOut();
router.invalidate();
handleClose();
}}
>
{t('Log out')}
</MenuItem>,
]
) : (
<LoginForm handleClose={handleClose} />
)}
</Menu>
);
};
export default UserMenu;

View File

@ -1,7 +1,9 @@
import { Avatar, Card, CardContent, CardHeader, Link as MUILink, Typography } from '@mui/material'; import { Avatar, Card, CardContent, CardHeader, Link as MUILink, Typography } from '@mui/material';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { FC } from 'react'; import { FC } from 'react';
import Api from '../../api/Api';
import { PostAuth, PostNonAuth } from '../../types/Post'; import { PostAuth, PostNonAuth } from '../../types/Post';
import convertDate from '../../utils/date';
interface Props { interface Props {
post: PostNonAuth | PostAuth; post: PostNonAuth | PostAuth;
@ -12,21 +14,37 @@ const Post: FC<Props> = ({ post }) => {
<Card sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1 }}> <Card sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1 }}>
<CardHeader <CardHeader
avatar={ avatar={
<MUILink component={Link} to="/" color="#FFF" variant="h6" underline="none"> 'id' in post.user ? (
<MUILink
component={Link}
to="/profile/$id"
params={{ id: post.user.id }}
color="#FFF"
variant="h6"
underline="none"
>
<Avatar alt={post.user.username} src={`storage/${post.user.image}`} />
</MUILink>
) : (
<Avatar alt={post.user.username} src={`storage/${post.user.image}`} /> <Avatar alt={post.user.username} src={`storage/${post.user.image}`} />
</MUILink> )
} }
title={post.user.username} title={
subheader={new Date(post.postedAt.date).toLocaleString(navigator.languages[0] ?? 'de-DE', { 'id' in post.user ? (
timeZone: post.postedAt.timezone, post.user.id !== Api.getAuthenticatedUser()?.id ? (
weekday: 'short', <MUILink component={Link} to="/profile/$id" params={{ id: post.user.id }}>
day: '2-digit', {post.user.username}
month: '2-digit', </MUILink>
year: 'numeric', ) : (
hour: '2-digit', <MUILink component={Link} to="/profile">
hour12: false, {post.user.username}
minute: '2-digit', </MUILink>
})} )
) : (
post.user.username
)
}
subheader={convertDate(post.postedAt)}
/> />
<CardContent> <CardContent>
<Typography>{post.content}</Typography> <Typography>{post.content}</Typography>

View File

@ -0,0 +1,34 @@
import { Avatar, Box, Grid, Typography } from '@mui/material';
import { FC } from 'react';
import { useTranslation } from 'react-i18next';
import { User } from '../../types/User';
import convertDate from '../../utils/date';
interface Props {
user: User;
}
const Profile: FC<Props> = ({ user }) => {
const { t } = useTranslation();
return (
<Box>
<Grid container spacing={2}>
<Grid item sx={{ display: 'grid', gridTemplateColumns: 'fit-content(100%) 1fr', columnGap: 2, rowGap: 1 }}>
<Box sx={{ gridColumn: '1/3', display: 'flex', justifyContent: 'center' }}>
<Avatar alt={user.username} src={`storage/${user.image}`} sx={{ width: 100, height: 100 }} />
</Box>
<Typography fontWeight="bold">{t('Username')}:</Typography>
<Typography>{user.username}</Typography>
<Typography fontWeight="bold">{t('Member since')}:</Typography>
<Typography>{convertDate(user.memberSince)}</Typography>
<Typography fontWeight="bold">{t('Post count')}:</Typography>
<Typography>{user.postCount}</Typography>
</Grid>
<Grid item></Grid>
</Grid>
</Box>
);
};
export default Profile;

View File

@ -12,6 +12,8 @@
import { Route as rootRoute } from './routes/__root' import { Route as rootRoute } from './routes/__root'
import { Route as IndexImport } from './routes/index' import { Route as IndexImport } from './routes/index'
import { Route as ProfileIndexImport } from './routes/profile/index'
import { Route as ProfileIdImport } from './routes/profile/$id'
// Create/Update Routes // Create/Update Routes
@ -20,6 +22,16 @@ const IndexRoute = IndexImport.update({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any) } as any)
const ProfileIndexRoute = ProfileIndexImport.update({
path: '/profile/',
getParentRoute: () => rootRoute,
} as any)
const ProfileIdRoute = ProfileIdImport.update({
path: '/profile/$id',
getParentRoute: () => rootRoute,
} as any)
// Populate the FileRoutesByPath interface // Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@ -31,12 +43,30 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexImport preLoaderRoute: typeof IndexImport
parentRoute: typeof rootRoute parentRoute: typeof rootRoute
} }
'/profile/$id': {
id: '/profile/$id'
path: '/profile/$id'
fullPath: '/profile/$id'
preLoaderRoute: typeof ProfileIdImport
parentRoute: typeof rootRoute
}
'/profile/': {
id: '/profile/'
path: '/profile'
fullPath: '/profile'
preLoaderRoute: typeof ProfileIndexImport
parentRoute: typeof rootRoute
}
} }
} }
// Create and export the route tree // Create and export the route tree
export const routeTree = rootRoute.addChildren({ IndexRoute }) export const routeTree = rootRoute.addChildren({
IndexRoute,
ProfileIdRoute,
ProfileIndexRoute,
})
/* prettier-ignore-end */ /* prettier-ignore-end */
@ -46,11 +76,19 @@ export const routeTree = rootRoute.addChildren({ IndexRoute })
"__root__": { "__root__": {
"filePath": "__root.tsx", "filePath": "__root.tsx",
"children": [ "children": [
"/" "/",
"/profile/$id",
"/profile/"
] ]
}, },
"/": { "/": {
"filePath": "index.tsx" "filePath": "index.tsx"
},
"/profile/$id": {
"filePath": "profile/$id.tsx"
},
"/profile/": {
"filePath": "profile/index.tsx"
} }
} }
} }

View File

@ -1,26 +1,53 @@
import { Toolbar } from '@mui/material'; import { QueryClient, useQueryErrorResetBoundary } from '@tanstack/react-query';
import { QueryClient } from '@tanstack/react-query'; import { createRootRouteWithContext, ErrorRouteComponent, Outlet, redirect, useRouter } from '@tanstack/react-router';
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/router-devtools'; import { TanStackRouterDevtools } from '@tanstack/router-devtools';
import { useEffect } from 'react';
import Api from '../api/Api'; import Api from '../api/Api';
import Header from '../components/Header/Header'; import Header from '../components/Header/Header';
import useGuestBookStore from '../store/store'; import { ROUTES } from '../types/Routes';
import { ERRORS } from '../utils/errors';
const Root = () => { const Root = () => {
//FIXME: REAUTH HERE //TODO: REAUTH HERE
const token = useGuestBookStore((state) => state.token);
Api.token = token;
return ( return (
<> <>
<Header /> <Header />
<Toolbar />
<Outlet /> <Outlet />
{process.env.NODE_ENV === 'development' && <TanStackRouterDevtools />} {process.env.NODE_ENV === 'development' && <TanStackRouterDevtools />}
</> </>
); );
}; };
//TODO: Make nice
const Error: ErrorRouteComponent = ({ error }) => {
const router = useRouter();
const queryErrorResetBoundary = useQueryErrorResetBoundary();
useEffect(() => {
// Reset the query error boundary
queryErrorResetBoundary.reset();
}, [queryErrorResetBoundary]);
if ('code' in error && error.code === ERRORS.UNAUTHORIZED) {
Api.logOut();
redirect({ to: ROUTES.INDEX });
}
return (
<div>
{error.message}
<button
onClick={() => {
router.invalidate();
}}
>
retry
</button>
</div>
);
};
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
component: Root, component: Root,
errorComponent: Error,
}); });

View File

@ -1,8 +1,10 @@
import { Grid, Pagination, PaginationItem, Snackbar } from '@mui/material'; import { Grid, Pagination, PaginationItem, Snackbar } from '@mui/material';
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'; import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, Link } from '@tanstack/react-router'; import { createFileRoute, Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import Api from '../api/Api'; import Api from '../api/Api';
import Post from '../components/Post/Post'; import Post from '../components/Post/Post';
import { ROUTES } from '../types/Routes';
const postsQueryOptions = (page?: number) => const postsQueryOptions = (page?: number) =>
queryOptions({ queryOptions({
@ -10,28 +12,18 @@ const postsQueryOptions = (page?: number) =>
queryFn: () => Api.posts(page), queryFn: () => Api.posts(page),
}); });
export const Route = createFileRoute('/')({ const Home = () => {
loaderDeps: ({ search: { page } }) => ({ page }),
loader: ({ context: { queryClient }, deps: { page } }) => queryClient.ensureQueryData(postsQueryOptions(page)),
validateSearch: (search: Record<string, unknown>): { page?: number } => {
return {
page: search?.page !== undefined ? Number(search?.page ?? 0) : undefined,
};
},
component: Home,
});
function Home() {
const { page } = Route.useSearch(); const { page } = Route.useSearch();
const { data: postsQuery, isFetching } = useSuspenseQuery(postsQueryOptions(page)); const { data: postsQuery, isFetching } = useSuspenseQuery(postsQueryOptions(page));
const { t } = useTranslation();
return ( return (
<> <>
<Snackbar open={isFetching} message="Updating" /> <Snackbar open={isFetching} message={t('Updating')} />
<Grid container spacing={2}> <Grid container spacing={2}>
{postsQuery.data.map((post) => ( {postsQuery.data.map((post) => (
<Grid item xs={12} md={6} lg={4} sx={{ display: 'flex' }}> <Grid key={post.id} item xs={12} md={6} lg={4} sx={{ display: 'flex' }}>
<Post key={post.id} post={post} /> <Post post={post} />
</Grid> </Grid>
))} ))}
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}> <Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
@ -44,7 +36,7 @@ function Home() {
{...item} {...item}
component={Link} component={Link}
to="/" to="/"
search={{ page: item.page ? item.page - 1 : undefined }} search={{ page: (item.page ?? 0) > 0 ? (item.page ?? 1) - 1 : undefined }}
//eslint-disable-next-line @typescript-eslint/no-explicit-any //eslint-disable-next-line @typescript-eslint/no-explicit-any
onClick={(e) => item.onClick(e as any)} onClick={(e) => item.onClick(e as any)}
/> />
@ -54,4 +46,15 @@ function Home() {
</Grid> </Grid>
</> </>
); );
} };
export const Route = createFileRoute(ROUTES.INDEX)({
loaderDeps: ({ search: { page } }) => ({ page }),
loader: ({ context: { queryClient }, deps: { page } }) => queryClient.ensureQueryData(postsQueryOptions(page)),
validateSearch: (search: Record<string, unknown>): { page?: number } => {
return {
page: search?.page !== undefined ? Number(search?.page ?? 0) : undefined,
};
},
component: Home,
});

View File

@ -0,0 +1,37 @@
import { Snackbar } from '@mui/material';
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, redirect } from '@tanstack/react-router';
import { t } from 'i18next';
import Api from '../../api/Api';
import Profile from '../../components/Profile/Profile';
import { ROUTES } from '../../types/Routes';
const profileQueryOptions = (id?: number) =>
queryOptions({
queryKey: ['profile', { id }],
queryFn: () => Api.user(id),
});
const ProfilePage = () => {
const { id } = Route.useParams();
const { data: profileQuery, isFetching } = useSuspenseQuery(profileQueryOptions(id));
return (
<>
<Snackbar open={isFetching} message={t('Updating')} />
<Profile user={profileQuery} />
</>
);
};
export const Route = createFileRoute(`${ROUTES.PROFILE}/$id`)({
params: {
parse: ({ id }) => ({ id: parseInt(id) }),
stringify: ({ id }) => ({ id: id.toString() }),
},
loader: ({ context: { queryClient }, params: { id } }) => queryClient.ensureQueryData(profileQueryOptions(id)),
beforeLoad: () => {
if (!Api.hasAuth()) throw redirect({ to: ROUTES.INDEX });
},
component: ProfilePage,
});

View File

@ -0,0 +1,31 @@
import { Snackbar } from '@mui/material';
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, redirect } from '@tanstack/react-router';
import { t } from 'i18next';
import Api from '../../api/Api';
import Profile from '../../components/Profile/Profile';
import { ROUTES } from '../../types/Routes';
const profileQueryOptions = queryOptions({
queryKey: ['profile'],
queryFn: () => Api.user(),
});
const ProfilePage = () => {
const { data: profileQuery, isFetching } = useSuspenseQuery(profileQueryOptions);
return (
<>
<Snackbar open={isFetching} message={t('Updating')} />
<Profile user={profileQuery} />
</>
);
};
export const Route = createFileRoute(`${ROUTES.PROFILE}/`)({
loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(profileQueryOptions),
beforeLoad: () => {
if (!Api.hasAuth()) throw redirect({ to: ROUTES.INDEX });
},
component: ProfilePage,
});

View File

@ -1,27 +1,14 @@
import type {} from '@redux-devtools/extension'; import type {} from '@redux-devtools/extension';
import { create } from 'zustand'; import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware'; import { devtools, persist } from 'zustand/middleware';
import { User } from '../types/User';
interface GuestBookState { interface GuestBookState {}
user: User | undefined;
token: string | undefined;
setUser: (user?: User, token?: string) => void;
}
const useGuestBookStore = create<GuestBookState>()( const useGuestBookStore = create<GuestBookState>()(
devtools( devtools(
persist( persist(() => ({}), {
(set) => ({ name: 'guestbook-storage',
user: undefined, })
// FIXME: Currentlay auth token, switch this to be reauth token
token: undefined,
setUser: (user?: User, token?: string) => set(() => ({ user, token })),
}),
{
name: 'guestbook-storage',
}
)
) )
); );

View File

@ -0,0 +1,4 @@
export enum ROUTES {
INDEX = '/',
PROFILE = '/profile',
}

View File

@ -0,0 +1,20 @@
import { Timestamp } from '../types/Timestamp';
/**
* Convert date to nicer format
* @param date Timestamp in question
* @returns Formatted string
*/
const convertDate = (date: Timestamp): string => {
return new Date(date.date).toLocaleString(navigator.languages[0] ?? 'de-DE', {
timeZone: date.timezone,
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
hour12: false,
minute: '2-digit',
});
};
export default convertDate;

View File

@ -1,5 +1,10 @@
import { TFunction } from 'i18next'; import { TFunction } from 'i18next';
export enum ERRORS {
NOT_FOUND = 'NotFound',
UNAUTHORIZED = 'Unauthorized',
}
/** /**
* Return translated error * Return translated error
* @param error Error object * @param error Error object
@ -18,9 +23,9 @@ const handleError = (
if (error.code) { if (error.code) {
switch (error.code) { switch (error.code) {
case 'NotFound': case ERRORS.NOT_FOUND:
return t(error.code, { context: `${error.entity}:${context}` }); return t(error.code, { context: `${error.entity}:${context}` });
case 'Unauthorized': case ERRORS.UNAUTHORIZED:
return t(error.code, { context }); return t(error.code, { context });
default: default:
return t('Unknown', { context }); return t('Unknown', { context });

View File

@ -1,12 +1,33 @@
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'; import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [TanStackRouterVite(), react()], plugins: [TanStackRouterVite(), react(), visualizer({ emitFile: true, open: true, filename: 'stats.html' })],
build: { build: {
outDir: '../dist', outDir: '../dist',
emptyOutDir: true,
rollupOptions: {
output: {
manualChunks: (id) => {
if (id.includes('node_modules')) {
if (id.includes('@mui/material')) {
return 'vendor_mui';
}
if (id.includes('@tanstack')) {
return 'vendor_tanstack';
}
if (id.includes('react')) {
return 'vendor_react';
}
return 'vendor'; // all other package goes here
}
},
},
},
}, },
base: '/phpCourse/exam/dist', base: '/phpCourse/exam/dist',
}); });