[FEATURE] Basic System with file manager (#4)

Co-authored-by: dunemask <dunemask@gmail.com>
Co-authored-by: Dunemask <dunemask@gmail.com>
Reviewed-on: https://gitea.dunemask.dev/elysium/minecluster/pulls/4
This commit is contained in:
dunemask 2023-12-20 03:20:04 +00:00
parent 8fb5b34c77
commit 4f19cf19d9
62 changed files with 5910 additions and 1190 deletions

View file

@ -1,4 +1,6 @@
// Imports
import { ThemeProvider } from "@mui/material/styles";
import mclTheme from "./util/theme.js";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { SettingsProvider } from "@mcl/settings";
import Viewport from "./nav/Viewport.jsx";
@ -11,9 +13,11 @@ export default function MCL() {
<div className="minecluster">
<QueryClientProvider client={queryClient}>
<SettingsProvider>
<BrowserRouter>
<Viewport />
</BrowserRouter>
<ThemeProvider theme={mclTheme}>
<BrowserRouter>
<Viewport />
</BrowserRouter>
</ThemeProvider>
</SettingsProvider>
</QueryClientProvider>
</div>

View file

@ -0,0 +1,42 @@
// ChonkyFullFileBrowser.tsx
import { forwardRef, memo } from "react";
import {
StylesProvider,
createGenerateClassName,
} from "@material-ui/core/styles";
import {
FileBrowser,
FileList,
FileContextMenu,
FileNavbar,
FileToolbar,
setChonkyDefaults,
FileBrowserHandle,
FileBrowserProps,
} from "chonky";
import { ChonkyIconFA } from "chonky-icon-fontawesome";
setChonkyDefaults({ iconComponent: ChonkyIconFA });
const muiJSSClassNameGenerator = createGenerateClassName({
// Seed property is used to add a prefix classes generated by material ui.
seed: "chonky",
});
export default memo(
forwardRef((props, ref) => {
const { onScroll } = props;
return (
<StylesProvider generateClassName={muiJSSClassNameGenerator}>
<FileBrowser ref={ref} {...props}>
<FileNavbar />
<FileToolbar />
<FileList onScroll={onScroll} />
<FileContextMenu />
</FileBrowser>
</StylesProvider>
);
}),
);

View file

@ -0,0 +1,146 @@
import { useState, useEffect, useMemo, useRef } from "react";
import Box from "@mui/material/Box";
import {
FileBrowser,
FileContextMenu,
FileList,
FileNavbar,
FileToolbar,
setChonkyDefaults,
ChonkyActions,
} from "chonky";
import { ChonkyIconFA } from "chonky-icon-fontawesome";
import {
getServerFiles,
createServerFolder,
deleteServerItem,
getServerItem,
} from "@mcl/queries";
import "@mcl/css/header.css";
export default function MineclusterFiles(props) {
// Chonky configuration
setChonkyDefaults({ iconComponent: ChonkyIconFA });
const fileActions = useMemo(
() => [
ChonkyActions.CreateFolder,
ChonkyActions.UploadFiles,
ChonkyActions.DownloadFiles,
ChonkyActions.CopyFiles,
ChonkyActions.DeleteFiles,
],
[],
);
const { server: serverName } = props;
const inputRef = useRef(null);
const [dirStack, setDirStack] = useState(["."]);
const [files, setFiles] = useState([]);
const updateFiles = () =>
getServerFiles(serverName, dirStack.join("/")).then((f) =>
setFiles(f ?? []),
);
useEffect(() => {
updateFiles();
}, [dirStack]);
const getFolderChain = () => {
if (dirStack.length === 1) return [{ id: "home", name: "/", isDir: true }];
return dirStack.map((d, i) => ({ id: `${d}-${i}`, name: d, isDir: true }));
};
const openParentFolder = () => setDirStack(dirStack.slice(0, -1));
function openFolder(payload) {
const { targetFile: file } = payload;
if (!file || !file.isDir) return;
setDirStack([...dirStack, file.name]);
}
function createFolder() {
const name = prompt("What is the name of the new folder?");
const path = [...dirStack, name].join("/");
createServerFolder(serverName, path).then(updateFiles);
}
function deleteItems(files) {
Promise.all(
files.map((f) =>
deleteServerItem(serverName, [...dirStack, f.name].join("/"), f.isDir),
),
)
.catch((e) => console.error("Error deleting some files!", e))
.then(updateFiles);
}
function uploadFileSelection(e) {
if (!e.target.files || e.target.files.length === 0) return;
const { files } = e.target;
Promise.all([...files].map((f) => uploadFile(f)))
.catch((e) => console.log("Error uploading a file", e))
.then(updateFiles);
}
async function uploadFile(file) {
const formData = new FormData();
formData.append("file", file);
formData.append("name", serverName);
formData.append("path", [...dirStack, name].join("/"));
await fetch("/api/files/upload", {
method: "POST",
body: formData,
});
}
async function downloadFiles(files) {
Promise.all(
files.map((f) =>
getServerItem(serverName, f.name, [...dirStack, f.name].join("/")),
),
)
.then(() => console.log("Done"))
.catch((e) => console.error("Error Downloading files!", e));
}
function fileClick(chonkyEvent) {
const { id: clickEvent, payload } = chonkyEvent;
console.log(chonkyEvent);
if (clickEvent === "open_parent_folder") return openParentFolder();
if (clickEvent === "create_folder") return createFolder();
if (clickEvent === "upload_files") return inputRef.current.click();
if (clickEvent === "download_files")
return downloadFiles(chonkyEvent.state.selectedFilesForAction);
if (clickEvent === "delete_files")
return deleteItems(chonkyEvent.state.selectedFilesForAction);
if (clickEvent !== "open_files") return console.log(clickEvent);
openFolder(payload);
}
return (
<Box className="minecluster-files" sx={{ height: "calc(100vh - 6rem)" }}>
<input
type="file"
id="file"
ref={inputRef}
style={{ display: "none" }}
onChange={uploadFileSelection}
multiple
/>
<FileBrowser
files={files}
folderChain={getFolderChain()}
onFileAction={fileClick}
fileActions={fileActions}
darkMode={true}
>
<FileNavbar />
<FileToolbar />
<FileList />
<FileContextMenu />
</FileBrowser>
</Box>
);
}

View file

@ -14,6 +14,8 @@ import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import PendingIcon from "@mui/icons-material/Pending";
import DeleteForeverIcon from "@mui/icons-material/DeleteForever";
import EditIcon from "@mui/icons-material/Edit";
import FolderIcon from "@mui/icons-material/Folder";
import { Link } from "react-router-dom";
export default function ServerCard(props) {
const { server, openRcon } = props;
@ -94,9 +96,24 @@ export default function ServerCard(props) {
>
<TerminalIcon />
</IconButton>
<IconButton color="primary" aria-label="Edit" size="large">
<IconButton
color="primary"
aria-label="Edit"
size="large"
component={Link}
to={`/mcl/edit?server=${name}`}
>
<EditIcon />
</IconButton>
<IconButton
color="primary"
aria-label="Files"
size="large"
component={Link}
to={`/mcl/files?server=${name}`}
>
<FolderIcon />
</IconButton>
<IconButton
color="error"
aria-label="Delete Server"

31
src/css/header.css Normal file
View file

@ -0,0 +1,31 @@
.appbar-items {
font-size: 1.25rem;
font-family: "Roboto", "Helvetica", "Arial", sans-serif;
font-weight: 500;
line-height: 1.6;
letter-spacing: 0.0075em;
}
.view > header {
transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
box-shadow:
0px 2px 4px -1px rgba(0, 0, 0, 0.2),
0px 4px 5px 0px rgba(0, 0, 0, 0.14),
0px 1px 10px 0px rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
width: 100%;
box-sizing: border-box;
flex-shrink: 0;
position: fixed;
top: 0;
left: auto;
right: 0;
color: rgba(0, 0, 0, 0.87);
z-index: 1302;
background-color: black;
}
.view > header > div > div > a {
height: 40px;
width: 40px;
}

20
src/css/overview.css Normal file
View file

@ -0,0 +1,20 @@
.overview-toolbar {
background-color: rgba(255, 255, 255, 0.5);
height: 230px;
display: flex;
justify-content: center;
}
.overview-visual-display {
display: flex;
background-color: rgba(223, 223, 223, 0.5);
border-radius: 50%;
}
.overview-visual-label {
text-align: center;
}
.overview-visual-wrapper {
margin: 1rem;
}

9
src/css/rcon.css Normal file
View file

@ -0,0 +1,9 @@
.rconLogsWrapper {
overflow-y: scroll;
max-height: 20rem;
word-wrap: break-word;
margin-bottom: 10px;
}
.rconActions {
display: inline-flex;
}

82
src/css/server-card.css Normal file
View file

@ -0,0 +1,82 @@
.servers {
display: flex;
justify-content: center;
flex-wrap: wrap;
}
.server-card {
width: 400px;
min-height: 228px;
min-width: 250px;
max-width: 400px;
margin: 15px;
background-image: url("/images/server-backdrop.png");
background-size: cover;
display: flex;
flex-wrap: wrap;
}
.server-card-header {
padding: 0px;
display: inline-flex;
max-height: 32px;
height: 100%;
font-weight: bold;
text-transform: capitalize;
background-image: linear-gradient(
to right,
rgba(255, 255, 255, 1),
rgba(255, 255, 255, 0.1)
);
width: 100%;
}
.server-card-title {
width: 100%;
height: 100%;
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.server-card-status-indicator {
margin-left: auto;
margin-right: auto;
}
.server-card-actions-wrapper {
margin-top: auto;
justify-content: end;
width: 100%;
background-color: rgba(255, 255, 255, 0.9);
}
.server-card-actions {
display: flex;
justify-content: center;
flex-wrap: wrap;
padding: 0px;
}
.server-card-action {
position: relative;
height: 20%;
}
.server-card-element {
background-color: rgba(255, 255, 255, 0.5);
}
.server-card-metrics {
display: flex;
flex-wrap: wrap;
width: 100%;
}
.server-card-metrics-info {
display: inline-flex;
width: 100%;
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View file

@ -13,7 +13,7 @@ import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
import MenuIcon from "@mui/icons-material/Menu";
import Drawer from "@mui/material/Drawer";
import ListItemIcon from "@mui/material/ListItemIcon";
import HomeIcon from "@mui/icons-material/Home";
import ListItemText from "@mui/material/ListItemText";
import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton";
@ -36,48 +36,23 @@ export default function MCLMenu() {
theme.zIndex.modal + 2 - (isDrawer ? 1 : 0);
return (
<AppBar position="fixed" sx={{ bgcolor: "black", zIndex: drawerIndex() }}>
<Box sx={{ flexGrow: 1, margin: "0 20px" }}>
<AppBar
position="fixed"
color="primary"
sx={{ zIndex: drawerIndex(), bgcolor: "black" }}
enableColorOnDark={true}
>
<Box
sx={{ flexGrow: 1, margin: "0 20px", color: "white" }}
className="appbar-items"
>
<Toolbar disableGutters>
<IconButton
size="large"
edge="start"
color="inherit"
aria-label="menu"
sx={{ mr: 2 }}
onClick={toggleDrawer}
>
<MenuIcon />
<IconButton component={Link} to="/" color="inherit">
<HomeIcon />
</IconButton>
<Drawer
open={drawerOpen}
onClose={closeDrawer}
sx={{ zIndex: drawerIndex(true) }}
>
<Toolbar />
<Box
sx={{ width: drawerWidth, overflow: "auto" }}
role="presentation"
>
<List>
{pages.map((page, index) => (
<ListItemButton
key={index}
component={Link}
to={page.path}
selected={location.pathname === page.path}
onClick={closeDrawer}
>
<ListItemIcon>{page.icon}</ListItemIcon>
<ListItemText primary={page.name} />
</ListItemButton>
))}
</List>
</Box>
</Drawer>
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
<span style={{ margin: "auto 0", color: "inherit" }}>
{navHeader()}
</Typography>
</span>
</Toolbar>
</Box>
</AppBar>

View file

@ -1,5 +1,6 @@
import Home from "@mcl/pages/Home.jsx";
import Create from "@mcl/pages/Create.jsx";
import Files from "@mcl/pages/Files.jsx";
// Go To https://mui.com/material-ui/material-icons/ for more!
import HomeIcon from "@mui/icons-material/Home";
import AddIcon from "@mui/icons-material/Add";
@ -17,4 +18,10 @@ export default [
icon: <AddIcon />,
component: <Create />,
},
{
name: "Edit",
path: "/mcl/files",
icon: <AddIcon />,
component: <Files />,
},
];

View file

@ -1,6 +1,8 @@
import Box from "@mui/material/Box";
import Toolbar from "@mui/material/Toolbar";
import MCLPortal from "./MCLPortal.jsx";
import Button from "@mui/material/Button";
import SpeedDialIcon from "@mui/material/SpeedDialIcon";
// Import Navbar
/*import Navbar from "./Navbar.jsx";*/
import MCLMenu from "./MCLMenu.jsx";

View file

@ -1,152 +1,12 @@
import { useState, useEffect } from "react";
import TextField from "@mui/material/TextField";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Select from "@mui/material/Select";
import MenuItem from "@mui/material/MenuItem";
import InputLabel from "@mui/material/InputLabel";
import FormControl from "@mui/material/FormControl";
import { useCreateServer, useVersionList } from "@mcl/queries";
const defaultServer = {
version: "latest",
name: "example",
serverType: "VANILLA",
difficulty: "easy",
maxPlayers: "20",
gamemode: "survival",
memory: "1024",
motd: "Minecluster Server Hosting",
};
import CreateOptions from "./CreateOptions.jsx";
export default function Create() {
const [spec, setSpec] = useState(defaultServer);
const versionList = useVersionList();
const [versions, setVersions] = useState(["latest"]);
const createServer = useCreateServer(spec);
const updateSpec = (attr, val) => {
const s = { ...spec };
s[attr] = val;
setSpec(s);
};
useEffect(() => {
if (!versionList.data) return;
setVersions([
"latest",
...versionList.data.versions
.filter(({ type: releaseType }) => releaseType === "release")
.map(({ id }) => id),
]);
}, [versionList.data]);
const coreUpdate = (attr) => (e) => updateSpec(attr, e.target.value);
function upsertSpec() {
if (validateSpec() !== "validated") return;
createServer(spec);
}
function validateSpec() {
console.log("TODO CREATE VALIDATION");
if (!spec.name) return alertValidationError("Name not included");
if (!spec.version) return alertValidationError("Version cannot be blank");
if (!spec.url) return alertValidationError("Url cannot be blank");
return "validated";
}
function alertValidationError(reason) {
alert(`Could not validate spec because: ${reason}`);
}
return (
<Box className="create">
<FormControl fullWidth>
<TextField
label="Name"
onChange={coreUpdate("name")}
defaultValue={spec.name}
required
/>
<TextField label="URL" onChange={coreUpdate("url")} required />
<TextField
label="Version"
onChange={coreUpdate("version")}
value={spec.version}
select
required
SelectProps={{ MenuProps: { sx: { maxHeight: "12rem" } } }}
>
{versions.map((v, k) => (
<MenuItem value={v} key={k}>
{v}
</MenuItem>
))}
</TextField>
<TextField
label="Server Type"
onChange={coreUpdate("serverType")}
value={spec.serverType}
select
required
>
<MenuItem value={"VANILLA"}>Vanilla</MenuItem>
<MenuItem value={"FABRIC"}>Fabric</MenuItem>
<MenuItem value={"PAPER"}>Paper</MenuItem>
<MenuItem value={"SPIGOT"}>Spigot</MenuItem>
</TextField>
<TextField
label="Difficulty"
onChange={coreUpdate("difficulty")}
value={spec.difficulty}
select
required
>
<MenuItem value={"peaceful"}>Peaceful</MenuItem>
<MenuItem value={"easy"}>Easy</MenuItem>
<MenuItem value={"medium"}>Medium</MenuItem>
<MenuItem value={"hard"}>Hard</MenuItem>
</TextField>
<TextField label="Whitelist" onChange={coreUpdate("whitelist")} />
<TextField label="Ops" onChange={coreUpdate("ops")} />
<TextField label="Icon" onChange={coreUpdate("icon")} required />
<TextField
label="Max Players"
onChange={coreUpdate("maxPlayers")}
defaultValue={spec.maxPlayers}
required
/>
<TextField
label="Gamemode"
onChange={coreUpdate("gamemode")}
value={spec.gamemode}
select
required
>
<MenuItem value={"survival"}>Survival</MenuItem>
<MenuItem value={"creative"}>Creative</MenuItem>
<MenuItem value={"adventure"}>Adventure</MenuItem>
<MenuItem value={"spectator"}>Spectator</MenuItem>
</TextField>
<TextField label="Seed" onChange={coreUpdate("seed")} />
<TextField label="Modpack" onChange={coreUpdate("modpack")} />
<TextField
label="Memory"
onChange={coreUpdate("memory")}
defaultValue={spec.memory}
required
/>
<TextField
label="MOTD"
onChange={coreUpdate("motd")}
defaultValue={spec.motd}
required
/>
<Button onClick={upsertSpec} variant="contained">
Create
</Button>
</FormControl>
{/*<CreateMenu />*/}
<Box className="create-wrapper" sx={{ display: "flex" }}>
<CreateOptions />
</Box>
</Box>
);
}

248
src/pages/CreateOptions.jsx Normal file
View file

@ -0,0 +1,248 @@
import { useState, useEffect } from "react";
import Autocomplete from "@mui/material/Autocomplete";
import TextField from "@mui/material/TextField";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Chip from "@mui/material/Chip";
import Select from "@mui/material/Select";
import MenuItem from "@mui/material/MenuItem";
import InputLabel from "@mui/material/InputLabel";
import FormControl from "@mui/material/FormControl";
import { useCreateServer, useVersionList } from "@mcl/queries";
const defaultServer = {
version: "latest",
serverType: "VANILLA",
difficulty: "easy",
maxPlayers: "5",
gamemode: "survival",
memory: "512",
motd: `\\u00A7e\\u00A7ka\\u00A7l\\u00A7aMine\\u00A76Cluster\\u00A7r\\u00A78\\u00A7b\\u00A7ka`,
};
export default function Create() {
const [wl, setWl] = useState([]);
const [ops, setOps] = useState([]);
const [spec, setSpec] = useState(defaultServer);
const versionList = useVersionList();
const [versions, setVersions] = useState(["latest"]);
const createServer = useCreateServer(spec);
const updateSpec = (attr, val) => {
const s = { ...spec };
s[attr] = val;
setSpec(s);
console.log(s);
};
useEffect(() => {
if (!versionList.data) return;
setVersions([
"latest",
...versionList.data.versions
.filter(({ type: releaseType }) => releaseType === "release")
.map(({ id }) => id),
]);
}, [versionList.data]);
const coreUpdate = (attr) => (e) => updateSpec(attr, e.target.value);
function opsAdd(e) {
const opEntry = e.target.innerHTML ?? e.target.value;
if (!opEntry) return;
const newOps = [...ops, opEntry];
setOps(newOps);
updateSpec("ops", newOps.join(","));
}
function whitelistAdd(e) {
const wlEntry = e.target.value;
if (!wlEntry) return;
const newWl = [...wl, wlEntry];
setWl(newWl);
updateSpec("whitelist", newWl.join(","));
}
const opsRemove =
(name, { onDelete: updateAutoComplete }) =>
(e) => {
updateAutoComplete(e);
const newOps = [...ops];
const entryIndex = newOps.indexOf(name);
if (entryIndex === -1) return;
newOps.splice(entryIndex, 1);
setOps(newOps);
updateSpec("ops", newOps.join(","));
};
const whitelistRemove =
(name, { onDelete: updateAutocomplete }) =>
(e) => {
updateAutocomplete(e);
const newWl = [...wl];
const entryIndex = newWl.indexOf(name);
if (entryIndex === -1) return;
newWl.splice(entryIndex, 1);
setWl(newWl);
updateSpec("whitelist", newWl.join(","));
};
const opUpdate = (e) => alert("Op not implimented");
function upsertSpec() {
if (validateSpec() !== "validated") return;
createServer(spec);
}
function validateSpec() {
console.log("TODO CREATE VALIDATION");
if (!spec.name) return alertValidationError("Name not included");
if (!spec.version) return alertValidationError("Version cannot be blank");
if (!spec.host) return alertValidationError("Host cannot be blank");
return "validated";
}
function alertValidationError(reason) {
alert(`Could not validate spec because: ${reason}`);
}
return (
<Box
className="create-options"
sx={{ width: "100%", maxWidth: "600px", margin: "auto" }}
>
<FormControl fullWidth sx={{ mt: "2rem", display: "flex", gap: ".5rem" }}>
<TextField
label="Name"
onChange={coreUpdate("name")}
helperText="Example: My Survival World"
defaultValue={spec.name}
FormHelperTextProps={{ sx: { ml: 0 } }}
required
/>
<TextField
label="Host"
onChange={coreUpdate("host")}
helperText="Example: host.mc.example.com"
FormHelperTextProps={{ sx: { ml: 0 } }}
required
/>
<TextField
label="Version"
onChange={coreUpdate("version")}
value={spec.version}
select
required
SelectProps={{ MenuProps: { sx: { maxHeight: "20rem" } } }}
>
{versions.map((v, k) => (
<MenuItem value={v} key={k}>
{v}
</MenuItem>
))}
</TextField>
<TextField
label="Server Type"
onChange={coreUpdate("serverType")}
value={spec.serverType}
select
required
>
<MenuItem value={"VANILLA"}>Vanilla</MenuItem>
<MenuItem value={"FABRIC"}>Fabric</MenuItem>
<MenuItem value={"PAPER"}>Paper</MenuItem>
<MenuItem value={"SPIGOT"}>Spigot</MenuItem>
</TextField>
<TextField
label="Difficulty"
onChange={coreUpdate("difficulty")}
value={spec.difficulty}
select
required
>
<MenuItem value={"peaceful"}>Peaceful</MenuItem>
<MenuItem value={"easy"}>Easy</MenuItem>
<MenuItem value={"medium"}>Medium</MenuItem>
<MenuItem value={"hard"}>Hard</MenuItem>
</TextField>
<Autocomplete
multiple
id="whitelist-autocomplete"
options={[]}
onChange={whitelistAdd}
freeSolo
renderInput={(p) => <TextField {...p} label="Whitelist" />}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
const defaultChipProps = getTagProps({ index });
return (
<Chip
label={option}
{...defaultChipProps}
onDelete={whitelistRemove(option, defaultChipProps)}
/>
);
})
}
/>
<Autocomplete
filterSelectedOptions={true}
multiple
id="ops-autocomplete"
options={wl}
onChange={opsAdd}
renderInput={(p) => <TextField {...p} label="Ops" />}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
const defaultChipProps = getTagProps({ index });
return (
<Chip
label={option}
{...defaultChipProps}
onDelete={opsRemove(option, defaultChipProps)}
/>
);
})
}
/>
{/*<TextField label="Ops" onChange={coreUpdate("ops")} />*/}
{/*<TextField label="Icon" onChange={coreUpdate("icon")} required />*/}
<TextField
label="Max Players"
onChange={coreUpdate("maxPlayers")}
defaultValue={spec.maxPlayers}
required
/>
<TextField
label="Gamemode"
onChange={coreUpdate("gamemode")}
value={spec.gamemode}
select
required
>
<MenuItem value={"survival"}>Survival</MenuItem>
<MenuItem value={"creative"}>Creative</MenuItem>
<MenuItem value={"adventure"}>Adventure</MenuItem>
<MenuItem value={"spectator"}>Spectator</MenuItem>
</TextField>
<TextField label="Seed" onChange={coreUpdate("seed")} />
{/*<TextField label="Modpack" onChange={coreUpdate("modpack")} />*/}
<TextField
label="Memory"
onChange={coreUpdate("memory")}
defaultValue={spec.memory}
required
/>
<TextField
label="MOTD"
onChange={coreUpdate("motd")}
defaultValue={spec.motd}
required
/>
<Button onClick={upsertSpec} variant="contained">
Create
</Button>
</FormControl>
</Box>
);
}

20
src/pages/Files.jsx Normal file
View file

@ -0,0 +1,20 @@
import { useEffect } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Toolbar from "@mui/material/Toolbar";
import MineclusterFiles from "@mcl/components/files/MineclusterFiles.jsx";
export default function Files() {
const [searchParams] = useSearchParams();
const currentServer = searchParams.get("server");
const nav = useNavigate();
useEffect(() => {
if (!currentServer) nav("/");
}, [currentServer]);
return (
<Box className="edit" sx={{ height: "100%" }}>
<MineclusterFiles server={currentServer} />
</Box>
);
}

View file

@ -1,19 +1,25 @@
import { Link } from "react-router-dom";
import { useState, useEffect } from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import ServerCard from "../servers/ServerCard.jsx";
import RconDialog, { useRconDialog } from "../servers/RconDialog.jsx";
import Overview from "../overview/Overview.jsx";
import ServerCard from "@mcl/components/servers/ServerCard.jsx";
import RconDialog, {
useRconDialog,
} from "@mcl/components/servers/RconDialog.jsx";
import Overview from "@mcl/components/overview/Overview.jsx";
import Button from "@mui/material/Button";
import SpeedDialIcon from "@mui/material/SpeedDialIcon";
import "@mcl/css/server-card.css";
import "@mcl/css/overview.css";
import { useServerInstances } from "@mcl/queries";
export default function Home() {
const clusterMetrics = { cpu: 0, memory: 0 };
const [server, setServer] = useState();
const [servers, setServers] = useState([]);
const [rdOpen, rconToggle] = useRconDialog();
const { isLoading, data: serversData } = useServerInstances();
const { servers: serverInstances, clusterMetrics } = serversData ?? {};
const serverInstances = serversData ?? [];
useEffect(() => {
if (!serverInstances) return;
serverInstances.sort((a, b) => a.name.localeCompare(b.name));
@ -53,6 +59,23 @@ export default function Home() {
dialogToggle={rconToggle}
serverName={server}
/>
<Button
component={Link}
to="/mcl/create"
color="primary"
variant="contained"
sx={{
position: "absolute",
bottom: 16,
right: 16,
padding: "1rem",
borderRadius: "100%",
height: "4rem",
width: "4rem",
}}
>
<SpeedDialIcon />
</Button>
</Box>
);
}

View file

@ -2,6 +2,15 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
const fetchApi = (subPath) => async () =>
fetch(`/api${subPath}`).then((res) => res.json());
const fetchApiCore = async (subPath, json, method = "POST", jsonify = false) =>
fetch(`/api${subPath}`, {
method,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(json),
}).then((res) => (jsonify ? res.json() : res));
const fetchApiPost = (subPath, json) => async () =>
fetch(`/api${subPath}`, {
method: "POST",
@ -12,16 +21,16 @@ const fetchApiPost = (subPath, json) => async () =>
}).then((res) => res.json());
export const useServerStatus = (server) =>
useQuery(
[`server-status-${server}`],
fetchApiPost("/server/status", { name: server })
);
useQuery({
queryKey: [`server-status-${server}`],
queryFn: fetchApiPost("/server/status", { name: server }),
});
export const useServerMetrics = (server) =>
useQuery(
[`server-metrics-${server}`],
fetchApiPost("/server/metrics", { name: server }),
{ refetchInterval: 10000 }
);
useQuery({
queryKey: [`server-metrics-${server}`],
queryFn: fetchApiPost("/server/metrics", { name: server }),
refetchInterval: 10000,
});
export const useStartServer = (server) =>
postJsonApi("/server/start", { name: server }, "server-instances");
export const useStopServer = (server) =>
@ -30,20 +39,61 @@ export const useDeleteServer = (server) =>
postJsonApi("/server/delete", { name: server }, "server-instances", "DELETE");
export const useCreateServer = (spec) =>
postJsonApi("/server/create", spec, "server-list");
export const getServerFiles = async (server, path) =>
fetchApiCore("/files/list", { name: server, path }, "POST", true);
export const createServerFolder = async (server, path) =>
fetchApiCore("/files/folder", {
name: server,
path,
}); /*postJsonApi("/files/folder", {name: server, path});*/
export const deleteServerItem = async (server, path, isDir) =>
fetchApiCore("/files/item", { name: server, path, isDir }, "DELETE");
export const getServerItem = async (server, name, path) =>
fetchApiCore("/files/item", { name: server, path })
.then((resp) =>
resp.status === 200
? resp.blob()
: Promise.reject("something went wrong"),
)
.then((blob) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.style.display = "none";
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
});
export const useInvalidator = () => {
const qc = useQueryClient();
return (q) => qc.invalidateQueries([q]);
};
export const useServerList = () =>
useQuery(["server-list"], fetchApi("/server/list"));
useQuery({ queryKey: ["server-list"], queryFn: fetchApi("/server/list") });
export const useServerInstances = () =>
useQuery(["server-instances"], fetchApi("/server/instances"), {
useQuery({
queryKey: ["server-instances"],
queryFn: fetchApi("/server/instances"),
refetchInterval: 5000,
});
export const useSystemAvailable = () =>
useQuery(["system-available"], fetchApi("/system/available"));
useQuery({
queryKey: ["system-available"],
queryFn: fetchApi("/system/available"),
});
export const useVersionList = () =>
useQuery(["minecraft-versions"], () =>
fetch("https://piston-meta.mojang.com/mc/game/version_manifest.json").then(
(r) => r.json()
)
);
useQuery({
queryKey: ["minecraft-versions"],
queryFn: () =>
fetch(
"https://piston-meta.mojang.com/mc/game/version_manifest.json",
).then((r) => r.json()),
});
const postJsonApi = (subPath, body, invalidate, method = "POST") => {
const qc = useQueryClient();
@ -55,6 +105,7 @@ const postJsonApi = (subPath, body, invalidate, method = "POST") => {
},
body: JSON.stringify(body),
});
qc.invalidateQueries([invalidate]);
if (invalidate) qc.invalidateQueries([invalidate]);
return res.json();
};
};

16
src/util/theme.js Normal file
View file

@ -0,0 +1,16 @@
// Generated using https://zenoo.github.io/mui-theme-creator/
import { createTheme } from "@mui/material/styles";
const themeOptions = {
palette: {
mode: "light",
primary: {
main: "rgba(109,216,144,255)",
},
secondary: {
main: "#f50057",
},
},
};
export default createTheme(themeOptions);