Merge pull request 'AppState context add' (#382) from feat/join-workflow into main
This commit is contained in:
BIN
pkgs/ui/public/clan-dark.png
Normal file
BIN
pkgs/ui/public/clan-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
BIN
pkgs/ui/public/clan-white.png
Normal file
BIN
pkgs/ui/public/clan-white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
@@ -1,61 +0,0 @@
|
||||
"use client";
|
||||
import { RecentActivity } from "@/components/dashboard/activity";
|
||||
import { AppOverview } from "@/components/dashboard/appOverview";
|
||||
import { NetworkOverview } from "@/components/dashboard/NetworkOverview";
|
||||
import { Notifications } from "@/components/dashboard/notifications";
|
||||
import { QuickActions } from "@/components/dashboard/quickActions";
|
||||
import { TaskQueue } from "@/components/dashboard/taskQueue";
|
||||
import { tw } from "@/utils/tailwind";
|
||||
|
||||
interface DashboardCardProps {
|
||||
children?: React.ReactNode;
|
||||
rowSpan?: number;
|
||||
sx?: string;
|
||||
}
|
||||
const DashboardCard = (props: DashboardCardProps) => {
|
||||
const { children, rowSpan, sx = "" } = props;
|
||||
return (
|
||||
<div
|
||||
className={tw`col-span-full row-span-${rowSpan || 1} xl:col-span-1 ${sx}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DashboardPanelProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
const DashboardPanel = (props: DashboardPanelProps) => {
|
||||
const { children } = props;
|
||||
return (
|
||||
<div className="col-span-full row-span-1 xl:col-span-2">{children}</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Dashboard() {
|
||||
return (
|
||||
<div className="flex h-screen w-full">
|
||||
<div className="grid w-full auto-rows-max grid-cols-1 grid-rows-none gap-4 xl:grid-cols-2 2xl:grid-cols-3 ">
|
||||
<DashboardCard rowSpan={2}>
|
||||
<NetworkOverview />
|
||||
</DashboardCard>
|
||||
<DashboardCard rowSpan={2}>
|
||||
<RecentActivity />
|
||||
</DashboardCard>
|
||||
<DashboardCard>
|
||||
<Notifications />
|
||||
</DashboardCard>
|
||||
<DashboardCard>
|
||||
<QuickActions />
|
||||
</DashboardCard>
|
||||
<DashboardPanel>
|
||||
<AppOverview />
|
||||
</DashboardPanel>
|
||||
<DashboardCard sx={tw`xl:col-span-full 2xl:col-span-1`}>
|
||||
<TaskQueue />
|
||||
</DashboardCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
pkgs/ui/src/app/join/page.tsx
Normal file
5
pkgs/ui/src/app/join/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import JoinPrequel from "@/views/joinPrequel";
|
||||
|
||||
export default function Page() {
|
||||
return <JoinPrequel />;
|
||||
}
|
||||
@@ -4,13 +4,14 @@ import "./globals.css";
|
||||
import localFont from "next/font/local";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Button,
|
||||
CssBaseline,
|
||||
IconButton,
|
||||
ThemeProvider,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import { ChangeEvent, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { StyledEngineProvider } from "@mui/material/styles";
|
||||
|
||||
@@ -20,7 +21,14 @@ import MenuIcon from "@mui/icons-material/Menu";
|
||||
import Image from "next/image";
|
||||
import { tw } from "@/utils/tailwind";
|
||||
import axios from "axios";
|
||||
import { MachineContextProvider } from "@/components/hooks/useMachines";
|
||||
|
||||
import {
|
||||
AppContext,
|
||||
WithAppState,
|
||||
// useAppState,
|
||||
} from "@/components/hooks/useAppContext";
|
||||
import Background from "@/components/background";
|
||||
// import { usePathname, redirect } from "next/navigation";
|
||||
|
||||
const roboto = localFont({
|
||||
src: [
|
||||
@@ -37,6 +45,17 @@ axios.defaults.baseURL = "http://localhost:2979";
|
||||
// add negative margin for smooth transition to fill the space of the sidebar
|
||||
const translate = tw`lg:-ml-64 -ml-14`;
|
||||
|
||||
// const AutoRedirectEffect = () => {
|
||||
// const { isLoading, data } = useAppState();
|
||||
// const pathname = usePathname();
|
||||
// React.useEffect(() => {
|
||||
// if (!isLoading && !data.isJoined && pathname !== "/") {
|
||||
// redirect("/");
|
||||
// }
|
||||
// }, [isLoading, data, pathname]);
|
||||
// return <></>;
|
||||
// };
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
@@ -65,9 +84,9 @@ export default function RootLayout({
|
||||
}
|
||||
}, [userPrefersDarkmode, useDarkTheme, setUseDarkTheme]);
|
||||
|
||||
const changeThemeHandler = (target: ChangeEvent, currentValue: boolean) => {
|
||||
setUseDarkTheme(currentValue);
|
||||
};
|
||||
// const changeThemeHandler = (target: ChangeEvent, currentValue: boolean) => {
|
||||
// setUseDarkTheme(currentValue);
|
||||
// };
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
@@ -82,15 +101,25 @@ export default function RootLayout({
|
||||
<body id="__next" className={roboto.className}>
|
||||
<CssBaseline />
|
||||
<Toaster />
|
||||
<MachineContextProvider>
|
||||
<WithAppState>
|
||||
<AppContext.Consumer>
|
||||
{(appState) => {
|
||||
const showSidebarDerived = Boolean(
|
||||
showSidebar &&
|
||||
!appState.isLoading &&
|
||||
appState.data.isJoined,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Background />
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<Sidebar
|
||||
show={showSidebar}
|
||||
show={showSidebarDerived}
|
||||
onClose={() => setShowSidebar(false)}
|
||||
/>
|
||||
<div
|
||||
className={tw`${
|
||||
!showSidebar && translate
|
||||
!showSidebarDerived && translate
|
||||
} flex h-full w-full flex-col overflow-y-scroll transition-[margin] duration-150 ease-in-out`}
|
||||
>
|
||||
<div className="static top-0 mb-2 py-2">
|
||||
@@ -100,10 +129,12 @@ export default function RootLayout({
|
||||
hidden={true}
|
||||
onClick={() => setShowSidebar((c) => !c)}
|
||||
>
|
||||
{!showSidebar && <MenuIcon />}
|
||||
{!showSidebar && appState.data.isJoined && (
|
||||
<MenuIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="col-span-1 block w-full text-center font-semibold text-white lg:hidden ">
|
||||
<div className="col-span-1 block w-full bg-fixed text-center font-semibold text-white lg:hidden">
|
||||
<Image
|
||||
src="/logo.svg"
|
||||
alt="Clan Logo"
|
||||
@@ -117,12 +148,31 @@ export default function RootLayout({
|
||||
|
||||
<div className="px-1">
|
||||
<div className="relative flex h-full flex-1 flex-col">
|
||||
<main>{children}</main>
|
||||
<main>
|
||||
{/* <AutoRedirectEffect /> */}
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
appState.setAppState((s) => ({
|
||||
...s,
|
||||
isJoined: !s.isJoined,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
Toggle Joined
|
||||
</Button>
|
||||
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MachineContextProvider>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</AppContext.Consumer>
|
||||
</WithAppState>
|
||||
</body>
|
||||
</ThemeProvider>
|
||||
</StyledEngineProvider>
|
||||
|
||||
@@ -1,79 +1,86 @@
|
||||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
Input,
|
||||
InputAdornment,
|
||||
Paper,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useInspectFlake } from "@/api/default/default";
|
||||
import { ConfirmVM } from "@/components/join/confirmVM";
|
||||
import { RecentActivity } from "@/components/dashboard/activity";
|
||||
import { AppOverview } from "@/components/dashboard/appOverview";
|
||||
import { NetworkOverview } from "@/components/dashboard/NetworkOverview";
|
||||
import { Notifications } from "@/components/dashboard/notifications";
|
||||
import { QuickActions } from "@/components/dashboard/quickActions";
|
||||
import { TaskQueue } from "@/components/dashboard/taskQueue";
|
||||
import { useAppState } from "@/components/hooks/useAppContext";
|
||||
import { MachineContextProvider } from "@/components/hooks/useMachines";
|
||||
import { LoadingOverlay } from "@/components/join/loadingOverlay";
|
||||
import { FlakeBadge } from "@/components/flakeBadge/flakeBadge";
|
||||
import { Log } from "@/components/join/log";
|
||||
import { tw } from "@/utils/tailwind";
|
||||
import JoinPrequel from "@/views/joinPrequel";
|
||||
|
||||
import { useForm, SubmitHandler, Controller } from "react-hook-form";
|
||||
import { Confirm } from "@/components/join/confirm";
|
||||
import { Layout } from "@/components/join/layout";
|
||||
import { ChevronRight } from "@mui/icons-material";
|
||||
|
||||
type FormValues = {
|
||||
flakeUrl: string;
|
||||
flakeAttribute: string;
|
||||
interface DashboardCardProps {
|
||||
children?: React.ReactNode;
|
||||
rowSpan?: number;
|
||||
sx?: string;
|
||||
}
|
||||
const DashboardCard = (props: DashboardCardProps) => {
|
||||
const { children, rowSpan, sx = "" } = props;
|
||||
return (
|
||||
<div
|
||||
className={tw`col-span-full row-span-${rowSpan || 1} xl:col-span-1 ${sx}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const queryParams = useSearchParams();
|
||||
const flakeUrl = queryParams.get("flake") || "";
|
||||
const flakeAttribute = queryParams.get("attr") || "default";
|
||||
const { handleSubmit, control, formState, getValues, reset } =
|
||||
useForm<FormValues>({ defaultValues: { flakeUrl: "" } });
|
||||
|
||||
const onSubmit: SubmitHandler<FormValues> = (data) => console.log(data);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
{!formState.isSubmitted && !flakeUrl && (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="w-full max-w-2xl justify-self-center"
|
||||
>
|
||||
<Controller
|
||||
name="flakeUrl"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
// variant="standard"
|
||||
// label="Clan url"
|
||||
required
|
||||
fullWidth
|
||||
startAdornment={
|
||||
<InputAdornment position="start">Clan Url:</InputAdornment>
|
||||
}
|
||||
endAdornment={
|
||||
<InputAdornment position="end">
|
||||
<IconButton type="submit">
|
||||
<ChevronRight />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}
|
||||
// }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
{(formState.isSubmitted || flakeUrl) && (
|
||||
<Confirm
|
||||
handleBack={() => reset()}
|
||||
flakeUrl={formState.isSubmitted ? getValues("flakeUrl") : flakeUrl}
|
||||
/>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
interface DashboardPanelProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
const DashboardPanel = (props: DashboardPanelProps) => {
|
||||
const { children } = props;
|
||||
return (
|
||||
<div className="col-span-full row-span-1 xl:col-span-2">{children}</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Dashboard() {
|
||||
const { data, isLoading } = useAppState();
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid h-full place-items-center">
|
||||
<div className="mt-8 w-full max-w-xl">
|
||||
<LoadingOverlay
|
||||
title="Clan Experience"
|
||||
subtitle="Loading"
|
||||
variant="circle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!data.isJoined) {
|
||||
return <JoinPrequel />;
|
||||
}
|
||||
if (data.isJoined) {
|
||||
return (
|
||||
<MachineContextProvider>
|
||||
<div className="flex h-screen w-full">
|
||||
<div className="grid w-full auto-rows-max grid-cols-1 grid-rows-none gap-4 xl:grid-cols-2 2xl:grid-cols-3 ">
|
||||
<DashboardCard rowSpan={2}>
|
||||
<NetworkOverview />
|
||||
</DashboardCard>
|
||||
<DashboardCard rowSpan={2}>
|
||||
<RecentActivity />
|
||||
</DashboardCard>
|
||||
<DashboardCard>
|
||||
<Notifications />
|
||||
</DashboardCard>
|
||||
<DashboardCard>
|
||||
<QuickActions />
|
||||
</DashboardCard>
|
||||
<DashboardPanel>
|
||||
<AppOverview />
|
||||
</DashboardPanel>
|
||||
<DashboardCard sx={tw`xl:col-span-full 2xl:col-span-1`}>
|
||||
<TaskQueue />
|
||||
</DashboardCard>
|
||||
</div>
|
||||
</div>
|
||||
</MachineContextProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,16 +7,12 @@ import {
|
||||
Edit,
|
||||
Group,
|
||||
Key,
|
||||
MenuOpen,
|
||||
NetworkCell,
|
||||
Settings,
|
||||
SettingsEthernet,
|
||||
VisibilityOff,
|
||||
} from "@mui/icons-material";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Divider,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
@@ -29,14 +25,13 @@ import {
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import { useListMachines } from "@/api/default/default";
|
||||
// import { useListMachines } from "@/api/default/default";
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return [{ id: "1" }, { id: "2" }];
|
||||
}
|
||||
|
||||
function getTemplate(params: { id: string }) {
|
||||
console.log({ params });
|
||||
// const res = await fetch(`https://.../posts/${params.id}`);
|
||||
return {
|
||||
short: `My Template ${params.id}`,
|
||||
@@ -47,8 +42,7 @@ interface TemplateDetailProps {
|
||||
params: { id: string };
|
||||
}
|
||||
export default function TemplateDetail({ params }: TemplateDetailProps) {
|
||||
const { data, isLoading } = useListMachines();
|
||||
console.log({ data, isLoading });
|
||||
// const { data, isLoading } = useListMachines();
|
||||
const details = getTemplate(params);
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { ChevronRight } from "@mui/icons-material";
|
||||
import {
|
||||
Avatar,
|
||||
Divider,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
|
||||
45
pkgs/ui/src/components/background.tsx
Normal file
45
pkgs/ui/src/components/background.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import Image from "next/image";
|
||||
import clanLight from "../../public/clan-dark.png";
|
||||
import clanDark from "../../public/clan-dark.png";
|
||||
import { useAppState } from "./hooks/useAppContext";
|
||||
|
||||
export default function Background() {
|
||||
const { data, isLoading } = useAppState();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"fixed -z-10 h-[100vh] w-[100vw] overflow-hidden opacity-10 blur-md dark:opacity-40"
|
||||
}
|
||||
>
|
||||
{(isLoading || !data.isJoined) && (
|
||||
<>
|
||||
<Image
|
||||
className="dark:hidden"
|
||||
alt="clan"
|
||||
src={clanLight}
|
||||
placeholder="blur"
|
||||
quality={100}
|
||||
fill
|
||||
sizes="100vw"
|
||||
style={{
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
<Image
|
||||
className="hidden dark:block"
|
||||
alt="clan"
|
||||
src={clanDark}
|
||||
placeholder="blur"
|
||||
quality={100}
|
||||
fill
|
||||
sizes="100vw"
|
||||
style={{
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Paper,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { IChangeEvent, FormProps } from "@rjsf/core";
|
||||
import { IChangeEvent } from "@rjsf/core";
|
||||
import { Form } from "@rjsf/mui";
|
||||
import validator from "@rjsf/validator-ajv8";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -100,7 +100,7 @@ function ErrorList<
|
||||
}
|
||||
|
||||
function PureCustomConfig(props: PureCustomConfigProps) {
|
||||
const { schema, initialValues, formHooks } = props;
|
||||
const { schema, formHooks } = props;
|
||||
const { setValue, watch } = formHooks;
|
||||
|
||||
console.log({ schema });
|
||||
|
||||
@@ -5,19 +5,14 @@ import {
|
||||
Step,
|
||||
StepLabel,
|
||||
Stepper,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import React, { ReactNode, useState } from "react";
|
||||
import { useForm, UseFormReturn } from "react-hook-form";
|
||||
import React, { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { CustomConfig } from "./customConfig";
|
||||
import { CreateMachineForm, FormStep } from "./interfaces";
|
||||
|
||||
const SC = (props: { children: ReactNode }) => {
|
||||
return <>{props.children}</>;
|
||||
};
|
||||
|
||||
export function CreateMachineForm() {
|
||||
const formHooks = useForm<CreateMachineForm>({
|
||||
defaultValues: {
|
||||
@@ -25,7 +20,7 @@ export function CreateMachineForm() {
|
||||
config: {},
|
||||
},
|
||||
});
|
||||
const { handleSubmit, control, watch, reset, formState } = formHooks;
|
||||
const { handleSubmit, reset } = formHooks;
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const [activeStep, setActiveStep] = useState<number>(0);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactElement, ReactNode } from "react";
|
||||
import { ReactElement } from "react";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
|
||||
export type StepId = "template" | "modules" | "config" | "save";
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { DashboardCard } from "@/components/card";
|
||||
import Image from "next/image";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface AppCardProps {
|
||||
name: string;
|
||||
icon?: string;
|
||||
@@ -32,11 +30,6 @@ const AppCard = (props: AppCardProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
type App = {
|
||||
name: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
const apps = [
|
||||
{
|
||||
name: "Firefox",
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { DashboardCard } from "@/components/card";
|
||||
import { notificationData } from "@/data/dashboardData";
|
||||
import { tw } from "@/utils/tailwind";
|
||||
import {
|
||||
Avatar,
|
||||
Chip,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
} from "@mui/material";
|
||||
import { Label } from "recharts";
|
||||
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import InfoIcon from "@mui/icons-material/Info";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
import { DashboardCard } from "@/components/card";
|
||||
import { Button, Fab } from "@mui/material";
|
||||
import { Fab } from "@mui/material";
|
||||
import { MouseEventHandler, ReactNode } from "react";
|
||||
|
||||
import LanIcon from "@mui/icons-material/Lan";
|
||||
|
||||
@@ -19,7 +19,7 @@ interface TaskEntryProps {
|
||||
details?: string;
|
||||
}
|
||||
const TaskEntry = (props: TaskEntryProps) => {
|
||||
const { result, task, details, status } = props;
|
||||
const { result, task, status } = props;
|
||||
return (
|
||||
<>
|
||||
<div className="col-span-1">{status}</div>
|
||||
|
||||
@@ -10,7 +10,10 @@ export const FlakeBadge = (props: FlakeBadgeProps) => (
|
||||
label={`${props.flakeUrl}#${props.flakeAttr}`}
|
||||
sx={{
|
||||
p: 2,
|
||||
"& .MuiChip-label": {
|
||||
"&.MuiChip-root": {
|
||||
maxWidth: "unset",
|
||||
},
|
||||
"&.MuiChip-label": {
|
||||
overflow: "unset",
|
||||
},
|
||||
}}
|
||||
|
||||
61
pkgs/ui/src/components/hooks/useAppContext.tsx
Normal file
61
pkgs/ui/src/components/hooks/useAppContext.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useListMachines } from "@/api/default/default";
|
||||
import { MachinesResponse } from "@/api/model";
|
||||
import { AxiosError, AxiosResponse } from "axios";
|
||||
import React, {
|
||||
createContext,
|
||||
Dispatch,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useState,
|
||||
} from "react";
|
||||
import { KeyedMutator } from "swr";
|
||||
|
||||
type AppContextType = {
|
||||
// data: AxiosResponse<{}, any> | undefined;
|
||||
data: AppState;
|
||||
|
||||
isLoading: boolean;
|
||||
error: AxiosError<any> | undefined;
|
||||
|
||||
setAppState: Dispatch<SetStateAction<AppState>>;
|
||||
mutate: KeyedMutator<AxiosResponse<MachinesResponse, any>>;
|
||||
swrKey: string | false | Record<any, any>;
|
||||
};
|
||||
|
||||
// const initialState = {
|
||||
// isLoading: true,
|
||||
// } as const;
|
||||
|
||||
export const AppContext = createContext<AppContextType>({} as AppContextType);
|
||||
|
||||
type AppState = {
|
||||
isJoined?: boolean;
|
||||
clanName?: string;
|
||||
};
|
||||
|
||||
interface AppContextProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
export const WithAppState = (props: AppContextProviderProps) => {
|
||||
const { children } = props;
|
||||
const { isLoading, error, mutate, swrKey } = useListMachines();
|
||||
|
||||
const [data, setAppState] = useState<AppState>({ isJoined: false });
|
||||
|
||||
return (
|
||||
<AppContext.Provider
|
||||
value={{
|
||||
data,
|
||||
setAppState,
|
||||
isLoading,
|
||||
error,
|
||||
swrKey,
|
||||
mutate,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAppState = () => React.useContext(AppContext);
|
||||
@@ -33,7 +33,9 @@ export const useVms = (options: UseVmsOptions) => {
|
||||
} catch (e) {
|
||||
const err = e as AxiosError<HTTPValidationError>;
|
||||
setError(err);
|
||||
toast.error(err.message);
|
||||
toast(
|
||||
"Could not find default configuration. Please select a machine preset",
|
||||
);
|
||||
return undefined;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -10,10 +10,11 @@ import {
|
||||
} from "@mui/material";
|
||||
import { Controller, SubmitHandler, UseFormReturn } from "react-hook-form";
|
||||
import { FlakeBadge } from "../flakeBadge/flakeBadge";
|
||||
import { createVm, useGetVmLogs } from "@/api/default/default";
|
||||
import { createVm, useInspectFlakeAttrs } from "@/api/default/default";
|
||||
import { VmConfig } from "@/api/model";
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useAppState } from "../hooks/useAppContext";
|
||||
|
||||
interface VmPropLabelProps {
|
||||
children: React.ReactNode;
|
||||
@@ -28,20 +29,26 @@ interface VmPropContentProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
const VmPropContent = (props: VmPropContentProps) => (
|
||||
<div className="col-span-4 font-bold sm:col-span-3">{props.children}</div>
|
||||
<div className="col-span-4 sm:col-span-3">{props.children}</div>
|
||||
);
|
||||
|
||||
interface VmDetailsProps {
|
||||
vmConfig: VmConfig;
|
||||
formHooks: UseFormReturn<VmConfig, any, undefined>;
|
||||
setVmUuid: Dispatch<SetStateAction<string | null>>;
|
||||
}
|
||||
|
||||
export const ConfigureVM = (props: VmDetailsProps) => {
|
||||
const { vmConfig, formHooks, setVmUuid } = props;
|
||||
const { control, handleSubmit } = formHooks;
|
||||
const { cores, flake_attr, flake_url, graphics, memory_size } = vmConfig;
|
||||
const { formHooks, setVmUuid } = props;
|
||||
const { control, handleSubmit, watch, setValue } = formHooks;
|
||||
const [isStarting, setStarting] = useState(false);
|
||||
const { setAppState } = useAppState();
|
||||
const { isLoading, data } = useInspectFlakeAttrs({ url: watch("flake_url") });
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && data?.data) {
|
||||
setValue("flake_attr", data.data.flake_attrs[0] || "");
|
||||
}
|
||||
}, [isLoading, setValue, data]);
|
||||
|
||||
const onSubmit: SubmitHandler<VmConfig> = async (data) => {
|
||||
setStarting(true);
|
||||
@@ -53,6 +60,7 @@ export const ConfigureVM = (props: VmDetailsProps) => {
|
||||
setStarting(false);
|
||||
if (response.statusText === "OK") {
|
||||
toast.success(("Joined @ " + uuid) as string);
|
||||
setAppState((s) => ({ ...s, isJoined: true }));
|
||||
} else {
|
||||
toast.error("Could not join");
|
||||
}
|
||||
@@ -64,20 +72,33 @@ export const ConfigureVM = (props: VmDetailsProps) => {
|
||||
className="grid grid-cols-4 gap-y-10"
|
||||
>
|
||||
<div className="col-span-4">
|
||||
<ListSubheader>General</ListSubheader>
|
||||
<ListSubheader sx={{ bgcolor: "inherit" }}>General</ListSubheader>
|
||||
</div>
|
||||
<VmPropLabel>Flake</VmPropLabel>
|
||||
<VmPropContent>
|
||||
<FlakeBadge flakeAttr={flake_attr} flakeUrl={flake_url} />
|
||||
<FlakeBadge
|
||||
flakeAttr={watch("flake_attr")}
|
||||
flakeUrl={watch("flake_url")}
|
||||
/>
|
||||
</VmPropContent>
|
||||
<VmPropLabel>Machine</VmPropLabel>
|
||||
<VmPropContent>
|
||||
{!isLoading && (
|
||||
<Controller
|
||||
name="flake_attr"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select {...field} variant="standard" fullWidth>
|
||||
{["default", "vm1"].map((attr) => (
|
||||
<Select
|
||||
{...field}
|
||||
required
|
||||
variant="standard"
|
||||
fullWidth
|
||||
disabled={isLoading}
|
||||
>
|
||||
{!data?.data.flake_attrs.includes("default") && (
|
||||
<MenuItem value={"default"}>default</MenuItem>
|
||||
)}
|
||||
{data?.data.flake_attrs.map((attr) => (
|
||||
<MenuItem value={attr} key={attr}>
|
||||
{attr}
|
||||
</MenuItem>
|
||||
@@ -85,9 +106,10 @@ export const ConfigureVM = (props: VmDetailsProps) => {
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</VmPropContent>
|
||||
<div className="col-span-4">
|
||||
<ListSubheader>VM</ListSubheader>
|
||||
<ListSubheader sx={{ bgcolor: "inherit" }}>VM</ListSubheader>
|
||||
</div>
|
||||
<VmPropLabel>CPU Cores</VmPropLabel>
|
||||
<VmPropContent>
|
||||
@@ -103,7 +125,7 @@ export const ConfigureVM = (props: VmDetailsProps) => {
|
||||
name="graphics"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Switch {...field} defaultChecked={vmConfig.graphics} />
|
||||
<Switch {...field} defaultChecked={watch("graphics")} />
|
||||
)}
|
||||
/>
|
||||
</VmPropContent>
|
||||
@@ -129,7 +151,12 @@ export const ConfigureVM = (props: VmDetailsProps) => {
|
||||
|
||||
<div className="col-span-4 grid items-center">
|
||||
{isStarting && <LinearProgress />}
|
||||
<Button type="submit" disabled={isStarting} variant="contained">
|
||||
<Button
|
||||
autoFocus
|
||||
type="submit"
|
||||
disabled={isStarting}
|
||||
variant="contained"
|
||||
>
|
||||
Join Clan
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState } from "react";
|
||||
import { LoadingOverlay } from "./loadingOverlay";
|
||||
import { FlakeBadge } from "../flakeBadge/flakeBadge";
|
||||
import { Typography, Button } from "@mui/material";
|
||||
import { FlakeResponse } from "@/api/model";
|
||||
// import { FlakeResponse } from "@/api/model";
|
||||
import { ConfirmVM } from "./confirmVM";
|
||||
import { Log } from "./log";
|
||||
import GppMaybeIcon from "@mui/icons-material/GppMaybe";
|
||||
@@ -11,22 +11,29 @@ import { useInspectFlake } from "@/api/default/default";
|
||||
|
||||
interface ConfirmProps {
|
||||
flakeUrl: string;
|
||||
flakeAttr: string;
|
||||
handleBack: () => void;
|
||||
}
|
||||
export const Confirm = (props: ConfirmProps) => {
|
||||
const { flakeUrl, handleBack } = props;
|
||||
const { flakeUrl, handleBack, flakeAttr } = props;
|
||||
const [userConfirmed, setUserConfirmed] = useState(false);
|
||||
|
||||
const { data, error, isLoading } = useInspectFlake({ url: flakeUrl });
|
||||
const { data, isLoading } = useInspectFlake({
|
||||
url: flakeUrl,
|
||||
});
|
||||
|
||||
return userConfirmed ? (
|
||||
<ConfirmVM url={flakeUrl} handleBack={handleBack} />
|
||||
<ConfirmVM
|
||||
url={flakeUrl}
|
||||
handleBack={handleBack}
|
||||
defaultFlakeAttr={flakeAttr}
|
||||
/>
|
||||
) : (
|
||||
<div className="mb-2 flex w-full max-w-2xl flex-col items-center justify-self-center pb-2">
|
||||
<div className="mb-2 flex w-full max-w-2xl flex-col items-center justify-self-center pb-2 ">
|
||||
{isLoading && (
|
||||
<LoadingOverlay
|
||||
title={"Loading Flake"}
|
||||
subtitle={<FlakeBadge flakeUrl={flakeUrl} flakeAttr="" />}
|
||||
subtitle={<FlakeBadge flakeUrl={flakeUrl} flakeAttr={flakeAttr} />}
|
||||
/>
|
||||
)}
|
||||
{data && (
|
||||
|
||||
@@ -3,43 +3,36 @@ import React, { useEffect, useState } from "react";
|
||||
import { VmConfig } from "@/api/model";
|
||||
import { useVms } from "@/components/hooks/useVms";
|
||||
|
||||
import { Alert, AlertTitle, Button } from "@mui/material";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { createVm, inspectVm, useGetVmLogs } from "@/api/default/default";
|
||||
|
||||
import { LoadingOverlay } from "./loadingOverlay";
|
||||
import { FlakeBadge } from "../flakeBadge/flakeBadge";
|
||||
import { Log } from "./log";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { ConfigureVM } from "./configureVM";
|
||||
import { VmBuildLogs } from "./vmBuildLogs";
|
||||
|
||||
interface ConfirmVMProps {
|
||||
url: string;
|
||||
handleBack: () => void;
|
||||
defaultFlakeAttr: string;
|
||||
}
|
||||
|
||||
export function ConfirmVM(props: ConfirmVMProps) {
|
||||
const { url, handleBack } = props;
|
||||
const { url, defaultFlakeAttr } = props;
|
||||
const formHooks = useForm<VmConfig>({
|
||||
defaultValues: {
|
||||
flake_url: url,
|
||||
flake_attr: "vm1",
|
||||
cores: 1,
|
||||
flake_attr: defaultFlakeAttr,
|
||||
cores: 4,
|
||||
graphics: true,
|
||||
memory_size: 1024,
|
||||
memory_size: 2048,
|
||||
},
|
||||
});
|
||||
const [vmUuid, setVmUuid] = useState<string | null>(null);
|
||||
|
||||
const { setValue, watch, formState, handleSubmit } = formHooks;
|
||||
const { config, error, isLoading } = useVms({
|
||||
const { setValue, watch, formState } = formHooks;
|
||||
const { config, isLoading } = useVms({
|
||||
url,
|
||||
// TODO: FIXME
|
||||
attr: watch("flake_attr"),
|
||||
attr: watch("flake_attr") || defaultFlakeAttr,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setValue("cores", config?.cores);
|
||||
@@ -52,48 +45,12 @@ export function ConfirmVM(props: ConfirmVMProps) {
|
||||
<div className="mb-2 flex w-full max-w-2xl flex-col items-center justify-self-center pb-2">
|
||||
{!formState.isSubmitted && (
|
||||
<>
|
||||
{error && (
|
||||
<Alert severity="error" className="w-full max-w-2xl">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
An Error occurred - See details below
|
||||
</Alert>
|
||||
)}
|
||||
<div className="mb-2 w-full max-w-2xl">
|
||||
{isLoading && (
|
||||
<LoadingOverlay
|
||||
title={"Loading VM Configuration"}
|
||||
subtitle={<FlakeBadge flakeUrl={url} flakeAttr={url} />}
|
||||
/>
|
||||
)}
|
||||
{config && (
|
||||
<ConfigureVM
|
||||
vmConfig={config}
|
||||
formHooks={formHooks}
|
||||
setVmUuid={setVmUuid}
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<>
|
||||
<Button
|
||||
color="error"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
onClick={handleBack}
|
||||
className="my-2"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Log
|
||||
title="Log"
|
||||
lines={
|
||||
error?.response?.data?.detail
|
||||
?.map((err, idx) => err.msg.split("\n"))
|
||||
?.flat()
|
||||
.filter(Boolean) || []
|
||||
}
|
||||
/>
|
||||
</>
|
||||
<LoadingOverlay title={"Loading VM Configuration"} subtitle="" />
|
||||
)}
|
||||
|
||||
<ConfigureVM formHooks={formHooks} setVmUuid={setVmUuid} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
"use client";
|
||||
import { LinearProgress, Typography } from "@mui/material";
|
||||
import { CircularProgress, LinearProgress, Typography } from "@mui/material";
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
title: React.ReactNode;
|
||||
subtitle: React.ReactNode;
|
||||
variant?: "linear" | "circle";
|
||||
}
|
||||
export const LoadingOverlay = (props: LoadingOverlayProps) => {
|
||||
const { title, subtitle } = props;
|
||||
const { title, subtitle, variant = "linear" } = props;
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Typography variant="subtitle2">{title}</Typography>
|
||||
<LinearProgress className="mb-2 w-full" />
|
||||
<div className="grid w-full place-items-center">{subtitle}</div>
|
||||
<Typography variant="subtitle1"></Typography>
|
||||
<div className="grid w-full place-items-center">
|
||||
<Typography variant="subtitle1">{title}</Typography>
|
||||
</div>
|
||||
<div className="grid w-full place-items-center">
|
||||
<Typography variant="subtitle2">{subtitle}</Typography>
|
||||
</div>
|
||||
{variant === "linear" && <LinearProgress className="my-2 w-full" />}
|
||||
{variant === "circle" && (
|
||||
<div className="grid w-full place-items-center">
|
||||
<CircularProgress className="my-2 w-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,11 +9,7 @@ interface VmBuildLogsProps {
|
||||
export const VmBuildLogs = (props: VmBuildLogsProps) => {
|
||||
const { vmUuid } = props;
|
||||
|
||||
const {
|
||||
data: logs,
|
||||
isLoading,
|
||||
error,
|
||||
} = useGetVmLogs(vmUuid as string, {
|
||||
const { data: logs, isLoading } = useGetVmLogs(vmUuid as string, {
|
||||
swr: {
|
||||
enabled: vmUuid !== null,
|
||||
},
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import {
|
||||
Alert,
|
||||
Divider,
|
||||
Icon,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Snackbar,
|
||||
} from "@mui/material";
|
||||
import Image from "next/image";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import DashboardIcon from "@mui/icons-material/Dashboard";
|
||||
import DevicesIcon from "@mui/icons-material/Devices";
|
||||
@@ -29,7 +26,7 @@ type MenuEntry = {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
to: string;
|
||||
missing: boolean;
|
||||
disabled: boolean;
|
||||
} & {
|
||||
subMenuEntries?: MenuEntry[];
|
||||
};
|
||||
@@ -39,37 +36,37 @@ const menuEntries: MenuEntry[] = [
|
||||
icon: <DashboardIcon />,
|
||||
label: "Dashoard",
|
||||
to: "/",
|
||||
missing: false,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
icon: <DevicesIcon />,
|
||||
label: "Machines",
|
||||
to: "/machines",
|
||||
missing: false,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
icon: <AppsIcon />,
|
||||
label: "Applications",
|
||||
to: "/applications",
|
||||
missing: true,
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
icon: <LanIcon />,
|
||||
label: "Network",
|
||||
to: "/network",
|
||||
missing: true,
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
icon: <DesignServicesIcon />,
|
||||
label: "Templates",
|
||||
to: "/templates",
|
||||
missing: false,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
icon: <BackupIcon />,
|
||||
label: "Backups",
|
||||
to: "/backups",
|
||||
missing: true,
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -83,22 +80,6 @@ interface SidebarProps {
|
||||
export function Sidebar(props: SidebarProps) {
|
||||
const { show, onClose } = props;
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const handleClick = () => {
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleClose = (
|
||||
event?: React.SyntheticEvent | Event,
|
||||
reason?: string,
|
||||
) => {
|
||||
if (reason === "clickaway") {
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={tw`${
|
||||
@@ -137,10 +118,8 @@ export function Sidebar(props: SidebarProps) {
|
||||
<ListItemButton
|
||||
className="justify-center lg:justify-normal"
|
||||
LinkComponent={Link}
|
||||
href={menuEntry.missing ? "" : menuEntry.to}
|
||||
onClickCapture={
|
||||
menuEntry.missing ? () => handleClick() : undefined
|
||||
}
|
||||
href={menuEntry.to}
|
||||
disabled={menuEntry.disabled}
|
||||
>
|
||||
<ListItemIcon
|
||||
color="inherit"
|
||||
@@ -160,12 +139,6 @@ export function Sidebar(props: SidebarProps) {
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
|
||||
<Snackbar open={open} autoHideDuration={2000} onClose={handleClose}>
|
||||
<Alert onClose={handleClose} severity="error" sx={{ width: "100%" }}>
|
||||
Site does not exist yet
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
<Divider flexItem className="mx-8 my-10 hidden bg-zinc-600 lg:block" />
|
||||
<div className="mx-auto mb-8 hidden w-full max-w-xs rounded-sm px-4 py-6 text-center align-bottom shadow-sm lg:block">
|
||||
<h3 className="mb-2 w-full font-semibold text-white">
|
||||
|
||||
@@ -58,20 +58,20 @@ function getRandomName(): string {
|
||||
}
|
||||
|
||||
// A function to generate random IPv6 addresses
|
||||
function getRandomId(): string {
|
||||
let hex = "0123456789abcdef";
|
||||
let id = "";
|
||||
for (let i = 0; i < 8; i++) {
|
||||
for (let j = 0; j < 4; j++) {
|
||||
let index = Math.floor(Math.random() * hex.length);
|
||||
id += hex[index];
|
||||
}
|
||||
if (i < 7) {
|
||||
id += ":";
|
||||
}
|
||||
}
|
||||
return id;
|
||||
}
|
||||
// function getRandomId(): string {
|
||||
// let hex = "0123456789abcdef";
|
||||
// let id = "";
|
||||
// for (let i = 0; i < 8; i++) {
|
||||
// for (let j = 0; j < 4; j++) {
|
||||
// let index = Math.floor(Math.random() * hex.length);
|
||||
// id += hex[index];
|
||||
// }
|
||||
// if (i < 7) {
|
||||
// id += ":";
|
||||
// }
|
||||
// }
|
||||
// return id;
|
||||
// }
|
||||
|
||||
// A function to generate random status keys
|
||||
function getRandomStatus(): NodeStatusKeys {
|
||||
|
||||
63
pkgs/ui/src/views/joinPrequel.tsx
Normal file
63
pkgs/ui/src/views/joinPrequel.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { IconButton, Input, InputAdornment } from "@mui/material";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { Confirm } from "@/components/join/confirm";
|
||||
import { Layout } from "@/components/join/layout";
|
||||
import { ChevronRight } from "@mui/icons-material";
|
||||
|
||||
type FormValues = {
|
||||
flakeUrl: string;
|
||||
};
|
||||
|
||||
export default function JoinPrequel() {
|
||||
const queryParams = useSearchParams();
|
||||
const flakeUrl = queryParams.get("flake") || "";
|
||||
const flakeAttr = queryParams.get("attr") || "default";
|
||||
const { handleSubmit, control, formState, getValues, reset } =
|
||||
useForm<FormValues>({ defaultValues: { flakeUrl: "" } });
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
{!formState.isSubmitted && !flakeUrl && (
|
||||
<form
|
||||
onSubmit={handleSubmit(() => {})}
|
||||
className="w-full max-w-2xl justify-self-center"
|
||||
>
|
||||
<Controller
|
||||
name="flakeUrl"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
color="secondary"
|
||||
aria-required="true"
|
||||
{...field}
|
||||
required
|
||||
fullWidth
|
||||
startAdornment={
|
||||
<InputAdornment position="start">Clan</InputAdornment>
|
||||
}
|
||||
endAdornment={
|
||||
<InputAdornment position="end">
|
||||
<IconButton type="submit">
|
||||
<ChevronRight />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
{(formState.isSubmitted || flakeUrl) && (
|
||||
<Confirm
|
||||
handleBack={() => reset()}
|
||||
flakeUrl={formState.isSubmitted ? getValues("flakeUrl") : flakeUrl}
|
||||
flakeAttr={flakeAttr}
|
||||
/>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"noUnusedLocals": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
|
||||
Reference in New Issue
Block a user