From 4948919368674dc013ee9a8af85e6f427c9101c3 Mon Sep 17 00:00:00 2001 From: Ar Rakin Date: Fri, 9 Feb 2024 00:25:26 +0600 Subject: [PATCH] feat: extension installation modal --- package.json | 8 +- public/next.svg | 1 - public/vercel.svg | 1 - src/app/extension/[id]/page.tsx | 2 + src/app/recoil.tsx | 29 ++- src/atoms/ExtensionPageAtom.ts | 13 ++ .../Extension/ExtensionControls.tsx | 6 + .../ExtensionInstallChooseInstances.tsx | 26 +++ .../Extension/ExtensionInstallCredentials.tsx | 31 +++ .../Extension/ExtensionInstallModal.tsx | 179 ++++++++++++++++++ src/components/Instance/Instance.tsx | 45 +++++ src/components/Instance/Instances.tsx | 21 ++ src/hooks/useClientInitialized.ts | 11 ++ src/hooks/useLocalStorage.ts | 33 ++++ src/types/SystemInstance.ts | 7 + 15 files changed, 407 insertions(+), 6 deletions(-) delete mode 100644 public/next.svg delete mode 100644 public/vercel.svg create mode 100644 src/components/Extension/ExtensionInstallChooseInstances.tsx create mode 100644 src/components/Extension/ExtensionInstallCredentials.tsx create mode 100644 src/components/Extension/ExtensionInstallModal.tsx create mode 100644 src/components/Instance/Instance.tsx create mode 100644 src/components/Instance/Instances.tsx create mode 100644 src/hooks/useClientInitialized.ts create mode 100644 src/hooks/useLocalStorage.ts create mode 100644 src/types/SystemInstance.ts diff --git a/package.json b/package.json index 8467a33..c73a25d 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,9 @@ "react": "^18", "react-dom": "^18", "react-icons": "^5.0.1", - "recoil": "^0.7.7" + "recoil": "^0.7.7", + "semver": "^7.6.0", + "uuid": "^9.0.1" }, "devDependencies": { "@commitlint/cli": "^18.6.0", @@ -35,6 +37,8 @@ "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "@types/semver": "^7.5.6", + "@types/uuid": "^9.0.8", "autoprefixer": "^10.0.1", "eslint": "^8", "eslint-config-next": "14.1.0", @@ -43,4 +47,4 @@ "tailwindcss": "^3.3.0", "typescript": "^5" } -} \ No newline at end of file +} diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index d2f8422..0000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/app/extension/[id]/page.tsx b/src/app/extension/[id]/page.tsx index 7b6b5b8..29ae14b 100644 --- a/src/app/extension/[id]/page.tsx +++ b/src/app/extension/[id]/page.tsx @@ -4,6 +4,7 @@ import ExtensionDownloadModal from "@/components/Extension/ExtensionDownloadModa import ExtensionIcon from "@/components/Extension/ExtensionIcon"; import ExtensionInfoList from "@/components/Extension/ExtensionInfoList"; import ExtensionInfoMobileList from "@/components/Extension/ExtensionInfoListMobile"; +import ExtensionInstallModal from "@/components/Extension/ExtensionInstallModal"; import ExtensionSecurity from "@/components/Extension/ExtensionSecurity"; import { Divider } from "@/components/Layout/Divider"; import { INDEX_URL } from "@/config/urls"; @@ -116,6 +117,7 @@ export default async function ExtensionPage({ params }: ServerSidePageProps) { return ( <> +
diff --git a/src/app/recoil.tsx b/src/app/recoil.tsx index 8921a7d..f7e53f3 100644 --- a/src/app/recoil.tsx +++ b/src/app/recoil.tsx @@ -1,8 +1,33 @@ "use client"; +import ExtensionPageAtom from "@/atoms/ExtensionPageAtom"; +import useLocalStorage from "@/hooks/useLocalStorage"; +import { LocalStorageSystemInstance } from "@/types/SystemInstance"; import { PropsWithChildren } from "react"; -import { RecoilRoot } from "recoil"; +import { MutableSnapshot, RecoilRoot } from "recoil"; export default function RecoilProvider({ children }: PropsWithChildren) { - return {children}; + const [get] = useLocalStorage("global_state"); + + const initializeState = (snapshot: MutableSnapshot) => { + const persisted = get(); + + if (persisted) { + const persistedState = JSON.parse(persisted); + + if ("instances" in persistedState) { + snapshot.set(ExtensionPageAtom, { + instances: [ + ...persistedState.instances, + ] as LocalStorageSystemInstance[], + downloadModalOpen: false, + installModalOpen: false, + }); + } + } + }; + + return ( + {children} + ); } diff --git a/src/atoms/ExtensionPageAtom.ts b/src/atoms/ExtensionPageAtom.ts index b9b8e71..1befd11 100644 --- a/src/atoms/ExtensionPageAtom.ts +++ b/src/atoms/ExtensionPageAtom.ts @@ -1,9 +1,22 @@ +import { LocalStorageSystemInstance } from "@/types/SystemInstance"; import { atom } from "recoil"; +import { v4 as uuid } from "uuid"; const ExtensionPageState = atom({ key: "extensionPageState", default: { downloadModalOpen: false, + installModalOpen: false, + instances: [ + { + id: uuid(), + url: "https://www.sudobot.org", + }, + { + id: uuid(), + url: "https://whatever.org", + }, + ] as LocalStorageSystemInstance[], }, }); diff --git a/src/components/Extension/ExtensionControls.tsx b/src/components/Extension/ExtensionControls.tsx index b8846dc..3857bd7 100644 --- a/src/components/Extension/ExtensionControls.tsx +++ b/src/components/Extension/ExtensionControls.tsx @@ -42,6 +42,12 @@ export default function ExtensionControls({ sx={{ px: 1.5 }} fullWidth startIcon={} + onClick={() => + setState((state) => ({ + ...state, + installModalOpen: true, + })) + } > Install diff --git a/src/components/Extension/ExtensionInstallChooseInstances.tsx b/src/components/Extension/ExtensionInstallChooseInstances.tsx new file mode 100644 index 0000000..bf785ec --- /dev/null +++ b/src/components/Extension/ExtensionInstallChooseInstances.tsx @@ -0,0 +1,26 @@ +import { Box } from "@mui/material"; +import { FC } from "react"; +import Instances from "../Instance/Instances"; + +interface ExtensionInstallationChooseInstancesProps {} + +const ExtensionInstallationChooseInstances: FC< + ExtensionInstallationChooseInstancesProps +> = () => { + return ( + +

+ Choose the instances you want to install the extension on. +

+
+ + +
+ ); +}; + +export default ExtensionInstallationChooseInstances; diff --git a/src/components/Extension/ExtensionInstallCredentials.tsx b/src/components/Extension/ExtensionInstallCredentials.tsx new file mode 100644 index 0000000..8577c70 --- /dev/null +++ b/src/components/Extension/ExtensionInstallCredentials.tsx @@ -0,0 +1,31 @@ +import { Box, TextField } from "@mui/material"; +import { FC } from "react"; + +interface ExtensionInstallCredentialsProps {} + +const ExtensionInstallCredentials: FC< + ExtensionInstallCredentialsProps +> = () => { + return ( + +

+ Enter the credentials to authenticate and install the extension. +

+ +
+ + +
+
+ ); +}; + +export default ExtensionInstallCredentials; diff --git a/src/components/Extension/ExtensionInstallModal.tsx b/src/components/Extension/ExtensionInstallModal.tsx new file mode 100644 index 0000000..cb211a3 --- /dev/null +++ b/src/components/Extension/ExtensionInstallModal.tsx @@ -0,0 +1,179 @@ +"use client"; + +import ExtensionPageState from "@/atoms/ExtensionPageAtom"; +import { APIExtension } from "@/types/APIExtension"; +import { + Box, + Button, + Modal, + Step, + StepLabel, + Stepper, + Typography, +} from "@mui/material"; +import { useState } from "react"; +import { MdClose } from "react-icons/md"; +import { useRecoilState } from "recoil"; +import DeployedCodeUpdate from "../Icons/DeployedCodeUpdate"; +import ExtensionInstallationChooseInstances from "./ExtensionInstallChooseInstances"; +import ExtensionInstallCredentials from "./ExtensionInstallCredentials"; + +type Props = { + extension: APIExtension; +}; + +const steps = ["Choose instances", "Install", "Finish"]; +const components = [ + ExtensionInstallationChooseInstances, + ExtensionInstallCredentials, + () =>

Finish

, +]; + +export default function ExtensionInstallModal({ extension }: Props) { + const [state, setState] = useRecoilState(ExtensionPageState); + const [activeStep, setActiveStep] = useState(0); + const [skipped, setSkipped] = useState(new Set()); + const onClose = () => { + setState((state) => ({ ...state, installModalOpen: false })); + setActiveStep(0); + setSkipped(new Set()); + }; + + const StepComponent = components[activeStep]; + + const isStepOptional = (step: number) => { + return false; + }; + + const isStepSkipped = (step: number) => { + return skipped.has(step); + }; + + const handleNext = () => { + let newSkipped = skipped; + if (isStepSkipped(activeStep)) { + newSkipped = new Set(newSkipped.values()); + newSkipped.delete(activeStep); + } + + setActiveStep((prevActiveStep) => prevActiveStep + 1); + setSkipped(newSkipped); + }; + + const handleBack = () => { + setActiveStep((prevActiveStep) => prevActiveStep - 1); + }; + + const handleSkip = () => { + if (!isStepOptional(activeStep)) { + throw new Error("You can't skip a step that isn't optional."); + } + + setActiveStep((prevActiveStep) => prevActiveStep + 1); + setSkipped((prevSkipped) => { + const newSkipped = new Set(prevSkipped.values()); + newSkipped.add(activeStep); + return newSkipped; + }); + }; + + const handleReset = () => { + setActiveStep(0); + }; + + return ( + +
+
+

+ + Install {extension.name} +

+ +
+
+
+ + + {steps.map((label, index) => { + const stepProps: { completed?: boolean } = {}; + const labelProps: { + optional?: React.ReactNode; + } = {}; + + if (isStepOptional(index)) { + labelProps.optional = ( + + Optional + + ); + } + + if (isStepSkipped(index)) { + stepProps.completed = false; + } + + return ( + + + {label} + + + ); + })} + + {activeStep === steps.length ? ( + <> + + All steps completed - you're finished + + + ) : ( + + )} + + +
+ + + {isStepOptional(activeStep) && ( + + )} + {" "} + +
+
+
+
+ ); +} diff --git a/src/components/Instance/Instance.tsx b/src/components/Instance/Instance.tsx new file mode 100644 index 0000000..ee02f29 --- /dev/null +++ b/src/components/Instance/Instance.tsx @@ -0,0 +1,45 @@ +import { LocalStorageSystemInstance } from "@/types/SystemInstance"; +import { Checkbox } from "@mui/material"; +import semver from "semver"; + +type Props = { + instance: LocalStorageSystemInstance; +}; + +function getVersion(url: string) { + return url === "https://www.sudobot.org" ? "8.6.1" : "1.0.0"; +} + +export default function Instance({ instance }: Props) { + const version = + "version" in instance && typeof instance.version === "string" + ? instance.version + : getVersion(instance.url); + const compatible = semver.gt(version, "8.0.0"); + + return ( +
+
+

+ {instance.url} +

+

+ {version} •{" "} + {compatible ? ( + + Compatible + + ) : ( + + Incompatible + + )} +

+
+ +
+ ); +} diff --git a/src/components/Instance/Instances.tsx b/src/components/Instance/Instances.tsx new file mode 100644 index 0000000..4ad0774 --- /dev/null +++ b/src/components/Instance/Instances.tsx @@ -0,0 +1,21 @@ +import ExtensionPageAtom from "@/atoms/ExtensionPageAtom"; +import { SystemInstance } from "@/types/SystemInstance"; +import { useRecoilValue } from "recoil"; +import Instance from "./Instance"; + +type Props = { + instances?: SystemInstance[]; +}; + +export default function Instances({ instances: systemInstances }: Props) { + const localStorageInstances = useRecoilValue(ExtensionPageAtom).instances; + const instances = systemInstances ?? localStorageInstances; + + return ( +
+ {instances?.map((instance) => ( + + ))} +
+ ); +} diff --git a/src/hooks/useClientInitialized.ts b/src/hooks/useClientInitialized.ts new file mode 100644 index 0000000..46e5dba --- /dev/null +++ b/src/hooks/useClientInitialized.ts @@ -0,0 +1,11 @@ +import { useEffect, useState } from "react"; + +export default function useClientInitialized() { + const [initialized, setInitialized] = useState(false); + + useEffect(() => { + setInitialized(true); + }, []); + + return initialized; +} diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts new file mode 100644 index 0000000..6136df4 --- /dev/null +++ b/src/hooks/useLocalStorage.ts @@ -0,0 +1,33 @@ +"use client"; + +import { useCallback } from "react"; +import useClientInitialized from "./useClientInitialized"; + +export default function useLocalStorage(key: string) { + const initialized = useClientInitialized(); + + const get = useCallback((): T | undefined => { + if (!initialized) { + return; + } + + const value = localStorage.getItem(key); + + if (value) { + return JSON.parse(value); + } + }, [initialized]); + + const set = useCallback( + (value: T) => { + if (!initialized) { + return; + } + + localStorage.setItem(key, JSON.stringify(value)); + }, + [initialized] + ); + + return [get, set] as const; +} diff --git a/src/types/SystemInstance.ts b/src/types/SystemInstance.ts new file mode 100644 index 0000000..78b4f63 --- /dev/null +++ b/src/types/SystemInstance.ts @@ -0,0 +1,7 @@ +export type SystemInstance = { + id: string; + url: string; + version: string; +}; + +export type LocalStorageSystemInstance = Pick;