Add base web UI

This commit is contained in:
2025-03-18 19:14:46 +01:00
parent abc75786f7
commit dbe1ec22e0
36 changed files with 2575 additions and 248 deletions

View File

@ -0,0 +1,92 @@
import { Alert, Box, Button, CircularProgress } from "@mui/material";
import { useEffect, useRef, useState } from "react";
enum State {
Loading,
Ready,
Error,
}
export function AsyncWidget(p: {
loadKey: any;
load: () => Promise<void>;
errMsg: string;
build: () => React.ReactElement;
ready?: boolean;
errAdditionalElement?: () => React.ReactElement;
}): React.ReactElement {
const [state, setState] = useState(State.Loading);
const counter = useRef<any | null>(null);
const load = async () => {
try {
setState(State.Loading);
await p.load();
setState(State.Ready);
} catch (e) {
console.error(e);
setState(State.Error);
}
};
useEffect(() => {
if (counter.current === p.loadKey) return;
counter.current = p.loadKey;
load();
});
if (state === State.Error)
return (
<Box
component="div"
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
flex: "1",
flexDirection: "column",
backgroundColor: (theme) =>
theme.palette.mode === "light"
? theme.palette.grey[100]
: theme.palette.grey[900],
}}
>
<Alert
variant="outlined"
severity="error"
style={{ margin: "0px 15px 15px 15px" }}
>
{p.errMsg}
</Alert>
<Button onClick={load}>Try again</Button>
{p.errAdditionalElement && p.errAdditionalElement()}
</Box>
);
if (state === State.Loading || p.ready === false)
return (
<Box
component="div"
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
flex: "1",
backgroundColor: (theme) =>
theme.palette.mode === "light"
? theme.palette.grey[100]
: theme.palette.grey[900],
}}
>
<CircularProgress />
</Box>
);
return p.build();
}

View File

@ -0,0 +1,13 @@
import { Button } from "@mui/material";
import { Link } from "react-router-dom";
export function AuthSingleMessage(p: { message: string }): React.ReactElement {
return (
<>
<p style={{ textAlign: "center" }}>{p.message}</p>
<Link to={"/"}>
<Button>Go back home</Button>
</Link>
</>
);
}

View File

@ -0,0 +1,88 @@
import { Box, Button } from "@mui/material";
import * as React from "react";
import { Outlet, useNavigate } from "react-router-dom";
import { useAuth } from "../App";
import { AuthApi, AuthInfo } from "../api/AuthApi";
import { AsyncWidget } from "./AsyncWidget";
import { MoneyWebAppBar } from "./MoneyWebAppBar";
import { MoneyNavList } from "./MoneyNavList";
interface AuthInfoContext {
info: AuthInfo;
reloadAuthInfo: () => void;
}
const AuthInfoContextK = React.createContext<AuthInfoContext | null>(null);
export function BaseAuthenticatedPage(): React.ReactElement {
const [authInfo, setAuthInfo] = React.useState<null | AuthInfo>(null);
const auth = useAuth();
const navigate = useNavigate();
const signOut = () => {
AuthApi.SignOut();
navigate("/");
auth.setSignedIn(false);
};
const load = async () => {
setAuthInfo(await AuthApi.GetAuthInfo());
};
return (
<AsyncWidget
loadKey="1"
load={load}
errMsg="Failed to load user information!"
errAdditionalElement={() => (
<>
<Button onClick={signOut}>Sign out</Button>
</>
)}
build={() => (
<AuthInfoContextK.Provider
value={{
info: authInfo!,
reloadAuthInfo: load,
}}
>
<Box
component="div"
sx={{
minHeight: "100vh",
display: "flex",
flexDirection: "column",
backgroundColor: (theme) =>
theme.palette.mode === "light"
? theme.palette.grey[100]
: theme.palette.grey[900],
color: (theme) =>
theme.palette.mode === "light"
? theme.palette.grey[900]
: theme.palette.grey[100],
}}
>
<MoneyWebAppBar onSignOut={signOut} />
<Box
sx={{
display: "flex",
flex: "2",
}}
>
<MoneyNavList />
<div style={{ flex: 1, display: "flex" }}>
<Outlet />
</div>
</Box>
</Box>
</AuthInfoContextK.Provider>
)}
/>
);
}
export function useAuthInfo(): AuthInfoContext {
return React.useContext(AuthInfoContextK)!;
}

View File

@ -0,0 +1,94 @@
import { mdiServer } from "@mdi/js";
import Icon from "@mdi/react";
import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
import CssBaseline from "@mui/material/CssBaseline";
import Grid from "@mui/material/Grid2";
import Paper from "@mui/material/Paper";
import Typography from "@mui/material/Typography";
import { Link, Outlet } from "react-router-dom";
import loginSplashImage from "./mufid-majnun-LVcjYwuHQlg-unsplash.jpg";
function Copyright(props: any): React.ReactElement {
return (
<Typography
variant="body2"
color="text.secondary"
align="center"
style={{ marginTop: "20px" }}
{...props}
>
{"Copyright © "}
<a
color="inherit"
href="https://0ph.fr/"
target="_blank"
rel="noreferrer"
style={{ color: "inherit" }}
>
Pierre HUBERT
</a>{" "}
{new Date().getFullYear()}
{"."}
</Typography>
);
}
export function BaseLoginPage() {
return (
<Grid container component="main" sx={{ height: "100vh" }}>
<CssBaseline />
<Grid
size={{ xs: false, sm: 4, md: 7 }}
sx={{
backgroundImage: `url(${loginSplashImage})`,
backgroundRepeat: "no-repeat",
backgroundColor: (t) =>
t.palette.mode === "light"
? t.palette.grey[50]
: t.palette.grey[900],
backgroundSize: "cover",
backgroundPosition: "center",
}}
/>
<Grid
size={{ xs: 12, sm: 8, md: 5 }}
component={Paper}
elevation={6}
square
>
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
flex: "1",
height: "100%",
}}
>
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
<Icon path={mdiServer} size={1} />
</Avatar>
<Link to="/" style={{ color: "inherit", textDecoration: "none" }}>
<Typography component="h1" variant="h5">
Money manager
</Typography>
</Link>
<Typography
component="h1"
variant="h6"
style={{ margin: " 40px 0px" }}
>
Open source money managment
</Typography>
{/* inner page */}
<Outlet />
<Copyright sx={{ mt: 5 }} style={{ margin: " 40px 0px" }} />
</Box>
</Grid>
</Grid>
);
}

View File

@ -0,0 +1,19 @@
import Brightness7Icon from "@mui/icons-material/Brightness7";
import DarkModeIcon from "@mui/icons-material/DarkMode";
import { IconButton, Tooltip } from "@mui/material";
import { useDarkTheme } from "../hooks/context_providers/DarkThemeProvider";
export function DarkThemeButton(): React.ReactElement {
const darkTheme = useDarkTheme();
return (
<Tooltip title="Activer / désactiver le mode sombre">
<IconButton
onClick={() => darkTheme.setEnabled(!darkTheme.enabled)}
style={{ color: "inherit" }}
>
{!darkTheme.enabled ? <DarkModeIcon /> : <Brightness7Icon />}
</IconButton>
</Tooltip>
);
}

View File

@ -0,0 +1,53 @@
import { mdiApi, mdiHome } from "@mdi/js";
import Icon from "@mdi/react";
import {
List,
ListItemButton,
ListItemIcon,
ListItemText,
} from "@mui/material";
import { useLocation } from "react-router-dom";
import { useAuthInfo } from "./BaseAuthenticatedPage";
import { RouterLink } from "./RouterLink";
export function MoneyNavList(): React.ReactElement {
const user = useAuthInfo().info;
return (
<List
dense
component="nav"
sx={{
minWidth: "200px",
backgroundColor: "background.paper",
}}
>
<NavLink
label="Accueil"
uri="/"
icon={<Icon path={mdiHome} size={1} />}
/>
<NavLink
label="API Tokens"
uri="/tokens"
icon={<Icon path={mdiApi} size={1} />}
/>
</List>
);
}
function NavLink(p: {
icon: React.ReactElement;
uri: string;
label: string;
}): React.ReactElement {
const location = useLocation();
return (
<RouterLink to={p.uri}>
<ListItemButton selected={p.uri === location.pathname}>
<ListItemIcon>{p.icon}</ListItemIcon>
<ListItemText primary={p.label} />
</ListItemButton>
</RouterLink>
);
}

View File

@ -0,0 +1,81 @@
import { mdiCash } from "@mdi/js";
import Icon from "@mdi/react";
import SettingsIcon from "@mui/icons-material/Settings";
import { Button } from "@mui/material";
import AppBar from "@mui/material/AppBar";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import Toolbar from "@mui/material/Toolbar";
import Typography from "@mui/material/Typography";
import * as React from "react";
import { useAuthInfo } from "./BaseAuthenticatedPage";
import { DarkThemeButton } from "./DarkThemeButton";
import { RouterLink } from "./RouterLink";
export function MoneyWebAppBar(p: {
onSignOut: () => void;
}): React.ReactElement {
const authInfo = useAuthInfo();
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const handleMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleCloseMenu = () => {
setAnchorEl(null);
};
const signOut = () => {
handleCloseMenu();
p.onSignOut();
};
return (
<AppBar position="sticky">
<Toolbar>
<Icon path={mdiCash} size={1} style={{ marginRight: "1rem" }} />
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
<RouterLink to="/">Money Manager</RouterLink>
</Typography>
<div>
<DarkThemeButton />
<Button size="large" color="inherit">
{authInfo!.info.name}
</Button>
<Button
size="large"
aria-label="account of current user"
aria-controls="menu-appbar"
aria-haspopup="true"
onClick={handleMenu}
color="inherit"
>
<SettingsIcon />
</Button>
<Menu
id="menu-appbar"
anchorEl={anchorEl}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
keepMounted
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
open={Boolean(anchorEl)}
onClose={handleCloseMenu}
>
<MenuItem onClick={signOut}>Sign out</MenuItem>
</Menu>
</div>
</Toolbar>
</AppBar>
);
}

View File

@ -0,0 +1,16 @@
import { PropsWithChildren } from "react";
import { Link } from "react-router-dom";
export function RouterLink(
p: PropsWithChildren<{ to: string; target?: React.HTMLAttributeAnchorTarget }>
): React.ReactElement {
return (
<Link
to={p.to}
target={p.target}
style={{ color: "inherit", textDecoration: "inherit" }}
>
{p.children}
</Link>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 582 KiB