Can upload ISO files
This commit is contained in:
77
virtweb_frontend/package-lock.json
generated
77
virtweb_frontend/package-lock.json
generated
@ -22,6 +22,8 @@
|
||||
"@types/node": "^16.18.48",
|
||||
"@types/react": "^18.2.21",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"filesize": "^10.0.12",
|
||||
"mui-file-input": "^3.0.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.15.0",
|
||||
@ -8472,11 +8474,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/filesize": {
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz",
|
||||
"integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==",
|
||||
"version": "10.0.12",
|
||||
"resolved": "https://registry.npmjs.org/filesize/-/filesize-10.0.12.tgz",
|
||||
"integrity": "sha512-6RS9gDchbn+qWmtV2uSjo5vmKizgfCQeb5jKmqx8HyzA3MoLqqyQxN+QcjkGBJt7FjJ9qFce67Auyya5rRRbpw==",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
"node": ">= 10.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
@ -12673,6 +12675,39 @@
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
|
||||
},
|
||||
"node_modules/mui-file-input": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mui-file-input/-/mui-file-input-3.0.1.tgz",
|
||||
"integrity": "sha512-02MtRtyPALqcQubpMdBVhScNK9T28GBcjJwv/Wg33hjJeEQzYaCiVC5OIjMO8WFoH5gEa+lYSfZAZ4rnn5r5cw==",
|
||||
"dependencies": {
|
||||
"pretty-bytes": "^6.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/react": "^11.5.0",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
"@mui/icons-material": "^5.0.0",
|
||||
"@mui/material": "^5.0.0",
|
||||
"@types/react": "^18.0.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/mui-file-input/node_modules/pretty-bytes": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
|
||||
"integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==",
|
||||
"engines": {
|
||||
"node": "^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/multicast-dns": {
|
||||
"version": "7.2.5",
|
||||
"resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz",
|
||||
@ -14886,6 +14921,14 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dev-utils/node_modules/filesize": {
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz",
|
||||
"integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dev-utils/node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
@ -23995,9 +24038,9 @@
|
||||
}
|
||||
},
|
||||
"filesize": {
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz",
|
||||
"integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ=="
|
||||
"version": "10.0.12",
|
||||
"resolved": "https://registry.npmjs.org/filesize/-/filesize-10.0.12.tgz",
|
||||
"integrity": "sha512-6RS9gDchbn+qWmtV2uSjo5vmKizgfCQeb5jKmqx8HyzA3MoLqqyQxN+QcjkGBJt7FjJ9qFce67Auyya5rRRbpw=="
|
||||
},
|
||||
"fill-range": {
|
||||
"version": "7.0.1",
|
||||
@ -27010,6 +27053,21 @@
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
|
||||
},
|
||||
"mui-file-input": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mui-file-input/-/mui-file-input-3.0.1.tgz",
|
||||
"integrity": "sha512-02MtRtyPALqcQubpMdBVhScNK9T28GBcjJwv/Wg33hjJeEQzYaCiVC5OIjMO8WFoH5gEa+lYSfZAZ4rnn5r5cw==",
|
||||
"requires": {
|
||||
"pretty-bytes": "^6.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"pretty-bytes": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
|
||||
"integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"multicast-dns": {
|
||||
"version": "7.2.5",
|
||||
"resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz",
|
||||
@ -28422,6 +28480,11 @@
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="
|
||||
},
|
||||
"filesize": {
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz",
|
||||
"integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ=="
|
||||
},
|
||||
"has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
|
@ -17,6 +17,8 @@
|
||||
"@types/node": "^16.18.48",
|
||||
"@types/react": "^18.2.21",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"filesize": "^10.0.12",
|
||||
"mui-file-input": "^3.0.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.15.0",
|
||||
|
@ -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