From 519a0bb6520cfd08442f6a2395a21965012d389c Mon Sep 17 00:00:00 2001 From: PhilReact Date: Thu, 27 Feb 2025 00:11:36 +0200 Subject: [PATCH] added private app feature --- src/components/Apps/AppViewer.tsx | 2 +- src/components/Apps/AppsDesktop.tsx | 5 +- src/components/Apps/AppsHomeDesktop.tsx | 4 +- src/components/Apps/AppsNavBarDesktop.tsx | 132 +++-- src/components/Apps/AppsPrivate.tsx | 542 +++++++++++++++++++ src/components/Apps/SortablePinnedApps.tsx | 52 +- src/components/Apps/TabComponent.tsx | 48 +- src/components/Apps/useHandlePrivateApps.tsx | 237 ++++++++ src/components/ContextMenuPinnedApps.tsx | 18 +- src/components/Snackbar/Snackbar.tsx | 2 +- 10 files changed, 958 insertions(+), 84 deletions(-) create mode 100644 src/components/Apps/AppsPrivate.tsx create mode 100644 src/components/Apps/useHandlePrivateApps.tsx diff --git a/src/components/Apps/AppViewer.tsx b/src/components/Apps/AppViewer.tsx index cef2d8d..902c6d8 100644 --- a/src/components/Apps/AppViewer.tsx +++ b/src/components/Apps/AppViewer.tsx @@ -46,7 +46,7 @@ export const AppViewer = React.forwardRef(({ app , hide, isDevMode}, iframeRef) if(isDevMode){ resetHistory() - if(!app?.isPreview){ + if(!app?.isPreview || app?.isPrivate){ setUrl(app?.url + `?time=${Date.now()}`) } return diff --git a/src/components/Apps/AppsDesktop.tsx b/src/components/Apps/AppsDesktop.tsx index b29f9d5..20a3d54 100644 --- a/src/components/Apps/AppsDesktop.tsx +++ b/src/components/Apps/AppsDesktop.tsx @@ -450,7 +450,7 @@ export const AppsDesktop = ({ mode, setMode, show , myName, goToHome, setDesktop }}> - + )} @@ -479,6 +479,7 @@ export const AppsDesktop = ({ mode, setMode, show , myName, goToHome, setDesktop isSelected={tab?.tabId === selectedTab?.tabId} app={tab} ref={iframeRefs.current[tab.tabId]} + isDevMode={tab?.service ? false : true} /> ); })} @@ -494,7 +495,7 @@ export const AppsDesktop = ({ mode, setMode, show , myName, goToHome, setDesktop }}> - + )} diff --git a/src/components/Apps/AppsHomeDesktop.tsx b/src/components/Apps/AppsHomeDesktop.tsx index 7c548d1..32f5abe 100644 --- a/src/components/Apps/AppsHomeDesktop.tsx +++ b/src/components/Apps/AppsHomeDesktop.tsx @@ -16,11 +16,13 @@ import { Spacer } from "../../common/Spacer"; import { SortablePinnedApps } from "./SortablePinnedApps"; import { extractComponents } from "../Chat/MessageDisplay"; import ArrowOutwardIcon from '@mui/icons-material/ArrowOutward'; +import { AppsPrivate } from "./AppsPrivate"; export const AppsHomeDesktop = ({ setMode, myApp, myWebsite, availableQapps, + myName }) => { const [qortalUrl, setQortalUrl] = useState('') @@ -135,7 +137,7 @@ export const AppsHomeDesktop = ({ Library - + { - const isSelectedAppPinned = !!sortablePinnedApps?.find( - (item) => - item?.name === selectedTab?.name && item?.service === selectedTab?.service - ); + const isSelectedAppPinned = useMemo(()=> { + if(selectedTab?.isPrivate){ + return !!sortablePinnedApps?.find( + (item) => + item?.privateAppProperties?.name === selectedTab?.privateAppProperties?.name && item?.privateAppProperties?.service === selectedTab?.privateAppProperties?.service && item?.privateAppProperties?.identifier === selectedTab?.privateAppProperties?.identifier + ); + } else { + return !!sortablePinnedApps?.find( + (item) => + item?.name === selectedTab?.name && item?.service === selectedTab?.service + ); + } + }, [selectedTab,sortablePinnedApps]) return ( { if (isSelectedAppPinned) { // Remove the selected app if it is pinned - updatedApps = prev.filter( - (item) => - !( - item?.name === selectedTab?.name && - item?.service === selectedTab?.service - ) - ); + if(selectedTab?.isPrivate){ + updatedApps = prev.filter( + (item) => + !( + item?.privateAppProperties?.name === selectedTab?.privateAppProperties?.name && + item?.privateAppProperties?.service === selectedTab?.privateAppProperties?.service && + item?.privateAppProperties?.identifier === selectedTab?.privateAppProperties?.identifier + ) + ); + } else { + updatedApps = prev.filter( + (item) => + !( + item?.name === selectedTab?.name && + item?.service === selectedTab?.service + ) + ); + } + } else { // Add the selected app if it is not pinned - updatedApps = [ + if(selectedTab?.isPrivate){ + updatedApps = [ ...prev, { - name: selectedTab?.name, - service: selectedTab?.service, + isPreview: true, + isPrivate: true, + privateAppProperties: { + ...(selectedTab?.privateAppProperties || {}) + } + }, ]; + } else { + updatedApps = [ + ...prev, + { + name: selectedTab?.name, + service: selectedTab?.service, + }, + ]; + } + } saveToLocalStorage( @@ -338,9 +374,15 @@ export const AppsNavBarDesktop = ({disableBack}) => { { - executeEvent("refreshApp", { - tabId: selectedTab?.tabId, - }); + if (selectedTab?.refreshFunc) { + selectedTab.refreshFunc(selectedTab?.tabId); + + } else { + executeEvent("refreshApp", { + tabId: selectedTab?.tabId, + }); + } + handleClose(); }} > @@ -368,38 +410,40 @@ export const AppsNavBarDesktop = ({disableBack}) => { primary="Refresh" /> - { - executeEvent("copyLink", { - tabId: selectedTab?.tabId, - }); - handleClose(); - }} - > - { + executeEvent("copyLink", { + tabId: selectedTab?.tabId, + }); + handleClose(); }} > - + + + - - - + + )} ); diff --git a/src/components/Apps/AppsPrivate.tsx b/src/components/Apps/AppsPrivate.tsx new file mode 100644 index 0000000..b014cd2 --- /dev/null +++ b/src/components/Apps/AppsPrivate.tsx @@ -0,0 +1,542 @@ +import React, { useContext, useState } from "react"; +import { + Avatar, + Box, + Button, + ButtonBase, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Input, + MenuItem, + Select, + Tab, + Tabs, + Typography, +} from "@mui/material"; +import { useDropzone } from "react-dropzone"; +import { useHandlePrivateApps } from "./useHandlePrivateApps"; +import { useRecoilState, useSetRecoilState } from "recoil"; +import { myGroupsWhereIAmAdminAtom } from "../../atoms/global"; +import { Label } from "../Group/AddGroup"; +import { Spacer } from "../../common/Spacer"; +import { + Add, + AppCircle, + AppCircleContainer, + AppCircleLabel, + PublishQAppChoseFile, + PublishQAppInfo, +} from "./Apps-styles"; +import ImageUploader from "../../common/ImageUploader"; +import { isMobile, MyContext } from "../../App"; +import { fileToBase64 } from "../../utils/fileReading"; +import { objectToBase64 } from "../../qdn/encryption/group-encryption"; +import { getFee } from "../../background"; + +const maxFileSize = 50 * 1024 * 1024; // 50MB + +export const AppsPrivate = ({myName}) => { + const { openApp } = useHandlePrivateApps(); + const [file, setFile] = useState(null); + const [logo, setLogo] = useState(null); + const [qortalUrl, setQortalUrl] = useState(""); + const [selectedGroup, setSelectedGroup] = useState(0); + + const [valueTabPrivateApp, setValueTabPrivateApp] = useState(0); + const [myGroupsWhereIAmAdmin, setMyGroupsWhereIAmAdmin] = useRecoilState( + myGroupsWhereIAmAdminAtom + ); + const [isOpenPrivateModal, setIsOpenPrivateModal] = useState(false); + const { show, setInfoSnackCustom, setOpenSnackGlobal, memberGroups } = useContext(MyContext); + + const [privateAppValues, setPrivateAppValues] = useState({ + name: "", + service: "DOCUMENT", + identifier: "", + groupId: 0, + }); + + const [newPrivateAppValues, setNewPrivateAppValues] = useState({ + service: "DOCUMENT", + identifier: "", + name: "", + }); + const { getRootProps, getInputProps } = useDropzone({ + accept: { + "application/zip": [".zip"], // Only accept zip files + }, + maxSize: maxFileSize, + multiple: false, // Disable multiple file uploads + onDrop: (acceptedFiles) => { + if (acceptedFiles.length > 0) { + setFile(acceptedFiles[0]); // Set the file name + } + }, + onDropRejected: (fileRejections) => { + fileRejections.forEach(({ file, errors }) => { + errors.forEach((error) => { + if (error.code === "file-too-large") { + console.error( + `File ${file.name} is too large. Max size allowed is ${ + maxFileSize / (1024 * 1024) + } MB.` + ); + } + }); + }); + }, + }); + + const addPrivateApp = async () => { + try { + if (privateAppValues?.groupId === 0) return; + + await openApp(privateAppValues, true); + } catch (error) { + console.log('error', error?.message) + + } + }; + + const clearFields = () => { + setPrivateAppValues({ + name: "", + service: "DOCUMENT", + identifier: "", + groupId: 0, + }); + setNewPrivateAppValues({ + service: "DOCUMENT", + identifier: "", + name: "", + }); + setFile(null); + setValueTabPrivateApp(0); + setSelectedGroup(0); + setLogo(null); + }; + + const publishPrivateApp = async () => { + try { + if (selectedGroup === 0) return; + if (!logo) throw new Error("Please select an image for a logo"); + if (!myName) throw new Error("You need a Qortal name to publish"); + if (!newPrivateAppValues?.name) throw new Error("Your app needs a name"); + const base64Logo = await fileToBase64(logo); + const base64App = await fileToBase64(file); + const objectToSave = { + app: base64App, + logo: base64Logo, + name: newPrivateAppValues.name, + }; + const object64 = await objectToBase64(objectToSave); + const decryptedData = await window.sendMessage( + "ENCRYPT_QORTAL_GROUP_DATA", + + { + base64: object64, + groupId: selectedGroup, + } + ); + if (decryptedData?.error) { + throw new Error( + decryptedData?.error || "Unable to encrypt app. App not published" + ); + } + const fee = await getFee("ARBITRARY"); + + await show({ + message: "Would you like to publish this app?", + publishFee: fee.fee + " QORT", + }); + await new Promise((res, rej) => { + window + .sendMessage("publishOnQDN", { + data: decryptedData, + identifier: newPrivateAppValues?.identifier, + service: newPrivateAppValues?.service, + }) + .then((response) => { + if (!response?.error) { + res(response); + return; + } + rej(response.error); + }) + .catch((error) => { + rej(error.message || "An error occurred"); + }); + }); + openApp( + { + identifier: newPrivateAppValues?.identifier, + service: newPrivateAppValues?.service, + name: myName, + groupId: selectedGroup, + }, + true + ); + clearFields(); + } catch (error) { + setOpenSnackGlobal(true) + setInfoSnackCustom({ + type: "error", + message: error?.message || "Unable to publish app", + }); + } + }; + + const handleChange = (event: React.SyntheticEvent, newValue: number) => { + setValueTabPrivateApp(newValue); + }; + + function a11yProps(index: number) { + return { + id: `simple-tab-${index}`, + "aria-controls": `simple-tabpanel-${index}`, + }; + } + return ( + <> + { + setIsOpenPrivateModal(true); + }} + sx={{ + width: "80px", + }} + > + + + + + + Private + + + {isOpenPrivateModal && ( + { + if (e.key === "Enter") { + if (valueTabPrivateApp === 0) { + if ( + !privateAppValues.name || + !privateAppValues.service || + !privateAppValues.identifier || + !privateAppValues?.groupId + ) + return; + addPrivateApp(); + } + } + }} + maxWidth="md" + fullWidth={true} + > + + {valueTabPrivateApp === 0 + ? "Access private app" + : "Publish private app"} + + + + + + + + + {valueTabPrivateApp === 0 && ( + <> + + + + + + + + + + + setPrivateAppValues((prev) => { + return { + ...prev, + name: e.target.value, + }; + }) + } + /> + + + + + setPrivateAppValues((prev) => { + return { + ...prev, + identifier: e.target.value, + }; + }) + } + /> + + + + + + + + )} + {valueTabPrivateApp === 1 && ( + <> + + + Select .zip file containing static content:{" "} + + + {` + 50mb MB maximum`} + {file && ( + <> + + {`Selected: (${file?.name})`} + + )} + + + + {" "} + + {file ? "Change" : "Choose"} File + + + + + + + + + + + + + setNewPrivateAppValues((prev) => { + return { + ...prev, + identifier: e.target.value, + }; + }) + } + /> + + + + + + setNewPrivateAppValues((prev) => { + return { + ...prev, + name: e.target.value, + }; + }) + } + /> + + + + setLogo(file)}> + + + {logo?.name} + + + + + + + + )} + + )} + + ); +}; diff --git a/src/components/Apps/SortablePinnedApps.tsx b/src/components/Apps/SortablePinnedApps.tsx index 15f54b9..98c2287 100644 --- a/src/components/Apps/SortablePinnedApps.tsx +++ b/src/components/Apps/SortablePinnedApps.tsx @@ -1,18 +1,21 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; import { DndContext, closestCenter } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable } from '@dnd-kit/sortable'; import { KeyboardSensor, PointerSensor, TouchSensor, useSensor, useSensors } from '@dnd-kit/core'; import { CSS } from '@dnd-kit/utilities'; import { Avatar, ButtonBase } from '@mui/material'; import { AppCircle, AppCircleContainer, AppCircleLabel } from './Apps-styles'; -import { getBaseApiReact } from '../../App'; +import { getBaseApiReact, MyContext } from '../../App'; import { executeEvent } from '../../utils/events'; import { settingsLocalLastUpdatedAtom, sortablePinnedAppsAtom } from '../../atoms/global'; import { useRecoilState, useSetRecoilState } from 'recoil'; import { saveToLocalStorage } from './AppsNavBar'; import { ContextMenuPinnedApps } from '../ContextMenuPinnedApps'; - +import LockIcon from "@mui/icons-material/Lock"; +import { useHandlePrivateApps } from './useHandlePrivateApps'; const SortableItem = ({ id, name, app, isDesktop }) => { + const {openApp} = useHandlePrivateApps() + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id }); const style = { transform: CSS.Transform.toString(transform), @@ -28,17 +31,27 @@ const SortableItem = ({ id, name, app, isDesktop }) => { return ( - { - executeEvent("addTab", { - data: app - }) + onClick={async ()=> { + if(app?.isPrivate){ + try { + await openApp(app?.privateAppProperties) + } catch (error) { + console.error(error) + } + + } else { + executeEvent("addTab", { + data: app + }) + } + }} > { border: "none", }} > - + ) : ( + { } }} alt={app?.metadata?.title || app?.name} - src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${ + src={ app?.privateAppProperties?.logo ? app?.privateAppProperties?.logo :`${getBaseApiReact()}/arbitrary/THUMBNAIL/${ app?.name }/qortal_avatar?async=true`} > @@ -72,10 +93,19 @@ const SortableItem = ({ id, name, app, isDesktop }) => { alt="center-icon" /> + )} + - + {app?.isPrivate ? ( + + {`${app?.privateAppProperties?.appName || "Private"}`} + + ) : ( + {app?.metadata?.title || app?.name} + )} + diff --git a/src/components/Apps/TabComponent.tsx b/src/components/Apps/TabComponent.tsx index aca6b55..ecf17a7 100644 --- a/src/components/Apps/TabComponent.tsx +++ b/src/components/Apps/TabComponent.tsx @@ -5,6 +5,7 @@ import { getBaseApiReact } from '../../App'; import { Avatar, ButtonBase } from '@mui/material'; import LogoSelected from "../../assets/svgs/LogoSelected.svg"; import { executeEvent } from '../../utils/events'; +import LockIcon from "@mui/icons-material/Lock"; const TabComponent = ({isSelected, app}) => { return ( @@ -34,25 +35,34 @@ const TabComponent = ({isSelected, app}) => { } src={NavCloseTab}/> ) } - - center-icon - + {app?.isPrivate && !app?.privateAppProperties?.logo ? ( + + ) : ( + + center-icon + + )} ) diff --git a/src/components/Apps/useHandlePrivateApps.tsx b/src/components/Apps/useHandlePrivateApps.tsx new file mode 100644 index 0000000..2eaa5f9 --- /dev/null +++ b/src/components/Apps/useHandlePrivateApps.tsx @@ -0,0 +1,237 @@ +import React, { useContext, useState } from "react"; +import { executeEvent } from "../../utils/events"; +import { getBaseApiReact, MyContext } from "../../App"; +import { createEndpoint } from "../../background"; +import { useRecoilState, useSetRecoilState } from "recoil"; +import { + settingsLocalLastUpdatedAtom, + sortablePinnedAppsAtom, +} from "../../atoms/global"; +import { saveToLocalStorage } from "./AppsNavBarDesktop"; +import { base64ToBlobUrl } from "../../utils/fileReading"; +import { base64ToUint8Array } from "../../qdn/encryption/group-encryption"; +import { uint8ArrayToObject } from "../../backgroundFunctions/encryption"; + +export const useHandlePrivateApps = () => { + const [status, setStatus] = useState(""); + const { + openSnackGlobal, + setOpenSnackGlobal, + infoSnackCustom, + setInfoSnackCustom, + } = useContext(MyContext); + const [sortablePinnedApps, setSortablePinnedApps] = useRecoilState( + sortablePinnedAppsAtom + ); + const setSettingsLocalLastUpdated = useSetRecoilState( + settingsLocalLastUpdatedAtom + ); + const openApp = async ( + privateAppProperties, + addToPinnedApps, + setLoadingStatePrivateApp + ) => { + try { + + + if(setLoadingStatePrivateApp){ + setLoadingStatePrivateApp(`Downloading and decrypting private app.`); + + } + setOpenSnackGlobal(true); + + setInfoSnackCustom({ + type: "info", + message: "Fetching app data", + duration: null + }); + const urlData = `${getBaseApiReact()}/arbitrary/${ + privateAppProperties?.service + }/${privateAppProperties?.name}/${ + privateAppProperties?.identifier + }?encoding=base64`; + let data; + try { + const responseData = await fetch(urlData, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if(!responseData?.ok){ + if(setLoadingStatePrivateApp){ + setLoadingStatePrivateApp("Error! Unable to download private app."); + } + + throw new Error("Unable to fetch app"); + } + + data = await responseData.text(); + if (data?.error) { + if(setLoadingStatePrivateApp){ + + setLoadingStatePrivateApp("Error! Unable to download private app."); + } + throw new Error("Unable to fetch app"); + } + } catch (error) { + if(setLoadingStatePrivateApp){ + + setLoadingStatePrivateApp("Error! Unable to download private app."); + } + throw error; + } + + let decryptedData; + // eslint-disable-next-line no-useless-catch + try { + decryptedData = await window.sendMessage( + "DECRYPT_QORTAL_GROUP_DATA", + + { + base64: data, + groupId: privateAppProperties?.groupId, + } + ); + if (decryptedData?.error) { + if(setLoadingStatePrivateApp){ + + setLoadingStatePrivateApp("Error! Unable to decrypt private app."); + } + throw new Error(decryptedData?.error); + } + } catch (error) { + if(setLoadingStatePrivateApp){ + + setLoadingStatePrivateApp("Error! Unable to decrypt private app."); + } + throw error; + } + + try { + const convertToUint = base64ToUint8Array(decryptedData); + const UintToObject = uint8ArrayToObject(convertToUint); + + if (decryptedData) { + setInfoSnackCustom({ + type: "info", + message: "Building app", + }); + const endpoint = await createEndpoint( + `/arbitrary/APP/${privateAppProperties?.name}/zip?preview=true` + ); + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "text/plain", + }, + body: UintToObject?.app, + }); + const previewPath = await response.text(); + const refreshfunc = async (tabId, privateAppProperties) => { + const checkIfPreviewLinkStillWorksUrl = await createEndpoint( + `/render/hash/HmtnZpcRPwisMfprUXuBp27N2xtv5cDiQjqGZo8tbZS?secret=E39WTiG4qBq3MFcMPeRZabtQuzyfHg9ZuR5SgY7nW1YH` + ); + const res = await fetch(checkIfPreviewLinkStillWorksUrl); + if (res.ok) { + executeEvent("refreshApp", { + tabId: tabId, + }); + } else { + const endpoint = await createEndpoint( + `/arbitrary/APP/${privateAppProperties?.name}/zip?preview=true` + ); + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "text/plain", + }, + body: UintToObject?.app, + }); + const previewPath = await response.text(); + executeEvent("updateAppUrl", { + tabId: tabId, + url: await createEndpoint(previewPath), + }); + + setTimeout(() => { + executeEvent("refreshApp", { + tabId: tabId, + }); + }, 300); + } + }; + + const appName = UintToObject?.name; + const logo = UintToObject?.logo + ? `data:image/png;base64,${UintToObject?.logo}` + : null; + + const dataBody = { + url: await createEndpoint(previewPath), + isPreview: true, + isPrivate: true, + privateAppProperties: { ...privateAppProperties, logo, appName }, + filePath: "", + refreshFunc: (tabId) => { + refreshfunc(tabId, privateAppProperties); + }, + }; + executeEvent("addTab", { + data: dataBody, + }); + setInfoSnackCustom({ + type: "success", + message: "Opened", + }); + if(setLoadingStatePrivateApp){ + + setLoadingStatePrivateApp(``); + } + if (addToPinnedApps) { + setSortablePinnedApps((prev) => { + const updatedApps = [ + ...prev, + { + isPrivate: true, + isPreview: true, + privateAppProperties: { + ...privateAppProperties, + logo, + appName, + }, + }, + ]; + + saveToLocalStorage( + "ext_saved_settings", + "sortablePinnedApps", + updatedApps + ); + return updatedApps; + }); + setSettingsLocalLastUpdated(Date.now()); + } + } + } catch (error) { + if(setLoadingStatePrivateApp){ + + setLoadingStatePrivateApp(`Error! ${error?.message || 'Unable to build private app.'}`); + } + throw error + } + } + catch (error) { + setInfoSnackCustom({ + type: "error", + message: error?.message || "Unable to fetch app", + }); + } + + }; + return { + openApp, + status, + }; +}; diff --git a/src/components/ContextMenuPinnedApps.tsx b/src/components/ContextMenuPinnedApps.tsx index be0ae46..bb64a4c 100644 --- a/src/components/ContextMenuPinnedApps.tsx +++ b/src/components/ContextMenuPinnedApps.tsx @@ -124,11 +124,19 @@ export const ContextMenuPinnedApps = ({ children, app, isMine }) => { { handleClose(e); setSortablePinnedApps((prev) => { - const updatedApps = prev.filter( - (item) => !(item?.name === app?.name && item?.service === app?.service) - ); - saveToLocalStorage('ext_saved_settings', 'sortablePinnedApps', updatedApps); - return updatedApps; + if(app?.isPrivate){ + const updatedApps = prev.filter( + (item) => !(item?.privateAppProperties?.name === app?.privateAppProperties?.name && item?.privateAppProperties?.service === app?.privateAppProperties?.service && item?.privateAppProperties?.identifier === app?.privateAppProperties?.identifier) + ); + saveToLocalStorage('ext_saved_settings', 'sortablePinnedApps', updatedApps); + return updatedApps; + } else { + const updatedApps = prev.filter( + (item) => !(item?.name === app?.name && item?.service === app?.service) + ); + saveToLocalStorage('ext_saved_settings', 'sortablePinnedApps', updatedApps); + return updatedApps; + } }); }}> diff --git a/src/components/Snackbar/Snackbar.tsx b/src/components/Snackbar/Snackbar.tsx index 3e5fccb..59fa295 100644 --- a/src/components/Snackbar/Snackbar.tsx +++ b/src/components/Snackbar/Snackbar.tsx @@ -22,7 +22,7 @@ export const CustomizedSnackbars = ({open, setOpen, info, setInfo, duration}) = if(!open) return null return (
- +