Skip to content

Commit

Permalink
feat(Admin): dynamically load plugin's widget
Browse files Browse the repository at this point in the history
- export widget using UMD and external dependencies.
- import widget using SystemJS.
- render as-is in Dashboard page for now.

TODO:
- provide a docker layout to organize/resize widgets.
- improve development workflow.
- test production workflow.
  • Loading branch information
poirierlouis committed Dec 9, 2024
1 parent ff22440 commit bf67ea3
Show file tree
Hide file tree
Showing 13 changed files with 235 additions and 44 deletions.
51 changes: 50 additions & 1 deletion code/admin/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion code/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,15 @@
"@mui/material": "^6.1.10",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.0.2"
"react-error-boundary": "^4.1.2",
"react-router": "^7.0.2",
"systemjs": "^6.15.1"
},
"devDependencies": {
"@eslint/js": "^9.15.0",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"@types/systemjs": "^6.15.1",
"@typescript-eslint/eslint-plugin": "^8.17.0",
"@typescript-eslint/parser": "^8.17.0",
"@vitejs/plugin-react": "^4.3.4",
Expand Down
89 changes: 88 additions & 1 deletion code/admin/src/Pages/Dashboard/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,97 @@
import Box from "@mui/material/Box";
import {createElement, Fragment, ReactElement, Suspense, useEffect, useState} from "react";
import {ErrorBoundary} from "react-error-boundary";
import {Alert} from "@mui/material";
import Module = System.Module;

interface PluginDto {
readonly Name: string;
}

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

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

useEffect(() => {
async function getWidgets() {
const plugins = await getPlugins();
const widgets: WidgetData[] = [];

for (const plugin of plugins) {
const url: string = `/api/v1/plugins/${plugin.Name}/assets/widget.umd.js`;

try {
const module: Module = await System.import(url);
const element: ReactElement = createElement(module.default);

widgets.push({
url: url,
element: element,
});
} catch (error) {
console.error(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) {
return [];
}
return await response.json() as PluginDto[];
}

return (
<Box className="page center">
<Alert severity="warning" sx={{m: 'auto'}}>Work in progress</Alert>
<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>
</Box>
);
}
8 changes: 7 additions & 1 deletion code/admin/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import {StrictMode} from 'react';
import React, {StrictMode} from 'react';
import * as MaterialUI from '@mui/material';
import {createRoot} from 'react-dom/client';
import './index.css';
import App from './App.tsx';
import {BrowserRouter} from "react-router";
import 'systemjs';

window.React = React;
// @ts-expect-error should declare in global
window.MaterialUI = MaterialUI;

createRoot(document.getElementById('root')!).render(
<StrictMode>
Expand Down
35 changes: 31 additions & 4 deletions code/admin/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,34 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import {defineConfig} from 'vite';
import react from '@vitejs/plugin-react';

// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})
plugins: [
react()
],
server: {
proxy: {
'/api': 'http://localhost:11778'
},
},
optimizeDeps: {
include: [
'react',
'@mui/material'
]
},
build: {
rollupOptions: {
external: [
'react',
'@mui/material'
],
output: {
globals: {
'react': 'React',
'@mui/material': 'MUI',
}
}
}
}
});
9 changes: 7 additions & 2 deletions code/server/loader/Systems/WebApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,12 @@ private void RegisterAssets(WebServer server)
server.WithStaticFolder(
baseRoute: $"/api/v1/plugins/{name}/assets/",
fileSystemPath: path,
isImmutable: true);
#if DEBUG
isImmutable: false,
#else
isImmutable: true,
#endif
configure: m => { m.AddCustomMimeType(".umd.js", "application/javascript"); });
}
}

Expand Down Expand Up @@ -104,4 +109,4 @@ private Task HandleListPlugins(IHttpContext context)

#endregion
}
}
}
2 changes: 2 additions & 0 deletions code/server/scripting/EmoteSystem/Admin/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist/
node_modules/
3 changes: 3 additions & 0 deletions code/server/scripting/EmoteSystem/Admin/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import {Widget} from './src/widget';

export default Widget;
1 change: 0 additions & 1 deletion code/server/scripting/EmoteSystem/Admin/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 0 additions & 2 deletions code/server/scripting/EmoteSystem/Admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@
"version": "1.0.0",
"license": "MIT",
"author": "Rayshader",
"main": "widget.tsx",
"scripts": {
"postinstall": "vite build",
"dev": "vite",
"start": "vite",
"build": "vite build"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,39 +1,46 @@
import {Alert, Card, CardActionArea, Tooltip, Typography} from "@mui/material";
import {useEffect, useState} from "react";
import {useEffect, useRef, useState} from "react";

export default function Widget() {
interface EmoteDto {
readonly Username: string;
readonly Emote: string;
}

export function Widget() {
const [username, setUsername] = useState<string>('');
const [emote, setEmote] = useState<string>('');
const load = useRef<boolean>(false);

useEffect(() => handleRefresh(), [username, emote]);
useEffect(() => {
if (load.current) {
return;
}
load.current = true;
return handleRefresh();
}, []);

// NOTE: start an interval to automatically fetch last emote every X s/m/h.

// @ts-ignore
const getLastEmote = async () => {
const response = await fetch('/api/v1/plugins/emote/');
const getLastEmote = async (): Promise<EmoteDto | undefined> => {
const response: Response = await fetch('/api/v1/plugins/emote/');

if (!response.ok) {
return;
}
const json = await response.json();

return {
username: json.Username ?? '',
emote: json.Emote ?? '',
};
return await response.json() as EmoteDto | undefined;
};

const handleRefresh = () => {
// @ts-ignore
async function request() {
const data = await getLastEmote();
const data: EmoteDto | undefined = await getLastEmote();

if (lock) {
if (!data || lock) {
return;
}
setUsername(data.username);
setEmote(data.emote);
setUsername(data.Username);
setEmote(data.Emote);
}

let lock: boolean = false;
Expand All @@ -48,7 +55,7 @@ export default function Widget() {

return (
<Tooltip title="Click to refresh">
<Card>
<Card sx={{maxWidth: '200px'}}>
<CardActionArea onClick={handleRefresh}>
{isEmpty() ?
<Alert severity="info">
Expand Down
Loading

0 comments on commit bf67ea3

Please sign in to comment.