From f13d543715b70435f0b906e42ed42acd176a25a9 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Sat, 6 Jul 2024 11:43:05 +0200 Subject: [PATCH] vinvoor: support adding new cards --- vingo/handlers/cards.go | 2 +- vinvoor/package.json | 2 + vinvoor/src/App.tsx | 2 - vinvoor/src/cards/Cards.tsx | 28 ++-- vinvoor/src/cards/CardsAdd.tsx | 135 ++++++++++++++----- vinvoor/src/cards/CardsDelete.tsx | 56 +++----- vinvoor/src/cards/CardsTable.tsx | 43 ++---- vinvoor/src/cards/CardsTableBody.tsx | 9 +- vinvoor/src/cards/CardsTableToolbar.tsx | 11 +- vinvoor/src/components/ConfirmationModal.tsx | 61 --------- vinvoor/src/hooks/useFetch.ts | 7 +- vinvoor/src/main.tsx | 13 +- vinvoor/src/types/cards.ts | 18 ++- vinvoor/src/types/table.ts | 1 + vinvoor/src/user/UserProvider.tsx | 4 +- vinvoor/src/util/fetch.ts | 46 +++++-- vinvoor/src/util/util.ts | 43 ++++++ vinvoor/yarn.lock | 23 ++++ 18 files changed, 301 insertions(+), 203 deletions(-) delete mode 100644 vinvoor/src/components/ConfirmationModal.tsx create mode 100644 vinvoor/src/util/util.ts diff --git a/vingo/handlers/cards.go b/vingo/handlers/cards.go index 1f2c756..dc35b75 100644 --- a/vingo/handlers/cards.go +++ b/vingo/handlers/cards.go @@ -20,7 +20,7 @@ func StartCardRegisterAPI(c *fiber.Ctx) error { logger.Println("Card registration started by user", registering_user) - return c.SendStatus(200) + return c.Status(200).JSON(map[string]bool{}) } func StartCardRegister(c *fiber.Ctx) error { diff --git a/vinvoor/package.json b/vinvoor/package.json index 2f17aa0..33653ec 100644 --- a/vinvoor/package.json +++ b/vinvoor/package.json @@ -20,7 +20,9 @@ "@types/react-router-dom": "^5.3.3", "@types/react-router-hash-link": "^2.4.9", "js-cookie": "^3.0.5", + "material-ui-confirm": "^3.0.16", "mdi-material-ui": "^7.9.1", + "notistack": "^3.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.23.1", diff --git a/vinvoor/src/App.tsx b/vinvoor/src/App.tsx index 8371a6c..1cec1e6 100644 --- a/vinvoor/src/App.tsx +++ b/vinvoor/src/App.tsx @@ -29,5 +29,3 @@ export const App = () => { ); }; - -// TODO: Add link to the github repo diff --git a/vinvoor/src/cards/Cards.tsx b/vinvoor/src/cards/Cards.tsx index fa2c699..80818ed 100644 --- a/vinvoor/src/cards/Cards.tsx +++ b/vinvoor/src/cards/Cards.tsx @@ -1,21 +1,33 @@ -import { useState } from "react"; +import { createContext, Dispatch, SetStateAction, useState } from "react"; import { LoadingSkeleton } from "../components/LoadingSkeleton"; import { useFetch } from "../hooks/useFetch"; -import { Card } from "../types/cards"; +import { Card, convertCardJSON } from "../types/cards"; import { CardsEmpty } from "./CardsEmpty"; import { CardsTable } from "./CardsTable"; +interface CardContextProps { + cards: readonly Card[]; + setCards: Dispatch>; +} + +export const CardContext = createContext({ + cards: [], + setCards: () => {}, +}); + export const Cards = () => { const [cards, setCards] = useState([]); - const { loading, error: _ } = useFetch("cards", setCards); + const { loading, error: _ } = useFetch( + "cards", + setCards, + convertCardJSON + ); return ( - {!!cards.length ? ( - - ) : ( - - )} + + {!!cards.length ? : } + ); }; diff --git a/vinvoor/src/cards/CardsAdd.tsx b/vinvoor/src/cards/CardsAdd.tsx index 1eb4b9b..e6b818e 100644 --- a/vinvoor/src/cards/CardsAdd.tsx +++ b/vinvoor/src/cards/CardsAdd.tsx @@ -1,47 +1,110 @@ -import { Add, CancelOutlined } from "@mui/icons-material"; +import { Add } from "@mui/icons-material"; import { Button, Typography } from "@mui/material"; -import { useState } from "react"; -import { ConfirmationModal } from "../components/ConfirmationModal"; +import { useConfirm } from "material-ui-confirm"; +import { useSnackbar } from "notistack"; +import { useContext, useState } from "react"; +import { Card, CardPostResponse, convertCardJSON } from "../types/cards"; +import { getApi, isResponseNot200Error, postApi } from "../util/fetch"; +import { equal, randomInt } from "../util/util"; +import { CardContext } from "./Cards"; + +const CHECK_INTERVAL = 1000; +const REGISTER_TIME = 60000; + +const confirmTitle = "Register a new card"; +const confirmContent = ` + Once you click 'register' you will have 60 seconds to hold your card to the scanner. + A popup will appear when the card is registered successfully and it will be added to your cards table. + `; + +const requestSuccess = "Register your card by holding it to vinscant"; +const requestYou = "You are already registering a card!"; +const requestOther = + "Failed to start the card registering process because another user is already registering a card. Please try again later."; +const requestFail = + "Failed to start the card registration process. Please try again later or contact a sysadmin"; + +const registerSucces = "Card registered successfully"; +const registerFail = "Failed to register card"; + +const getCards = () => + getApi("cards", convertCardJSON).catch((_) => null); + +const checkCardsChange = async (): Promise< + [boolean, readonly Card[] | null] +> => { + const startTime = Date.now(); + const cardsStart = await getCards(); + + if (!cardsStart) return [false, null]; + + let cardsNow: readonly Card[] | null = null; + while (Date.now() - startTime < REGISTER_TIME) { + cardsNow = await getCards(); + + if (!equal(cardsStart, cardsNow)) break; + + await new Promise((r) => setTimeout(r, CHECK_INTERVAL)); + } + + return [cardsNow !== null && cardsNow !== cardsStart, cardsNow]; +}; export const CardsAdd = () => { - const [open, setOpen] = useState(false); + const { setCards } = useContext(CardContext); + const [disabled, setDisabled] = useState(false); + const confirm = useConfirm(); + const { enqueueSnackbar, closeSnackbar } = useSnackbar(); - const handleOpen = () => setOpen(true); - const handleClose = () => setOpen(false); + const startRegistering = () => + postApi("cards/register") + .then(() => { + const id = randomInt().toString(); + enqueueSnackbar(requestSuccess, { + variant: "info", + persist: true, + key: id, + }); + setDisabled(true); - const title = "Register a new card"; + checkCardsChange().then((result) => { + closeSnackbar(id); + setDisabled(false); - const content = ` - This feature is not yet implemented as I'm waiting for an endpoint. - Hannes................................................ - `; + if (result[0] && result[1] !== null) { + enqueueSnackbar(registerSucces, { variant: "success" }); + setCards(result[1]); + } else enqueueSnackbar(registerFail, { variant: "error" }); + }); + }) + .catch((error) => { + if (isResponseNot200Error(error)) { + error.response.json().then((response: CardPostResponse) => { + if (response.is_current_user) + enqueueSnackbar(requestYou, { variant: "warning" }); + else + enqueueSnackbar(requestOther, { variant: "error" }); + }); + } else enqueueSnackbar(requestFail, { variant: "error" }); + }); - const actions = ( - <> - - - - ); + const handleClick = () => { + confirm({ + title: confirmTitle, + description: confirmContent, + confirmationText: "Register", + }).then(() => startRegistering()); + }; return ( - <> - - - + ); }; diff --git a/vinvoor/src/cards/CardsDelete.tsx b/vinvoor/src/cards/CardsDelete.tsx index df7e274..6e318de 100644 --- a/vinvoor/src/cards/CardsDelete.tsx +++ b/vinvoor/src/cards/CardsDelete.tsx @@ -1,25 +1,17 @@ -import { CancelOutlined } from "@mui/icons-material"; import DeleteIcon from "@mui/icons-material/Delete"; -import { Button, IconButton, Tooltip, Typography } from "@mui/material"; -import { Dispatch, FC, SetStateAction, useState } from "react"; -import { ConfirmationModal } from "../components/ConfirmationModal"; -import { Card } from "../types/cards"; +import { IconButton, Tooltip } from "@mui/material"; +import { useConfirm } from "material-ui-confirm"; +import { FC } from "react"; interface CardDeleteProps { selected: readonly string[]; - setCards: Dispatch>; } -export const CardsDelete: FC = ({ selected, setCards }) => { - const [open, setOpen] = useState(false); - +export const CardsDelete: FC = ({ selected }) => { + const confirm = useConfirm(); const numSelected = selected.length; - const handleOpen = () => setOpen(true); - const handleClose = () => setOpen(false); - const title = `Delete card${numSelected > 1 ? "s" : ""}`; - const content = ` Are you sure you want to delete ${numSelected} card${ numSelected > 1 ? "s" : "" @@ -29,33 +21,19 @@ export const CardsDelete: FC = ({ selected, setCards }) => { Hannnneeeeeeees........................... `; - const actions = ( - <> - - - - ); + const handleClick = () => { + confirm({ + title: title, + description: content, + confirmationText: "Delete", + }).then(() => console.log("Card deleted!")); + }; return ( - <> - - - - - - - + + + + + ); }; diff --git a/vinvoor/src/cards/CardsTable.tsx b/vinvoor/src/cards/CardsTable.tsx index 8c463aa..c21829c 100644 --- a/vinvoor/src/cards/CardsTable.tsx +++ b/vinvoor/src/cards/CardsTable.tsx @@ -1,33 +1,18 @@ import { Paper, Table, TableContainer, TablePagination } from "@mui/material"; -import { - ChangeEvent, - Dispatch, - FC, - MouseEvent, - SetStateAction, - useMemo, - useState, -} from "react"; +import { ChangeEvent, MouseEvent, useContext, useMemo, useState } from "react"; import { Card } from "../types/cards"; import { TableOrder } from "../types/table"; +import { CardContext } from "./Cards"; import { CardsTableBody } from "./CardsTableBody"; import { CardsTableHead } from "./CardsTableHead"; import { CardsTableToolbar } from "./CardsTableToolbar"; -interface CardTableProps { - cards: readonly Card[]; - setCards: Dispatch>; -} - const rowsPerPageOptions = [10, 25, 50]; const descendingComparator = (a: T, b: T, orderBy: keyof T) => { - if (b[orderBy] < a[orderBy]) { - return -1; - } - if (b[orderBy] > a[orderBy]) { - return 1; - } + if (b[orderBy] < a[orderBy]) return -1; + if (b[orderBy] > a[orderBy]) return 1; + return 0; }; @@ -47,18 +32,19 @@ const stableSort = ( array: readonly T[], comparator: (a: T, b: T) => number ) => { - const stabilizedThis = array.map((el, index) => [el, index] as [T, number]); - stabilizedThis.sort((a, b) => { + const stabilized = array.map((el, index) => [el, index] as [T, number]); + stabilized.sort((a, b) => { const order = comparator(a[0], b[0]); if (order !== 0) { return order; } return a[1] - b[1]; }); - return stabilizedThis.map((el) => el[0]); + return stabilized.map((el) => el[0]); }; -export const CardsTable: FC = ({ cards, setCards }) => { +export const CardsTable = () => { + const { cards } = useContext(CardContext); const [order, setOrder] = useState("asc"); const [orderBy, setOrderBy] = useState("serial"); const [selected, setSelected] = useState([]); @@ -78,6 +64,7 @@ export const CardsTable: FC = ({ cards, setCards }) => { if (event.target.checked) { const newSelected = cards.map((n) => n.serial); setSelected(newSelected); + return; } @@ -114,9 +101,7 @@ export const CardsTable: FC = ({ cards, setCards }) => { const handleChangePage = ( _: MouseEvent | null, newPage: number - ) => { - setPage(newPage); - }; + ) => setPage(newPage); const handleChangeRowsPerPage = (event: ChangeEvent) => { setRowsPerPage(parseInt(event.target.value, 10)); @@ -134,12 +119,12 @@ export const CardsTable: FC = ({ cards, setCards }) => { page * rowsPerPage, page * rowsPerPage + rowsPerPage ), - [order, orderBy, page, rowsPerPage] + [cards, order, orderBy, page, rowsPerPage] ); return ( - + = ({ align={headCell.align} padding={headCell.padding} > - {row[headCell.id]} + + {headCell.convert + ? headCell.convert(row[headCell.id]) + : (row[headCell.id] as string)} + ))} @@ -63,6 +67,3 @@ export const CardsTableBody: FC = ({ ); }; - -// TODO: Go over all mouse events -// TODO: Move all components props diff --git a/vinvoor/src/cards/CardsTableToolbar.tsx b/vinvoor/src/cards/CardsTableToolbar.tsx index 5057be0..f9dc865 100644 --- a/vinvoor/src/cards/CardsTableToolbar.tsx +++ b/vinvoor/src/cards/CardsTableToolbar.tsx @@ -1,19 +1,14 @@ import { Toolbar, Typography } from "@mui/material"; import { alpha } from "@mui/material/styles"; -import { Dispatch, FC, SetStateAction } from "react"; -import { Card } from "../types/cards"; +import { FC } from "react"; import { CardsAdd } from "./CardsAdd"; import { CardsDelete } from "./CardsDelete"; interface CardTableToolbarProps { selected: readonly string[]; - setCards: Dispatch>; } -export const CardsTableToolbar: FC = ({ - selected, - setCards, -}) => { +export const CardsTableToolbar: FC = ({ selected }) => { const numSelected = selected.length; return ( @@ -38,7 +33,7 @@ export const CardsTableToolbar: FC = ({ > {numSelected} selected - + ) : ( <> diff --git a/vinvoor/src/components/ConfirmationModal.tsx b/vinvoor/src/components/ConfirmationModal.tsx deleted file mode 100644 index 3d83924..0000000 --- a/vinvoor/src/components/ConfirmationModal.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Box, Modal } from "@mui/material"; -import { FC, ReactNode } from "react"; -import { TypographyG } from "./TypographyG"; - -interface ConfirmationModalProps { - open: boolean; - onClose: () => void; - title: string; - content: ReactNode; - actions: ReactNode; -} - -export const ConfirmationModal: FC = ({ - open, - onClose, - title, - content, - actions, -}) => { - return ( - - - - {title} - {content} - - - {actions} - - - - ); -}; diff --git a/vinvoor/src/hooks/useFetch.ts b/vinvoor/src/hooks/useFetch.ts index 0a2b1ac..c26ac0b 100644 --- a/vinvoor/src/hooks/useFetch.ts +++ b/vinvoor/src/hooks/useFetch.ts @@ -1,5 +1,5 @@ import { Dispatch, SetStateAction, useEffect, useState } from "react"; -import { fetchApi } from "../util/fetch"; +import { getApi } from "../util/fetch"; interface useFetchResult { loading: boolean; @@ -8,13 +8,14 @@ interface useFetchResult { export const useFetch = ( endpoint: string, - setData: Dispatch> + setData: Dispatch>, + convertData?: (data: any) => T ): useFetchResult => { const [loading, setLoading] = useState(true); const [error, setError] = useState(undefined); useEffect(() => { - fetchApi(endpoint) + getApi(endpoint, convertData) .then((data) => setData(data)) .catch((error) => setError(error)) .finally(() => setLoading(false)); diff --git a/vinvoor/src/main.tsx b/vinvoor/src/main.tsx index 8f070ec..32df151 100644 --- a/vinvoor/src/main.tsx +++ b/vinvoor/src/main.tsx @@ -3,6 +3,8 @@ import "@fontsource/roboto/400.css"; import "@fontsource/roboto/500.css"; import "@fontsource/roboto/700.css"; import { CssBaseline } from "@mui/material"; +import { ConfirmProvider } from "material-ui-confirm"; +import { SnackbarProvider } from "notistack"; import React from "react"; import ReactDOM from "react-dom/client"; import { createBrowserRouter, RouterProvider } from "react-router-dom"; @@ -47,7 +49,16 @@ ReactDOM.createRoot(document.getElementById("root")!).render( - + + + + + diff --git a/vinvoor/src/types/cards.ts b/vinvoor/src/types/cards.ts index 223a1d3..2f78d61 100644 --- a/vinvoor/src/types/cards.ts +++ b/vinvoor/src/types/cards.ts @@ -1,10 +1,21 @@ import { TableHeadCell } from "./table"; -export interface Card { +interface CardJSON { serial: string; createdAt: string; } +export interface Card { + serial: string; + createdAt: Date; +} + +export const convertCardJSON = (cardsJSON: CardJSON[]): Card[] => + cardsJSON.map((CardJSON) => ({ + serial: CardJSON.serial, + createdAt: new Date(CardJSON.createdAt), + })); + export const CardsHeadCells: readonly TableHeadCell[] = [ { id: "serial", @@ -17,5 +28,10 @@ export const CardsHeadCells: readonly TableHeadCell[] = [ label: "Created at", align: "right", padding: "normal", + convert: (value: Date) => value.toDateString(), }, ]; + +export interface CardPostResponse { + is_current_user: boolean; +} diff --git a/vinvoor/src/types/table.ts b/vinvoor/src/types/table.ts index 3d4a059..dc0603e 100644 --- a/vinvoor/src/types/table.ts +++ b/vinvoor/src/types/table.ts @@ -8,4 +8,5 @@ export interface TableHeadCell { label: string; align: TableAlignOptions; padding: TablePaddingOptions; + convert?: (value: any) => string; } diff --git a/vinvoor/src/user/UserProvider.tsx b/vinvoor/src/user/UserProvider.tsx index 7577470..f145e97 100644 --- a/vinvoor/src/user/UserProvider.tsx +++ b/vinvoor/src/user/UserProvider.tsx @@ -9,7 +9,7 @@ import { useState, } from "react"; import { User } from "../types/user"; -import { fetchApi } from "../util/fetch"; +import { getApi } from "../util/fetch"; interface UserProviderProps { children: ReactNode; @@ -55,7 +55,7 @@ export const UserProvider: FC = ({ children }) => { let newUserState = { ...userState }; - fetchApi("user") + getApi("user") .then((data) => (newUserState.user = data)) .catch((error) => { Cookies.remove("session_id"); diff --git a/vinvoor/src/util/fetch.ts b/vinvoor/src/util/fetch.ts index 0497de2..e1db30d 100644 --- a/vinvoor/src/util/fetch.ts +++ b/vinvoor/src/util/fetch.ts @@ -3,16 +3,46 @@ const URLS: { [key: string]: string } = { API: import.meta.env.VITE_API_URL, }; -export const fetchBase = (endpoint: string) => { - return _fetch(`${URLS.BASE}/${endpoint}`); +export const getApi = (endpoint: string, convertData?: (data: any) => T) => { + return _fetch(`${URLS.API}/${endpoint}`, {}, convertData); }; -export const fetchApi = (endpoint: string) => { - return _fetch(`${URLS.API}/${endpoint}`); +export const postApi = ( + endpoint: string, + body: { [key: string]: string } = {} +) => { + return _fetch(`${URLS.API}/${endpoint}`, { + method: "post", + body: JSON.stringify(body), + }); }; -const _fetch = async (url: string) => { - return fetch(url, { credentials: "include" }).then((response) => - response.json() - ); +interface ResponseNot200Error extends Error { + response: Response; +} + +export const isResponseNot200Error = ( + error: any +): error is ResponseNot200Error => { + return (error as ResponseNot200Error).response !== undefined; +}; + +const _fetch = async ( + url: string, + options: RequestInit = {}, + convertData?: (data: any) => T +): Promise => { + return fetch(url, { credentials: "include", ...options }) + .then((response) => { + if (!response.ok) { + const error = new Error( + "Fetch failed with status: " + response.status + ) as ResponseNot200Error; + error.response = response; + throw error; + } + + return response.json(); + }) + .then((data) => (convertData ? convertData(data) : data)); }; diff --git a/vinvoor/src/util/util.ts b/vinvoor/src/util/util.ts new file mode 100644 index 0000000..5ce3b09 --- /dev/null +++ b/vinvoor/src/util/util.ts @@ -0,0 +1,43 @@ +export const randomInt = (lower: number = 0, upper: number = 10000): number => { + return Math.floor(Math.random() * (upper - lower + 1) + lower); +}; + +export const equal = (left: any, right: any): boolean => { + if (typeof left !== typeof right) return false; + + if (Array.isArray(left) && Array.isArray(right)) + return equalArray(left, right); + if (typeof left === "object" && left !== null && right !== null) + return equalObject( + left as Record, + right as Record + ); + + return left === right; +}; + +const equalArray = (left: any[], right: any[]): boolean => { + if (left.length !== right.length) return false; + + for (let i = 0; i < left.length; i++) { + if (!equal(left[i], right[i])) return false; + } + + return true; +}; + +const equalObject = ( + left: Record, + right: Record +): boolean => { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + + if (leftKeys.length !== rightKeys.length) return false; + + for (const key of leftKeys) { + if (!equal(left[key], right[key])) return false; + } + + return true; +}; diff --git a/vinvoor/yarn.lock b/vinvoor/yarn.lock index 78fe7ca..6f3d8bb 100644 --- a/vinvoor/yarn.lock +++ b/vinvoor/yarn.lock @@ -1016,6 +1016,11 @@ chalk@^4.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +clsx@^1.1.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" + integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== + clsx@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" @@ -1399,6 +1404,11 @@ globby@^11.1.0: merge2 "^1.4.1" slash "^3.0.0" +goober@^2.0.33: + version "2.1.14" + resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.14.tgz#4a5c94fc34dc086a8e6035360ae1800005135acd" + integrity sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg== + graphemer@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" @@ -1579,6 +1589,11 @@ loose-envify@^1.1.0, loose-envify@^1.4.0: dependencies: js-tokens "^3.0.0 || ^4.0.0" +material-ui-confirm@^3.0.16: + version "3.0.16" + resolved "https://registry.yarnpkg.com/material-ui-confirm/-/material-ui-confirm-3.0.16.tgz#8b25b4770a0f15d888c838bcd21180f655e03469" + integrity sha512-aJoa/FM/U/86qztoljlk8FWmjSJbAMzDWCdWbDqU5WwB0WzcWPyGrhBvIqihR9uKdHKBf1YrvMjn68uOrfsXAg== + mdi-material-ui@^7.9.1: version "7.9.1" resolved "https://registry.yarnpkg.com/mdi-material-ui/-/mdi-material-ui-7.9.1.tgz#f28dbd6883a8c6198ca78e19c9f23728b2599226" @@ -1626,6 +1641,14 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +notistack@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/notistack/-/notistack-3.0.1.tgz#daf59888ab7e2c30a1fa8f71f9cba2978773236e" + integrity sha512-ntVZXXgSQH5WYfyU+3HfcXuKaapzAJ8fBLQ/G618rn3yvSzEbnOB8ZSOwhX+dAORy/lw+GC2N061JA0+gYWTVA== + dependencies: + clsx "^1.1.0" + goober "^2.0.33" + object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"