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" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<script type="module" crossorigin src="/phpCourse/exam/dist/assets/index-BJXidBC9.js"></script>
<link rel="stylesheet" crossorigin href="/phpCourse/exam/dist/assets/index-D83Ey19k.css">
<script type="module" crossorigin src="/phpCourse/exam/dist/assets/index-BF6aZoB4.js"></script>
<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>
<body>
<div id="root"></div>

View File

@ -12,5 +12,11 @@
"Log in": "Anmelden",
"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 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",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^3.2.4",
"rollup-plugin-visualizer": "^5.0.0",
"typescript": "^5.2.2",
"vite": "^5.3.4"
}

View File

@ -102,6 +102,9 @@ importers:
prettier-plugin-organize-imports:
specifier: ^3.2.4
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:
specifier: ^5.2.2
version: 5.5.3
@ -1020,6 +1023,10 @@ packages:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
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:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@ -1091,6 +1098,10 @@ packages:
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
engines: {node: '>= 0.4'}
define-lazy-prop@2.0.0:
resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
engines: {node: '>=8'}
dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
@ -1105,6 +1116,9 @@ packages:
electron-to-chromium@1.4.830:
resolution: {integrity: sha512-TrPKKH20HeN0J1LHzsYLs2qwXrp8TF4nHdu4sq61ozGbzMpWhI7iIOPYPPkxeq1azMT9PZ8enPFcftbs/Npcjg==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
error-ex@1.3.2:
resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
@ -1237,6 +1251,10 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
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:
resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==}
engines: {node: '>= 0.4'}
@ -1359,10 +1377,19 @@ packages:
resolution: {integrity: sha512-Dd+Lb2/zvk9SKy1TGCt1wFJFo/MWBPMX5x7KcvLajWTGuomczdQX61PvY5yK6SVACwpoexWo81IfFyoKY2QnTA==}
engines: {node: '>= 0.4'}
is-docker@2.2.1:
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
engines: {node: '>=8'}
hasBin: true
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
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:
resolution: {integrity: sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==}
engines: {node: '>= 0.4'}
@ -1383,6 +1410,10 @@ packages:
resolution: {integrity: sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==}
engines: {node: '>= 0.4'}
is-wsl@2.2.0:
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
engines: {node: '>=8'}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@ -1492,6 +1523,10 @@ packages:
once@1.4.0:
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:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@ -1626,6 +1661,10 @@ packages:
regenerator-runtime@0.14.1:
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:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@ -1643,6 +1682,16 @@ packages:
deprecated: Rimraf versions prior to v4 are no longer supported
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:
resolution: {integrity: sha512-Elx2UT8lzxxOXMpy5HWQGZqkrQOtrVDDa/bm9l10+U4rQnVzbL/LgZ4NOM1MPIDyHk69W4InuYDF5dzRh4Kw1A==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@ -1704,6 +1753,10 @@ packages:
stream-slice@0.1.2:
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:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
@ -1867,9 +1920,17 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
wrappy@1.0.2:
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:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@ -1877,6 +1938,14 @@ packages:
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
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:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@ -2871,6 +2940,12 @@ snapshots:
optionalDependencies:
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: {}
color-convert@1.9.3:
@ -2933,6 +3008,8 @@ snapshots:
es-errors: 1.3.0
gopd: 1.0.1
define-lazy-prop@2.0.0: {}
dir-glob@3.0.1:
dependencies:
path-type: 4.0.0
@ -2948,6 +3025,8 @@ snapshots:
electron-to-chromium@1.4.830: {}
emoji-regex@8.0.0: {}
error-ex@1.3.2:
dependencies:
is-arrayish: 0.2.1
@ -3122,6 +3201,8 @@ snapshots:
gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
get-intrinsic@1.2.4:
dependencies:
es-errors: 1.3.0
@ -3249,8 +3330,12 @@ snapshots:
dependencies:
hasown: 2.0.2
is-docker@2.2.1: {}
is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
is-generator-function@1.0.10:
dependencies:
has-tostringtag: 1.0.2
@ -3267,6 +3352,10 @@ snapshots:
dependencies:
which-typed-array: 1.1.15
is-wsl@2.2.0:
dependencies:
is-docker: 2.2.1
isexe@2.0.0: {}
js-tokens@4.0.0: {}
@ -3349,6 +3438,12 @@ snapshots:
dependencies:
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:
dependencies:
deep-is: 0.1.4
@ -3460,6 +3555,8 @@ snapshots:
regenerator-runtime@0.14.1: {}
require-directory@2.1.1: {}
resolve-from@4.0.0: {}
resolve@1.22.8:
@ -3474,6 +3571,15 @@ snapshots:
dependencies:
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:
dependencies:
'@types/estree': 1.0.5
@ -3542,6 +3648,12 @@ snapshots:
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:
dependencies:
ansi-regex: 5.0.1
@ -3669,12 +3781,32 @@ snapshots:
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: {}
y18n@5.0.8: {}
yallist@3.1.1: {}
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: {}
zod@3.23.8: {}

View File

@ -12,5 +12,11 @@
"Log in": "Anmelden",
"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 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;
class ApiImpl {
//FIXME: PRIVATE when reauth token exists
public token?: string;
private token?: string;
private refreshToken?: string;
private self?: User;
constructor() {
if (instance) {
@ -19,18 +20,26 @@ class ApiImpl {
}
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<[User, string]> => {
public logIn = async (email: string, password: string): Promise<void> => {
const { user, token } = await (await this.post('login', { email, password })).json();
this.self = user;
this.isAdmin = user.isAdmin;
this.token = token;
return [user, token];
};
public logOut = async (): Promise<boolean> => {
this.token = undefined;
return await (await this.postAuth('logout')).json();
try {
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> => {
@ -41,6 +50,10 @@ class ApiImpl {
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 (
endpoint: string,
body: Record<string, unknown> | undefined = undefined,

View File

@ -4,13 +4,14 @@ import { useRouter } from '@tanstack/react-router';
import { FC, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Api from '../../../api/Api';
import useGuestBookStore from '../../../store/store';
import handleError from '../../../utils/errors';
const Login: FC = () => {
const [error, setError] = useState();
interface Props {
handleClose: () => void;
}
const setUser = useGuestBookStore((state) => state.setUser);
const LoginForm: FC<Props> = ({ handleClose }) => {
const [error, setError] = useState();
const { t } = useTranslation();
const router = useRouter();
@ -22,9 +23,9 @@ const Login: FC = () => {
},
onSubmit: async ({ value }) => {
try {
const [user, token] = await Api.logIn(value.email, value.password);
setUser(user, token);
await Api.logIn(value.email, value.password);
router.invalidate();
handleClose();
//eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
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 {
AppBar,
Avatar,
Box,
CircularProgress,
IconButton,
Menu,
MenuItem,
Link as MUILink,
Toolbar,
useScrollTrigger,
} 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 { useTranslation } from 'react-i18next';
import Api from '../../api/Api';
import useGuestBookStore from '../../store/store';
import Login from '../Forms/Login/Login';
import LanguageMenu from '../Menus/Language/LanguageMenu';
import UserMenu from '../Menus/User/UserMenu';
const ElevationScroll = ({ children }: { children: ReactElement }) => {
const trigger = useScrollTrigger({
@ -30,21 +28,17 @@ const ElevationScroll = ({ children }: { children: ReactElement }) => {
};
const Header: FC = () => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const user = useGuestBookStore((state) => state.user);
const setUser = useGuestBookStore((state) => state.setUser);
const [anchorUserMenu, setAnchorUserMenu] = useState<null | HTMLElement>(null);
const [anchorLanguageMenu, setAnchorLanguageMenu] = useState<null | HTMLElement>(null);
const { t } = useTranslation();
const router = useRouter();
const isLoading = useRouterState({ select: (s) => s.status === 'pending' });
const handleMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const user = Api.getAuthenticatedUser();
const handleClose = () => {
setAnchorEl(null);
setAnchorLanguageMenu(null);
setAnchorUserMenu(null);
};
return (
@ -58,55 +52,20 @@ const Header: FC = () => {
</MUILink>
{isLoading && <CircularProgress size={16} thickness={10} sx={{ color: 'white' }} />}
</Box>
<IconButton size="large" onClick={(event) => setAnchorLanguageMenu(event.currentTarget)}>
<Translate sx={{ color: 'white' }} />
</IconButton>
{user ? (
<IconButton onClick={handleMenu} sx={{ p: 0 }}>
<IconButton onClick={(event) => setAnchorUserMenu(event.currentTarget)} sx={{ p: 0 }}>
<Avatar alt={user.username} src={`storage/${user.image}`} />
</IconButton>
) : (
<IconButton size="large" onClick={handleMenu} color="inherit">
<IconButton size="large" onClick={(event) => setAnchorUserMenu(event.currentTarget)} color="inherit">
<AccountCircle />
</IconButton>
)}
<Menu
id="menu-appbar"
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>
<LanguageMenu anchorEl={anchorLanguageMenu} handleClose={handleClose} />
<UserMenu anchorEl={anchorUserMenu} handleClose={handleClose} />
</Toolbar>
</AppBar>
<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 { Link } from '@tanstack/react-router';
import { FC } from 'react';
import Api from '../../api/Api';
import { PostAuth, PostNonAuth } from '../../types/Post';
import convertDate from '../../utils/date';
interface Props {
post: PostNonAuth | PostAuth;
@ -12,21 +14,37 @@ const Post: FC<Props> = ({ post }) => {
<Card sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1 }}>
<CardHeader
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}`} />
</MUILink>
)
}
title={post.user.username}
subheader={new Date(post.postedAt.date).toLocaleString(navigator.languages[0] ?? 'de-DE', {
timeZone: post.postedAt.timezone,
weekday: 'short',
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
hour12: false,
minute: '2-digit',
})}
title={
'id' in post.user ? (
post.user.id !== Api.getAuthenticatedUser()?.id ? (
<MUILink component={Link} to="/profile/$id" params={{ id: post.user.id }}>
{post.user.username}
</MUILink>
) : (
<MUILink component={Link} to="/profile">
{post.user.username}
</MUILink>
)
) : (
post.user.username
)
}
subheader={convertDate(post.postedAt)}
/>
<CardContent>
<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 IndexImport } from './routes/index'
import { Route as ProfileIndexImport } from './routes/profile/index'
import { Route as ProfileIdImport } from './routes/profile/$id'
// Create/Update Routes
@ -20,6 +22,16 @@ const IndexRoute = IndexImport.update({
getParentRoute: () => rootRoute,
} 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
declare module '@tanstack/react-router' {
@ -31,12 +43,30 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexImport
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
export const routeTree = rootRoute.addChildren({ IndexRoute })
export const routeTree = rootRoute.addChildren({
IndexRoute,
ProfileIdRoute,
ProfileIndexRoute,
})
/* prettier-ignore-end */
@ -46,11 +76,19 @@ export const routeTree = rootRoute.addChildren({ IndexRoute })
"__root__": {
"filePath": "__root.tsx",
"children": [
"/"
"/",
"/profile/$id",
"/profile/"
]
},
"/": {
"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 } from '@tanstack/react-query';
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router';
import { QueryClient, useQueryErrorResetBoundary } from '@tanstack/react-query';
import { createRootRouteWithContext, ErrorRouteComponent, Outlet, redirect, useRouter } from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/router-devtools';
import { useEffect } from 'react';
import Api from '../api/Api';
import Header from '../components/Header/Header';
import useGuestBookStore from '../store/store';
import { ROUTES } from '../types/Routes';
import { ERRORS } from '../utils/errors';
const Root = () => {
//FIXME: REAUTH HERE
const token = useGuestBookStore((state) => state.token);
Api.token = token;
//TODO: REAUTH HERE
return (
<>
<Header />
<Toolbar />
<Outlet />
{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 }>()({
component: Root,
errorComponent: Error,
});

View File

@ -1,8 +1,10 @@
import { Grid, Pagination, PaginationItem, Snackbar } from '@mui/material';
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import Api from '../api/Api';
import Post from '../components/Post/Post';
import { ROUTES } from '../types/Routes';
const postsQueryOptions = (page?: number) =>
queryOptions({
@ -10,28 +12,18 @@ const postsQueryOptions = (page?: number) =>
queryFn: () => Api.posts(page),
});
export const Route = createFileRoute('/')({
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 Home = () => {
const { page } = Route.useSearch();
const { data: postsQuery, isFetching } = useSuspenseQuery(postsQueryOptions(page));
const { t } = useTranslation();
return (
<>
<Snackbar open={isFetching} message="Updating" />
<Snackbar open={isFetching} message={t('Updating')} />
<Grid container spacing={2}>
{postsQuery.data.map((post) => (
<Grid item xs={12} md={6} lg={4} sx={{ display: 'flex' }}>
<Post key={post.id} post={post} />
<Grid key={post.id} item xs={12} md={6} lg={4} sx={{ display: 'flex' }}>
<Post post={post} />
</Grid>
))}
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
@ -44,7 +36,7 @@ function Home() {
{...item}
component={Link}
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
onClick={(e) => item.onClick(e as any)}
/>
@ -54,4 +46,15 @@ function Home() {
</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 { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { User } from '../types/User';
interface GuestBookState {
user: User | undefined;
token: string | undefined;
setUser: (user?: User, token?: string) => void;
}
interface GuestBookState {}
const useGuestBookStore = create<GuestBookState>()(
devtools(
persist(
(set) => ({
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',
}
)
persist(() => ({}), {
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';
export enum ERRORS {
NOT_FOUND = 'NotFound',
UNAUTHORIZED = 'Unauthorized',
}
/**
* Return translated error
* @param error Error object
@ -18,9 +23,9 @@ const handleError = (
if (error.code) {
switch (error.code) {
case 'NotFound':
case ERRORS.NOT_FOUND:
return t(error.code, { context: `${error.entity}:${context}` });
case 'Unauthorized':
case ERRORS.UNAUTHORIZED:
return t(error.code, { context });
default:
return t('Unknown', { context });

View File

@ -1,12 +1,33 @@
import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig } from 'vite';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [TanStackRouterVite(), react()],
plugins: [TanStackRouterVite(), react(), visualizer({ emitFile: true, open: true, filename: 'stats.html' })],
build: {
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',
});