Auth redirects, language switch, basic profile

This commit is contained in:
2024-07-26 01:14:12 +02:00
parent 2091bdb4e3
commit 36a4659915
31 changed files with 5638 additions and 331 deletions
+1
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"
}
+132
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: {}
@@ -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"
}
@@ -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"
}
+21 -8
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,
@@ -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;
+16 -57
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 />
@@ -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;
@@ -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;
+31 -13
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>
@@ -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;
+40 -2
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"
}
}
}
+35 -8
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,
});
+20 -17
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,
});
+37
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,
});
+31
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,
});
+4 -17
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',
})
)
);
+4
View File
@@ -0,0 +1,4 @@
export enum ROUTES {
INDEX = '/',
PROFILE = '/profile',
}
+20
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;
+7 -2
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 });
+22 -1
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',
});