Can upload ISO files
This commit is contained in:
@ -12,6 +12,7 @@ import { BaseLoginPage } from "./widgets/BaseLoginPage";
|
||||
import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
|
||||
import { LoginRoute } from "./routes/auth/LoginRoute";
|
||||
import { AuthApi } from "./api/AuthApi";
|
||||
import { IsoFilesRoute } from "./routes/IsoFilesRoute";
|
||||
|
||||
interface AuthContext {
|
||||
signedIn: boolean;
|
||||
@ -32,6 +33,7 @@ export function App() {
|
||||
createRoutesFromElements(
|
||||
signedIn ? (
|
||||
<Route path="*" element={<BaseAuthenticatedPage />}>
|
||||
<Route path="iso" element={<IsoFilesRoute />} />
|
||||
<Route path="*" element={<NotFoundRoute />} />
|
||||
</Route>
|
||||
) : (
|
||||
|
@ -1,5 +1,14 @@
|
||||
import { AuthApi } from "./AuthApi";
|
||||
|
||||
interface RequestParams {
|
||||
uri: string;
|
||||
method: "GET" | "POST" | "DELETE" | "PATCH" | "PUT";
|
||||
allowFail?: boolean;
|
||||
jsonData?: any;
|
||||
formData?: FormData;
|
||||
progress?: (progress: number) => void;
|
||||
}
|
||||
|
||||
interface APIResponse {
|
||||
data: any;
|
||||
status: number;
|
||||
@ -32,14 +41,8 @@ export class APIClient {
|
||||
/**
|
||||
* Perform a request on the backend
|
||||
*/
|
||||
static async exec(args: {
|
||||
uri: string;
|
||||
method: "GET" | "POST" | "DELETE" | "PATCH" | "PUT";
|
||||
allowFail?: boolean;
|
||||
jsonData?: any;
|
||||
formData?: FormData;
|
||||
}): Promise<APIResponse> {
|
||||
let body = undefined;
|
||||
static async exec(args: RequestParams): Promise<APIResponse> {
|
||||
let body: string | undefined | FormData = undefined;
|
||||
let headers: any = {};
|
||||
|
||||
// JSON request
|
||||
@ -53,31 +56,71 @@ export class APIClient {
|
||||
body = args.formData;
|
||||
}
|
||||
|
||||
const res = await fetch(this.backendURL() + args.uri, {
|
||||
method: args.method,
|
||||
body: body,
|
||||
headers: headers,
|
||||
credentials: "include",
|
||||
});
|
||||
const url = this.backendURL() + args.uri;
|
||||
|
||||
// Process response
|
||||
let data;
|
||||
if (res.headers.get("content-type") === "application/json")
|
||||
data = await res.json();
|
||||
else data = await res.blob();
|
||||
let status: number;
|
||||
|
||||
// Make the request with XMLHttpRequest
|
||||
if (args.progress) {
|
||||
const res: XMLHttpRequest = await new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.upload.addEventListener("progress", (e) =>
|
||||
args.progress!(e.loaded / e.total)
|
||||
);
|
||||
xhr.addEventListener("load", () => resolve(xhr));
|
||||
xhr.addEventListener("error", () =>
|
||||
reject(new Error("File upload failed"))
|
||||
);
|
||||
xhr.addEventListener("abort", () =>
|
||||
reject(new Error("File upload aborted"))
|
||||
);
|
||||
xhr.addEventListener("timeout", () =>
|
||||
reject(new Error("File upload timeout"))
|
||||
);
|
||||
xhr.open(args.method, url, true);
|
||||
xhr.withCredentials = true;
|
||||
for (const key in headers) {
|
||||
if (headers.hasOwnProperty(key))
|
||||
xhr.setRequestHeader(key, headers[key]);
|
||||
}
|
||||
xhr.send(body);
|
||||
});
|
||||
|
||||
status = res.status;
|
||||
if (res.responseType === "json") data = JSON.parse(res.responseText);
|
||||
else data = res.response;
|
||||
}
|
||||
|
||||
// Make the request with fetch
|
||||
else {
|
||||
const res = await fetch(url, {
|
||||
method: args.method,
|
||||
body: body,
|
||||
headers: headers,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
// Process response
|
||||
if (res.headers.get("content-type") === "application/json")
|
||||
data = await res.json();
|
||||
else data = await res.blob();
|
||||
|
||||
status = res.status;
|
||||
}
|
||||
|
||||
// Handle expired tokens
|
||||
if (res.status === 412) {
|
||||
if (status === 412) {
|
||||
AuthApi.UnsetAuthenticated();
|
||||
window.location.href = "/";
|
||||
}
|
||||
|
||||
if (!args.allowFail && !res.ok)
|
||||
throw new ApiError("Request failed!", res.status, data);
|
||||
if (!args.allowFail && (status < 200 || status > 299))
|
||||
throw new ApiError("Request failed!", status, data);
|
||||
|
||||
return {
|
||||
data: data,
|
||||
status: res.status,
|
||||
status: status,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
21
virtweb_frontend/src/api/IsoFilesApi.ts
Normal file
21
virtweb_frontend/src/api/IsoFilesApi.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { APIClient } from "./ApiClient";
|
||||
|
||||
export class IsoFilesApi {
|
||||
/**
|
||||
* Upload a new ISO file to the server
|
||||
*/
|
||||
static async Upload(
|
||||
file: File,
|
||||
progress: (progress: number) => void
|
||||
): Promise<void> {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
|
||||
await APIClient.exec({
|
||||
method: "POST",
|
||||
uri: "/iso/upload",
|
||||
formData: fd,
|
||||
progress: progress,
|
||||
});
|
||||
}
|
||||
}
|
@ -3,6 +3,8 @@ import { APIClient } from "./ApiClient";
|
||||
export interface ServerConfig {
|
||||
local_auth_enabled: boolean;
|
||||
oidc_auth_enabled: boolean;
|
||||
iso_mimetypes: string[];
|
||||
iso_max_size: number;
|
||||
}
|
||||
|
||||
let config: ServerConfig | null = null;
|
||||
|
68
virtweb_frontend/src/hooks/providers/AlertDialogProvider.tsx
Normal file
68
virtweb_frontend/src/hooks/providers/AlertDialogProvider.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
} from "@mui/material";
|
||||
import React, { PropsWithChildren } from "react";
|
||||
|
||||
type AlertContext = (message: string, title?: string) => Promise<void>;
|
||||
|
||||
const AlertContextK = React.createContext<AlertContext | null>(null);
|
||||
|
||||
export function AlertDialogProvider(p: PropsWithChildren): React.ReactElement {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const [title, setTitle] = React.useState<string | undefined>(undefined);
|
||||
const [message, setMessage] = React.useState("");
|
||||
|
||||
const cb = React.useRef<null | (() => void)>(null);
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
|
||||
if (cb.current !== null) cb.current();
|
||||
cb.current = null;
|
||||
};
|
||||
|
||||
const hook: AlertContext = (message, title) => {
|
||||
setTitle(title);
|
||||
setMessage(message);
|
||||
setOpen(true);
|
||||
|
||||
return new Promise((res) => {
|
||||
cb.current = res;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertContextK.Provider value={hook}>{p.children}</AlertContextK.Provider>
|
||||
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
aria-labelledby="alert-dialog-title"
|
||||
aria-describedby="alert-dialog-description"
|
||||
>
|
||||
{title && <DialogTitle id="alert-dialog-title">{title}</DialogTitle>}
|
||||
<DialogContent>
|
||||
<DialogContentText id="alert-dialog-description">
|
||||
{message}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} autoFocus>
|
||||
Ok
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAlert(): AlertContext {
|
||||
return React.useContext(AlertContextK)!;
|
||||
}
|
43
virtweb_frontend/src/hooks/providers/SnackbarProvider.tsx
Normal file
43
virtweb_frontend/src/hooks/providers/SnackbarProvider.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { Snackbar } from "@mui/material";
|
||||
|
||||
import React, { PropsWithChildren } from "react";
|
||||
|
||||
type SnackbarContext = (message: string, duration?: number) => void;
|
||||
|
||||
const SnackbarContextK = React.createContext<SnackbarContext | null>(null);
|
||||
|
||||
export function SnackbarProvider(p: PropsWithChildren): React.ReactElement {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const [message, setMessage] = React.useState("");
|
||||
const [duration, setDuration] = React.useState(0);
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const hook: SnackbarContext = (message, duration) => {
|
||||
setMessage(message);
|
||||
setDuration(duration ?? 6000);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SnackbarContextK.Provider value={hook}>
|
||||
{p.children}
|
||||
</SnackbarContextK.Provider>
|
||||
|
||||
<Snackbar
|
||||
open={open}
|
||||
autoHideDuration={duration}
|
||||
onClose={handleClose}
|
||||
message={message}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSnackbar(): SnackbarContext {
|
||||
return React.useContext(SnackbarContextK)!;
|
||||
}
|
@ -11,6 +11,8 @@ import reportWebVitals from "./reportWebVitals";
|
||||
import { LoadServerConfig } from "./widgets/LoadServerConfig";
|
||||
import { ThemeProvider, createTheme } from "@mui/material";
|
||||
import { LoadingMessageProvider } from "./hooks/providers/LoadingMessageProvider";
|
||||
import { AlertDialogProvider } from "./hooks/providers/AlertDialogProvider";
|
||||
import { SnackbarProvider } from "./hooks/providers/SnackbarProvider";
|
||||
|
||||
const darkTheme = createTheme({
|
||||
palette: {
|
||||
@ -24,11 +26,15 @@ const root = ReactDOM.createRoot(
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={darkTheme}>
|
||||
<LoadingMessageProvider>
|
||||
<LoadServerConfig>
|
||||
<App />
|
||||
</LoadServerConfig>
|
||||
</LoadingMessageProvider>
|
||||
<AlertDialogProvider>
|
||||
<SnackbarProvider>
|
||||
<LoadingMessageProvider>
|
||||
<LoadServerConfig>
|
||||
<App />
|
||||
</LoadServerConfig>
|
||||
</LoadingMessageProvider>
|
||||
</SnackbarProvider>{" "}
|
||||
</AlertDialogProvider>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
90
virtweb_frontend/src/routes/IsoFilesRoute.tsx
Normal file
90
virtweb_frontend/src/routes/IsoFilesRoute.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { Button, LinearProgress, Typography } from "@mui/material";
|
||||
import { filesize } from "filesize";
|
||||
import { MuiFileInput } from "mui-file-input";
|
||||
import React from "react";
|
||||
import { IsoFilesApi } from "../api/IsoFilesApi";
|
||||
import { ServerApi } from "../api/ServerApi";
|
||||
import { useAlert } from "../hooks/providers/AlertDialogProvider";
|
||||
import { useSnackbar } from "../hooks/providers/SnackbarProvider";
|
||||
import { VirtWebPaper } from "../widgets/VirtWebPaper";
|
||||
import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
|
||||
|
||||
export function IsoFilesRoute(): React.ReactElement {
|
||||
return (
|
||||
<VirtWebRouteContainer label="ISO files management">
|
||||
<UploadIsoFileForm onFileUploaded={() => alert("file uploaded!")} />
|
||||
</VirtWebRouteContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadIsoFileForm(p: {
|
||||
onFileUploaded: () => void;
|
||||
}): React.ReactElement {
|
||||
const alert = useAlert();
|
||||
const snackbar = useSnackbar();
|
||||
|
||||
const [value, setValue] = React.useState<File | null>(null);
|
||||
const [uploadProgress, setUploadProgress] = React.useState<number | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const handleChange = (newValue: File | null) => {
|
||||
if (newValue && newValue.size > ServerApi.Config.iso_max_size) {
|
||||
alert(
|
||||
`The file is too big (max size allowed: ${filesize(
|
||||
ServerApi.Config.iso_max_size
|
||||
)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newValue && !ServerApi.Config.iso_mimetypes.includes(newValue.type)) {
|
||||
alert(`Selected file mimetype is not allowed! (${newValue.type})`);
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(newValue);
|
||||
};
|
||||
|
||||
const upload = async () => {
|
||||
try {
|
||||
setUploadProgress(0);
|
||||
await IsoFilesApi.Upload(value!, setUploadProgress);
|
||||
|
||||
setValue(null);
|
||||
snackbar("The file was successfully uploaded!");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
await alert("Failed to perform file upload! " + e);
|
||||
}
|
||||
|
||||
setUploadProgress(null);
|
||||
};
|
||||
|
||||
if (uploadProgress !== null) {
|
||||
return (
|
||||
<VirtWebPaper label="File upload">
|
||||
<Typography variant="body1">
|
||||
Upload in progress ({Math.floor(uploadProgress * 100)}%)...
|
||||
</Typography>
|
||||
<LinearProgress variant="determinate" value={uploadProgress * 100} />
|
||||
</VirtWebPaper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VirtWebPaper label="File upload">
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<span style={{ width: "10px" }}></span>
|
||||
<MuiFileInput
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
style={{ flex: 1 }}
|
||||
inputProps={{ accept: ServerApi.Config.iso_mimetypes.join(",") }}
|
||||
/>
|
||||
|
||||
{value && <Button onClick={upload}>Upload file</Button>}
|
||||
</div>
|
||||
</VirtWebPaper>
|
||||
);
|
||||
}
|
18
virtweb_frontend/src/widgets/VirtWebPaper.tsx
Normal file
18
virtweb_frontend/src/widgets/VirtWebPaper.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { Paper, Typography } from "@mui/material";
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
export function VirtWebPaper(
|
||||
p: { label: string } & PropsWithChildren
|
||||
): React.ReactElement {
|
||||
return (
|
||||
<Paper elevation={2} style={{ padding: "10px" }}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
style={{ marginBottom: "10px", fontWeight: "bold" }}
|
||||
>
|
||||
{p.label}
|
||||
</Typography>
|
||||
{p.children}
|
||||
</Paper>
|
||||
);
|
||||
}
|
18
virtweb_frontend/src/widgets/VirtWebRouteContainer.tsx
Normal file
18
virtweb_frontend/src/widgets/VirtWebRouteContainer.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { Typography } from "@mui/material";
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
export function VirtWebRouteContainer(
|
||||
p: {
|
||||
label: string;
|
||||
} & PropsWithChildren
|
||||
): React.ReactElement {
|
||||
return (
|
||||
<div style={{ margin: "50px" }}>
|
||||
<Typography variant="h4" style={{ marginBottom: "20px" }}>
|
||||
{p.label}
|
||||
</Typography>
|
||||
|
||||
{p.children}
|
||||
</div>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user