Skip to content

Commit

Permalink
feat(Admin): add notifications API
Browse files Browse the repository at this point in the history
- bump dependencies.
- split build into manual chunks.
- add ToastProvider to show notifications with different styles and duration.
- widgets can use 'useToasts' global to show toasts.
- queue notifications, prevent continuous duplicates.
- improve implementation and project workflow for EmoteSystem\Admin.
  • Loading branch information
poirierlouis committed Dec 10, 2024
1 parent bf67ea3 commit 6e2c7b2
Show file tree
Hide file tree
Showing 15 changed files with 411 additions and 248 deletions.
10 changes: 10 additions & 0 deletions code/admin/globals.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import {App} from "./api";

declare global {
interface Window {
MUI: typeof import('@mui/material');
App: App;
}
}

export {};
198 changes: 88 additions & 110 deletions code/admin/package-lock.json

Large diffs are not rendered by default.

13 changes: 7 additions & 6 deletions code/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"private": true,
"version": "0.1.0",
"license": "MIT",
"authors": ["Rayshader"],
"type": "module",
"scripts": {
"dev": "vite",
Expand All @@ -12,8 +13,8 @@
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.13.5",
"@emotion/styled": "^11.13.5",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^6.1.10",
"@mui/material": "^6.1.10",
"react": "^19.0.0",
Expand All @@ -25,18 +26,18 @@
"devDependencies": {
"@eslint/js": "^9.15.0",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"@types/react-dom": "^19.0.2",
"@types/systemjs": "^6.15.1",
"@typescript-eslint/eslint-plugin": "^8.17.0",
"@typescript-eslint/parser": "^8.17.0",
"@typescript-eslint/eslint-plugin": "^8.18.0",
"@typescript-eslint/parser": "^8.18.0",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.16.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.12.0",
"typescript": "^5.7.2",
"typescript-eslint": "^8.15.0",
"typescript-eslint": "^8.18.0",
"vite": "^6.0.1"
}
}
54 changes: 30 additions & 24 deletions code/admin/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as React from 'react';
import {useState} from 'react';
import {Route, Routes} from 'react-router';
import {styled} from '@mui/material/styles';
import Box from '@mui/material/Box';
import MuiAppBar, {AppBarProps as MuiAppBarProps} from '@mui/material/AppBar';
Expand All @@ -7,7 +8,7 @@ import {ThemeProvider} from "@mui/material";
import {theme} from "./Theme.ts";
import Toolbar from "./Toolbar/Toolbar.tsx";
import Drawer, {drawerWidth} from "./Drawer/Drawer.tsx";
import {Route, Routes} from "react-router";
import ToastProvider from "./Toast/ToastProvider.tsx";
import Dashboard from "./Pages/Dashboard/Dashboard.tsx";
import Plugins from "./Pages/Plugins/Plugins.tsx";
import Settings from "./Pages/Settings/Settings.tsx";
Expand Down Expand Up @@ -47,7 +48,7 @@ export interface ToolbarProps {
}

export default function App() {
const [openDrawer, setOpenDrawer] = React.useState<boolean>(false);
const [openDrawer, setOpenDrawer] = useState<boolean>(false);

const handleDrawerOpen = () => {
setOpenDrawer(true);
Expand All @@ -59,32 +60,37 @@ export default function App() {

return (
<ThemeProvider theme={theme}
defaultMode="dark"
defaultMode="system"
noSsr>
<Box sx={{height: '100%', display: 'flex'}}>
<CssBaseline/>
<ToastProvider>
<Box sx={{height: '100%', display: 'flex'}}>
<CssBaseline/>

<AppBar position="fixed" color="primary" enableColorOnDark open={openDrawer}>
<Toolbar open={openDrawer} onOpenDrawer={handleDrawerOpen}/>
</AppBar>
<AppBar position="fixed"
color="primary"
enableColorOnDark
open={openDrawer}>
<Toolbar open={openDrawer} onOpenDrawer={handleDrawerOpen}/>
</AppBar>

<Drawer open={openDrawer} onCloseDrawer={handleDrawerClose}/>
<Drawer open={openDrawer} onCloseDrawer={handleDrawerClose}/>

<Box component="main"
sx={{
overflowY: 'auto',
flexGrow: 1,
p: 3,
mt: '64px'
}}>
<Routes>
<Route index element={<Dashboard/>}/>
<Route path="plugins" element={<Plugins/>}/>
<Route path="settings" element={<Settings/>}/>
<Route path="about" element={<About/>}/>
</Routes>
<Box component="main"
sx={{
overflowY: 'auto',
flexGrow: 1,
p: 3,
mt: '64px'
}}>
<Routes>
<Route index element={<Dashboard/>}/>
<Route path="plugins" element={<Plugins/>}/>
<Route path="settings" element={<Settings/>}/>
<Route path="about" element={<About/>}/>
</Routes>
</Box>
</Box>
</Box>
</ToastProvider>
</ThemeProvider>
);
}
73 changes: 31 additions & 42 deletions code/admin/src/Pages/Dashboard/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import Box from "@mui/material/Box";
import {createElement, Fragment, ReactElement, Suspense, useEffect, useState} from "react";
import {createElement, ReactElement, Suspense, useEffect, useState} from "react";
import {ErrorBoundary} from "react-error-boundary";
import {Alert} from "@mui/material";
import {Alert, CircularProgress} from "@mui/material";
import {useToasts} from "../../Toast/ToastProvider.tsx";
import Module = System.Module;

interface PluginDto {
Expand All @@ -10,11 +11,13 @@ interface PluginDto {

interface WidgetData {
readonly url: string;
readonly name: string;
readonly element: ReactElement;
}

export default function Dashboard() {
const [widgets, setWidgets] = useState<WidgetData[]>([]);
const showToast = useToasts();

useEffect(() => {
async function getWidgets() {
Expand All @@ -30,68 +33,54 @@ export default function Dashboard() {

widgets.push({
url: url,
name: plugin.Name,
element: element,
});
} catch (error) {
console.error(error);
showToast({
style: 'error',
message: `Failed to load widget of plugin ${plugin.Name}:\n${error}`,
});
}
}

if (lock) {
return;
}
setWidgets(widgets);
}

let lock: boolean = false;

getWidgets();
return () => {
lock = true;
};
}, []);

useEffect(() => {

}, [widgets]);

const getPlugins = async (): Promise<PluginDto[]> => {
const response: Response = await fetch('/api/v1/plugins');

if (!response.ok) {
showToast({
style: 'error',
message: 'Failed to request API. Are you sure the server is running?',
duration: 5000
});
return [];
}
return await response.json() as PluginDto[];
}

return (
<Box className="page center">
<ErrorBoundary fallbackRender={
({error}) => {
return (
<Alert severity="error" sx={{width: '500px', m: 'auto'}}>
Error while rendering widgets: {error.message}
</Alert>
);
}
}>
{widgets.map((widget, i) => {
return (
<Fragment key={i}>
<Box>
<Suspense key={i}
fallback={
<Alert severity="info" sx={{width: '500px', m: 'auto'}}>
Loading widgets...
</Alert>
}>
{widget.element}
</Suspense>
</Box>
</Fragment>
);
})}
</ErrorBoundary>
{widgets.map((widget, i) => (
<Box key={i}>
<ErrorBoundary fallbackRender={
({error}) => (
<Alert severity="error" sx={{width: '500px', m: 'auto'}}>
Error while rendering widget {widget.name}:
{error.message}
</Alert>
)
}>
<Suspense fallback={<CircularProgress/>}>
{widget.element}
</Suspense>
</ErrorBoundary>
</Box>
))}
</Box>
);
}
110 changes: 110 additions & 0 deletions code/admin/src/Toast/ToastProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import {Alert, Snackbar} from "@mui/material";
import {ActionDispatch, createContext, ReactElement, useContext, useEffect, useReducer, useState} from "react";
import {equalsToast, Toast, ToastAction, ToastState} from "./ToastReducer.ts";

interface ToastProviderProps {
readonly children: ReactElement | Array<ReactElement>;
}

export function useToasts() {
const dispatch = useContext(ToastDispatchContext);

return (toast: Toast) => {
dispatch!({
type: 'open',
toast: {
...toast,
style: toast.style ?? 'info',
duration: toast.duration ?? 4000,
}
});
};
}

const toastReducer = (state: ToastState, action: ToastAction): ToastState => {
if (action.type === 'open') {
return queueToast(state, action.toast);
} else if (action.type === 'close') {
return closeToast(state);
}
throw new Error(`ToastProvider: unknown action "${action.type}".`);
};

const queueToast = (state: ToastState, toast?: Toast): ToastState => {
if (!toast) {
return state;
}
const lastToast: Toast | undefined = state.toasts[state.toasts.length - 1] ?? state.toast;

if (equalsToast(lastToast, toast)) {
return state;
}
if (state.toast) {
return {
toast: state.toast,
toasts: [...state.toasts, toast]
}
}
return {
toast: toast,
toasts: []
};
}

const closeToast = (state: ToastState) => {
let toasts: Toast[] = state.toasts;
let show: Toast | undefined = toasts.splice(0, 1)[0];

return {
toast: show,
toasts: [...toasts]
};
}

const toastState: ToastState = {
toast: undefined,
toasts: []
};

const ToastContext = createContext<ToastState | null>(null);
const ToastDispatchContext = createContext<ActionDispatch<[action: ToastAction]> | null>(null);

export default function ToastProvider({children}: ToastProviderProps) {
const [state, dispatch] = useReducer(toastReducer, toastState);
const [show, setShow] = useState<boolean>(false);
const [toast, setToast] = useState<Toast>({message: '', duration: 4000});

useEffect(() => {
if (!state.toast) {
return;
}
setToast(state.toast);
setShow(true);
}, [state]);

const handleClose = () => {
setShow(false);
// NOTE: wait for animation to finish.
setTimeout(() => {
dispatch({type: 'close'});
}, 500);
};

return (
<ToastContext.Provider value={state}>
<ToastDispatchContext.Provider value={dispatch}>
<Snackbar
open={show}
autoHideDuration={toast.duration}
onClose={handleClose}>
<Alert severity={toast.style}
variant="filled"
sx={{width: '100%'}}>
{toast.message}
</Alert>
</Snackbar>
{children}
</ToastDispatchContext.Provider>
</ToastContext.Provider>
);
}
25 changes: 25 additions & 0 deletions code/admin/src/Toast/ToastReducer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export type ToastStyle = 'info' | 'success' | 'warning' | 'error';

export interface Toast {
readonly message: string;
readonly style?: ToastStyle;
readonly duration?: number;
readonly onClosed?: () => void;
}

export interface ToastState {
readonly toast?: Toast;
readonly toasts: Toast[];
}

export interface ToastAction {
readonly type: 'open' | 'close';
readonly toast?: Toast;
}

export function equalsToast(a?: Toast, b?: Toast): boolean {
return a?.style === b?.style &&
a?.duration === b?.duration &&
a?.message === b?.message &&
a?.onClosed === b?.onClosed;
}
Loading

0 comments on commit 6e2c7b2

Please sign in to comment.