Skip to content

Commit

Permalink
Initial work on the plugin system.
Browse files Browse the repository at this point in the history
* Add the ability for Thorium Nova to host plugin assets. Closes #64

* Starting on the plugin editing UI

* Initial UI for the plugin configuration page.

* Basic plugin definition. Closes #58

* Implement the NetRequest system

This makes it possible to have clients request any data
using parameters. It's basically a glorified real-time live data
REST API over websockets, really.

The cool thing about it is that it's implemented with React Suspense
so to the developer, it behaves as if you were just calling a function.

Closes #59
  • Loading branch information
alexanderson1993 authored Oct 30, 2021
1 parent 0b178db commit 8866538
Show file tree
Hide file tree
Showing 18 changed files with 473 additions and 22 deletions.
2 changes: 2 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {WelcomeButtons} from "./components/WelcomeButtons";
import {FlightLobby} from "./components/FlightLobby";

const DocLayout = lazy(() => import("./docs"));
const Config = lazy(() => import("./pages/Config"));

const MainPage = () => {
// const {netSend} = useThorium();
Expand Down Expand Up @@ -64,6 +65,7 @@ function AppRoutes() {
<Route path="/components" element={<ComponentDemo />} />
<Route path="/releases" element={<Releases />} />
<Route path="/docs/*" element={<DocLayout />}></Route>
<Route path="/config/*" element={<Config />}></Route>
<Route path="*" element={<NoMatch />} />
</Routes>
);
Expand Down
2 changes: 2 additions & 0 deletions client/src/context/AppContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import useEasterEgg from "../hooks/useEasterEgg";
import {ErrorBoundary, FallbackProps} from "react-error-boundary";
import bg from "../images/background.jpg";
import {FaSpinner} from "react-icons/fa";
import {NetRequestData} from "./useNetRequest";

const Fallback: React.FC<FallbackProps> = ({error}) => {
return (
Expand Down Expand Up @@ -60,6 +61,7 @@ export default function AppContext({children}: {children: ReactNode}) {
<Suspense fallback={<LoadingSpinner />}>
<AlertDialog>
<ThoriumProvider>
<NetRequestData />
<Router>{children}</Router>
</ThoriumProvider>
</AlertDialog>
Expand Down
71 changes: 71 additions & 0 deletions client/src/context/useNetRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {useEffect} from "react";
import {
AllRequestNames,
AllRequestParams,
AllRequestReturns,
} from "server/src/netRequests";
import {proxy, useSnapshot} from "valtio";
import {NetResponseData} from "../hooks/useDataConnection";
import {stableValueHash} from "../utils/stableValueHash";
import {useThorium} from "./ThoriumContext";
import {useErrorHandler} from "react-error-boundary";
const netRequestProxy = proxy<Partial<{[requestId: string]: any}>>({});
const netRequestPromises: {[requestId: string]: (value: unknown) => void} = {};

export function NetRequestData() {
useNetRequestData();
return null;
}
function useNetRequestData() {
const {socket} = useThorium();
const handleError = useErrorHandler();

if (!socket) throw new Promise(() => {});
useEffect(() => {
function handleNetRequestData(data: NetResponseData) {
try {
if (typeof data !== "object") {
throw new Error(`netResponse data must be an object. Got "${data}"`);
}
if ("error" in data) {
throw new Error(data.error);
}
if (!("requestId" in data && "response" in data)) {
const dataString = JSON.stringify(data, null, 2);
throw new Error(
`netResponse data must include a requestId and a response. Got ${dataString}`
);
}
netRequestProxy[data.requestId] = data.response;
netRequestPromises[data.requestId]?.(null);
} catch (err) {
handleError(err);
}
}
socket.on("netRequestData", handleNetRequestData);
return () => {
socket.off("netRequestData", handleNetRequestData);
};
}, [socket, handleError]);
}

export function useNetRequest<
T extends AllRequestNames,
R extends AllRequestReturns[T]
>(requestName: T, params?: AllRequestParams[T]): R {
const requestId = stableValueHash({requestName, params});
const data = useSnapshot(netRequestProxy);
const {socket} = useThorium();
if (!socket) throw new Promise(() => {});

if (!data[requestId]) {
if (!netRequestPromises[requestId]) {
socket.send("netRequest", {requestName, params, requestId});
}
throw new Promise(res => {
netRequestPromises[requestId] = res;
});
}

return data[requestId];
}
161 changes: 161 additions & 0 deletions client/src/pages/Config/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import {usePrompt} from "@thorium/ui/AlertDialog";
import Button from "@thorium/ui/Button";
import InfoTip from "@thorium/ui/InfoTip";
import Input from "@thorium/ui/Input";
import Menubar from "@thorium/ui/Menubar";
import SearchableList from "@thorium/ui/SearchableList";
import TagInput from "@thorium/ui/TagInput";
import UploadWell from "@thorium/ui/UploadWell";
import {useNetSend} from "client/src/context/ThoriumContext";
import {useNetRequest} from "client/src/context/useNetRequest";
import {useState} from "react";
import {FaEdit} from "react-icons/fa";
import {NavLink, useNavigate, useParams} from "react-router-dom";

let plugin: null | {
id: string;
name: string;
description: string;
tags: string[];
} = null;
const setName = (params: any) => {};
const setDescription = (params: any) => {};
const setTags = (params: any) => {};
export default function Config() {
const [error, setError] = useState(false);
const data = useNetRequest("pluginsList");
const netSend = useNetSend();
const navigate = useNavigate();
const params = useParams();
const prompt = usePrompt();
return (
<div className="h-full">
<Menubar></Menubar>
<div className="p-8 h-[calc(100%-2rem)]">
<h1 className="font-bold text-white text-3xl mb-4">Plugin Config</h1>

<div className="flex gap-8 h-[calc(100%-3rem)]">
<div className="flex flex-col w-80 h-full">
<Button
className="w-full btn-sm btn-success"
onClick={async () => {
const name = await prompt({header: "Enter plugin name"});
if (typeof name !== "string") return;
const id = await netSend("pluginCreate", {name});
navigate(`/config/${id}/edit`);
}}
>
New Plugin
</Button>

<SearchableList
items={data.map(d => ({
id: d.id,
name: d.name,
description: d.description,
tags: d.tags,
author: d.author,
}))}
searchKeys={["name", "author", "tags"]}
selectedItem={params.pluginId || null}
setSelectedItem={id => navigate(`/config/${id}`)}
renderItem={c => (
<div className="flex justify-between items-center" key={c.id}>
<div>
{c.name}
<div>
<small>{c.author}</small>
</div>
</div>
<NavLink
{...{to: `/config/${c.id}/edit`}}
onClick={e => e.stopPropagation()}
>
<FaEdit />
</NavLink>
</div>
)}
/>
</div>
<div className="w-96 space-y-4">
<Input
className="pb-4"
label="Plugin Name"
defaultValue={plugin?.name}
isInvalid={error}
invalidMessage="Name is required"
onChange={() => setError(false)}
onBlur={(e: React.FocusEvent<Element>) => {
const target = e.target as HTMLInputElement;
plugin && target.value
? setName({
variables: {id: plugin.id, name: target.value},
})
: setError(true);
}}
/>
<Input
className="pb-4"
label="Description"
defaultValue={plugin?.description}
onChange={() => setError(false)}
onBlur={(e: React.FocusEvent<Element>) => {
const target = e.target as HTMLInputElement;
plugin && target.value
? setDescription({
variables: {id: plugin.id, description: target.value},
})
: setError(true);
}}
/>
<TagInput
label="Tags"
tags={plugin?.tags || []}
onAdd={tag => {
if (plugin?.tags.includes(tag) || !plugin) return;
setTags({
variables: {id: plugin.id, tags: plugin.tags.concat(tag)},
});
}}
onRemove={tag => {
if (!plugin) return;
setTags({
variables: {
id: plugin.id,
tags: plugin.tags.filter(t => t !== tag),
},
});
}}
/>
</div>
<div>
<label>
<span className="flex">
Cover Image{" "}
<InfoTip>
Used on the Thorium Plugin Registry. Images should be square
and at least 1024x1024 in size.
</InfoTip>
</span>
<UploadWell
accept="image/*"
onChange={(files: FileList) => {
// if (!plugin) return;
// setCoverImage({variables: {id: plugin?.id, image: files[0]}});
}}
>
{/* {plugin?.coverImage && (
<img
src={`${plugin.coverImage}?${new Date().getTime()}`}
className="w-[90%] h-[90%] object-cover"
alt="Cover"
/>
)} */}
</UploadWell>
</label>
</div>
</div>
</div>
</div>
);
}
Loading

0 comments on commit 8866538

Please sign in to comment.