diff --git a/package-lock.json b/package-lock.json index d83c2ed2..5584e1eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-tooltip": "^1.0.7", "@sentry/nextjs": "^7.99.0", - "@skip-go/widget": "0.0.1-alpha.16", + "@skip-go/widget": "0.0.1-alpha.19", "@skip-router/core": "5.0.4", "@solana/spl-token": "^0.4.1", "@solana/wallet-adapter-react": "^0.15.35", @@ -11318,9 +11318,9 @@ } }, "node_modules/@skip-go/widget": { - "version": "0.0.1-alpha.16", - "resolved": "https://registry.npmjs.org/@skip-go/widget/-/widget-0.0.1-alpha.16.tgz", - "integrity": "sha512-VopsZH++EYuGMmr/GwSK4x13yWjtHbzrCseqoAxB0rBnLcWNmVVZQSsYkxNfYMHeGz1LXQ+2h/Ij7gdH8qh7dg==", + "version": "0.0.1-alpha.19", + "resolved": "https://registry.npmjs.org/@skip-go/widget/-/widget-0.0.1-alpha.19.tgz", + "integrity": "sha512-jft9ViSNTQGZSWlcw3T2kYygFOKZIef97glk80dC67zNbnbRp+mGxbBRIM35tI29qHkdx5p0Ab+/psjh9Wz82Q==", "dependencies": { "@cosmos-kit/cosmostation-extension": "^2.7.10", "@cosmos-kit/keplr-extension": "^2.7.9", @@ -11340,7 +11340,7 @@ "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tooltip": "^1.0.7", - "@skip-router/core": "5.0.5", + "@skip-router/core": "5.1.0-rc.0", "@solana/spl-token": "^0.4.6", "@solana/wallet-adapter-react": "^0.15.35", "@solana/wallet-adapter-wallets": "^0.19.32", @@ -11502,27 +11502,27 @@ "integrity": "sha512-WCZK4yksj2hBDz4w7xFZQTRZQ/RJhBX26uFHmmQFIcNUUVAihrLO+RerqJgk0dZqC42wstM9pEUQGtPmLcIYvg==" }, "node_modules/@skip-go/widget/node_modules/@keplr-wallet/types": { - "version": "0.12.103", - "resolved": "https://registry.npmjs.org/@keplr-wallet/types/-/types-0.12.103.tgz", - "integrity": "sha512-S0rcBLUBlOWNE6e8ri1IxCrlVk41ST+68Ge9gNHixwkjDrTURV+BIJjlp1EYL6Kf/D2JcqKMtxkZ9dJcxi8KGQ==", + "version": "0.12.104", + "resolved": "https://registry.npmjs.org/@keplr-wallet/types/-/types-0.12.104.tgz", + "integrity": "sha512-J3A8eK8vf419ikkH+9FA+WYc3xVOT6e0nxOoQJY5livjxMWCpo7UO+hvK82yS/lR/s/rremNyqXjKxHJZefs7w==", "dependencies": { "long": "^4.0.0" } }, "node_modules/@skip-go/widget/node_modules/@keplr-wallet/unit": { - "version": "0.12.103", - "resolved": "https://registry.npmjs.org/@keplr-wallet/unit/-/unit-0.12.103.tgz", - "integrity": "sha512-KQh4D+TAi4ei8avri6Ujo7w7NgComYkpyCjv7PnclpRaHKngM0fFrd5ZlCnu8Rhy9o8Rv0FNb5EVakyG0qLCjw==", + "version": "0.12.104", + "resolved": "https://registry.npmjs.org/@keplr-wallet/unit/-/unit-0.12.104.tgz", + "integrity": "sha512-cFV/Oxv22rvrTCW0FVrYtcUlXcBgj7UrqnnSZMhFCwUqyAURdhM/QT2wLxv5JV9020IGpBAOLzMIcxShykUUJw==", "dependencies": { - "@keplr-wallet/types": "0.12.103", + "@keplr-wallet/types": "0.12.104", "big-integer": "^1.6.48", "utility-types": "^3.10.0" } }, "node_modules/@skip-go/widget/node_modules/@skip-router/core": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@skip-router/core/-/core-5.0.5.tgz", - "integrity": "sha512-6pGGNCGoAB83GJUJYgcqmHQ/3xraJ010Eg34FwTWTKWH58NEuEKkSw0oxzOwHOZ18B64rP3WdOtEl9eu372y0A==", + "version": "5.1.0-rc.0", + "resolved": "https://registry.npmjs.org/@skip-router/core/-/core-5.1.0-rc.0.tgz", + "integrity": "sha512-oHnshIyCiLurnvAGbsk5yBtqPA130IBC6VigcP8ofBwIk0BsJ2jGehhdkjzIG3j0tDnniKA4GedTJWUDUb0dTA==", "dependencies": { "@cosmjs/amino": "0.32.3", "@cosmjs/cosmwasm-stargate": "0.32.3", diff --git a/package.json b/package.json index 9f07a4cc..31ca3502 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@radix-ui/react-tooltip": "^1.0.7", "@sentry/nextjs": "^7.99.0", "@skip-router/core": "5.0.4", - "@skip-go/widget": "0.0.1-alpha.16", + "@skip-go/widget": "0.0.1-alpha.19", "@solana/spl-token": "^0.4.1", "@solana/wallet-adapter-react": "^0.15.35", "@solana/wallet-adapter-wallets": "^0.19.31", diff --git a/src/components/AssetInput.tsx b/src/components/AssetInput.tsx new file mode 100644 index 00000000..9044f8b5 --- /dev/null +++ b/src/components/AssetInput.tsx @@ -0,0 +1,232 @@ +import { BackspaceIcon } from "@heroicons/react/20/solid"; +import { Asset } from "@skip-router/core"; +import { BigNumber } from "bignumber.js"; +import { MouseEventHandler, useMemo } from "react"; +import { formatUnits } from "viem"; + +import { useAssets } from "@/context/assets"; +import { useAnyDisclosureOpen } from "@/context/disclosures"; +import { useAccount } from "@/hooks/useAccount"; +import { useBalancesByChain } from "@/hooks/useBalancesByChain"; +import { Chain } from "@/hooks/useChains"; +import { formatPercent, formatUSD } from "@/utils/intl"; +import { formatNumberWithCommas, formatNumberWithoutCommas } from "@/utils/number"; +import { cn } from "@/utils/ui"; + +import AssetSelect from "./AssetSelect"; +import ChainSelect from "./ChainSelect"; +import { SimpleTooltip } from "./SimpleTooltip"; +import { SpinnerIcon } from "./SpinnerIcon"; + +interface Props { + amount: string; + amountUSD?: string; + diffPercentage?: number; + onAmountChange?: (amount: string) => void; + onAmountMax?: MouseEventHandler; + asset?: Asset; + onAssetChange?: (asset: Asset) => void; + chain?: Chain; + onChainChange?: (chain: Chain) => void; + chains: Chain[]; + context: "source" | "destination"; + isError?: string | boolean; + isLoading?: boolean; +} + +function AssetInput({ + amount, + amountUSD, + diffPercentage = 0, + onAmountChange, + onAmountMax, + asset, + onAssetChange, + chain, + chains, + onChainChange, + context, + isError, + isLoading, +}: Props) { + const { assetsByChainID, getNativeAssets } = useAssets(); + + const assets = useMemo(() => { + if (!chain) return getNativeAssets(); + return assetsByChainID(chain.chainID); + }, [assetsByChainID, chain, getNativeAssets]); + + const account = useAccount(chain?.chainID); + + const isAnyDisclosureOpen = useAnyDisclosureOpen(); + + const { data: balances, isLoading: isBalancesLoading } = useBalancesByChain({ + address: account?.address, + chain, + assets, + enabled: !isAnyDisclosureOpen && context === "source", + }); + + const selectedAssetBalance = useMemo(() => { + if (!asset || !balances) return "0"; + return formatUnits(BigInt(balances[asset.denom] ?? "0"), asset.decimals ?? 6); + }, [asset, balances]); + + const maxButtonDisabled = useMemo(() => { + return parseFloat(selectedAssetBalance) <= 0; + }, [selectedAssetBalance]); + + return ( +
+
+
+ +
+
+ +
+
+
+ {isLoading && } + {amount && !isLoading && ( + + )} + { + if (!onAmountChange) return; + + let latest = e.target.value; + + if (latest.match(/^[.,]/)) latest = `0.${latest}`; // Handle first character being a period or comma + latest = latest.replace(/^[0]{2,}/, "0"); // Remove leading zeros + latest = latest.replace(/[^\d.,]/g, ""); // Remove non-numeric and non-decimal characters + latest = latest.replace(/[.]{2,}/g, "."); // Remove multiple decimals + latest = latest.replace(/[,]{2,}/g, ","); // Remove multiple commas + + onAmountChange?.(formatNumberWithoutCommas(latest)); + }} + onKeyDown={(event) => { + if (!onAmountChange) return; + + if (event.key === "Escape") { + if (event.currentTarget.selectionStart === event.currentTarget.selectionEnd) { + event.currentTarget.select(); + } + return; + } + + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + let value = new BigNumber(formatNumberWithoutCommas(event.currentTarget.value) || "0"); + if (event.key === "ArrowUp") { + event.preventDefault(); + if (event.shiftKey) { + value = value.plus(10); + } else if (event.altKey || event.ctrlKey || event.metaKey) { + value = value.plus(0.1); + } else { + value = value.plus(1); + } + } + if (event.key === "ArrowDown") { + event.preventDefault(); + if (event.shiftKey) { + value = value.minus(10); + } else if (event.altKey || event.ctrlKey || event.metaKey) { + value = value.minus(0.1); + } else { + value = value.minus(1); + } + } + if (value.isNegative()) { + value = new BigNumber(0); + } + onAmountChange(value.toString()); + } + }} + /> +
+

+ {amountUSD && Number(amountUSD) > 0 ? formatUSD(amountUSD) : null} +

+ {amountUSD !== undefined && Number(amountUSD) > 0 && diffPercentage !== 0 && context === "destination" ? ( +

= 0 ? "text-green-500" : "text-red-500")}> + ({formatPercent(diffPercentage)}) +

+ ) : null} +
+ {context === "source" && account?.address && asset && ( +
+ Balance:{" "} + {isBalancesLoading ? ( + + ) : ( + +
+ {parseFloat(selectedAssetBalance).toLocaleString("en-US", { + maximumFractionDigits: 4, + })} +
+
+ )} + +
+ )} +
+
+ {typeof isError === "string" && ( +
+ {isError} +
+ )} +
+ ); +} + +export default AssetInput; diff --git a/src/components/AssetSelect/AssetSelectContent.tsx b/src/components/AssetSelect/AssetSelectContent.tsx new file mode 100644 index 00000000..0de0f266 --- /dev/null +++ b/src/components/AssetSelect/AssetSelectContent.tsx @@ -0,0 +1,139 @@ +import { ArrowLeftIcon } from "@heroicons/react/20/solid"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { Asset } from "@skip-router/core"; +import { matchSorter } from "match-sorter"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { formatUnits } from "viem"; + +import { cn } from "@/utils/ui"; + +import { SpinnerIcon } from "../SpinnerIcon"; + +interface Props { + assets?: Asset[]; + balances: Record; + onChange?: (asset: Asset) => void; + onClose: () => void; + showChainInfo?: boolean; + isBalancesLoading?: boolean; +} + +function AssetSelectContent({ assets = [], balances, onChange, onClose, showChainInfo, isBalancesLoading }: Props) { + const inputRef = useRef(null); + + useEffect(() => inputRef.current?.focus(), []); + + const [searchValue, setSearchValue] = useState(""); + + const sortedAssets = useMemo(() => { + return assets + ?.sort((a, b) => { + if (!a.recommendedSymbol) return 1; + if (!b.recommendedSymbol) return -1; + if (a.recommendedSymbol > b.recommendedSymbol) return 1; + if (a.recommendedSymbol < b.recommendedSymbol) return -1; + return 0; + }) + .filter((asset) => { + if (asset.originChainID === "sifchain-1" && asset.originDenom !== "rowan") { + return false; + } + return true; + }) + .sort((a, b) => { + const balanceA = BigInt(balances[a.denom] || "0"); + const balanceB = BigInt(balances[b.denom] || "0"); + if (balanceA > balanceB) return -1; + if (balanceA < balanceB) return 1; + return 0; + }); + }, [assets, balances]); + + const filteredAssets = useMemo(() => { + if (!searchValue) return sortedAssets; + return matchSorter(sortedAssets || [], searchValue, { + keys: ["recommendedSymbol", "symbol", "denom"], + }); + }, [searchValue, sortedAssets]); + + return ( +
+
+ +

Select Token

+ {isBalancesLoading && } +
+ setSearchValue(e.target.value)} + value={searchValue} + ref={inputRef} + /> + + + {filteredAssets.map((asset) => ( + + ))} + + + + + + +
+ ); +} + +export default AssetSelectContent; diff --git a/src/components/AssetSelect/index.tsx b/src/components/AssetSelect/index.tsx new file mode 100644 index 00000000..c34ed24e --- /dev/null +++ b/src/components/AssetSelect/index.tsx @@ -0,0 +1,70 @@ +import { ChevronDownIcon } from "@heroicons/react/20/solid"; +import { Asset } from "@skip-router/core"; +import { useState } from "react"; + +import { Dialog, DialogContent, DialogTrigger } from "@/components/Dialog"; +import { cn } from "@/utils/ui"; + +import AssetSelectContent from "./AssetSelectContent"; + +interface Props { + asset?: Asset; + assets?: Asset[]; + balances?: Record; + onChange?: (asset: Asset) => void; + showChainInfo?: boolean; + isBalancesLoading?: boolean; +} + +function AssetSelect({ asset, assets, balances, onChange, showChainInfo, isBalancesLoading }: Props) { + const [isOpen, setIsOpen] = useState(false); + return ( + + + + + + setIsOpen(false)} + showChainInfo={showChainInfo} + isBalancesLoading={isBalancesLoading} + /> + + + ); +} + +export default AssetSelect; diff --git a/src/components/AssetValue.tsx b/src/components/AssetValue.tsx new file mode 100644 index 00000000..c5ea9551 --- /dev/null +++ b/src/components/AssetValue.tsx @@ -0,0 +1,32 @@ +import { useMemo } from "react"; +import { formatUnits } from "viem"; + +import { useAssets } from "@/context/assets"; +import { raise } from "@/utils/assert"; + +interface Props { + chainId: string; + denom: string; + value: string; +} + +export function AssetValue({ chainId, denom, value }: Props) { + const { getAsset } = useAssets(); + + const { decimals = 6, recommendedSymbol } = useMemo(() => { + return getAsset(denom, chainId) || raise(`AssetValue error: no asset found for '${denom}' on '${chainId}'`); + }, [chainId, denom, getAsset]); + + const formattedValue = useMemo(() => { + const v = formatUnits(BigInt(value), decimals); + return parseFloat(v).toLocaleString("en-US", { + maximumFractionDigits: 2, + }); + }, [decimals, value]); + + return ( + + {formattedValue} {recommendedSymbol} + + ); +} diff --git a/src/components/BuildInfo.tsx b/src/components/BuildInfo.tsx new file mode 100644 index 00000000..e425f0f7 --- /dev/null +++ b/src/components/BuildInfo.tsx @@ -0,0 +1,75 @@ +import { ReactNode, useEffect, useState } from "react"; +import { tinykeys } from "tinykeys"; + +import { API_URL } from "@/constants/api"; +import { cn } from "@/utils/ui"; + +const githubUrl = "https://github.com/skip-mev/ibc-dot-fun"; + +const buildInfo: [string, ReactNode][] = [ + ["node env", process.env.NODE_ENV], + ["api url", API_URL], + [ + "commit", + process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA + ? `${githubUrl}/commit/${process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA}` + : null, + ], + [ + "pull request", + process.env.NEXT_PUBLIC_GIT_PULL_REQUEST_ID + ? `${githubUrl}/pull/${process.env.NEXT_PUBLIC_GIT_PULL_REQUEST_ID}` + : null, + ], +]; + +export const BuildInfo = () => { + const [show, setShow] = useState(() => process.env.NEXT_PUBLIC_VERCEL_ENV !== "production"); + + useEffect(() => { + return tinykeys(window, { + "Shift+Escape": () => setShow((prev) => !prev), + }); + }, []); + + if (!show) return null; + + return ( +
+
+ {buildInfo.map( + ([k, v], i) => + v && ( +
+
{k}
+
+ {typeof v === "string" && /^https?:\/\//.test(v) ? ( + + {v.replace(/^https?:\/\//, "")} + + ) : ( + v + )} +
+
+ ), + )} +
+
+ +
+ ); +}; diff --git a/src/components/ChainSelect/ChainSelectContent.tsx b/src/components/ChainSelect/ChainSelectContent.tsx new file mode 100644 index 00000000..c6852fe6 --- /dev/null +++ b/src/components/ChainSelect/ChainSelectContent.tsx @@ -0,0 +1,117 @@ +import { ArrowLeftIcon } from "@heroicons/react/20/solid"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { matchSorter } from "match-sorter"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import { Chain } from "@/hooks/useChains"; +import { cn } from "@/utils/ui"; + +interface Props { + chains: Chain[]; + onChange: (chain: Chain) => void; + onClose: () => void; +} + +function ChainSelectContent({ chains, onChange, onClose }: Props) { + const inputRef = useRef(null); + + useEffect(() => inputRef.current?.focus(), []); + + const [searchValue, setSearchValue] = useState(""); + + const filteredChains = useMemo(() => { + if (!searchValue) return chains; + return matchSorter(chains, searchValue, { + keys: ["chainID", "chainName", "prettyName"], + }); + }, [chains, searchValue]); + + return ( +
+
+ +

Select Network

+
+ setSearchValue(e.target.value)} + value={searchValue} + ref={inputRef} + /> + {chains.length < 1 ? ( +
+ + + + +
+ ) : ( + + + {filteredChains.map((chain) => ( + + ))} + + + + + + + )} +
+ ); +} + +export default ChainSelectContent; diff --git a/src/components/ChainSelect/ChainSelectTrigger.tsx b/src/components/ChainSelect/ChainSelectTrigger.tsx new file mode 100644 index 00000000..162fcf46 --- /dev/null +++ b/src/components/ChainSelect/ChainSelectTrigger.tsx @@ -0,0 +1,32 @@ +import { ChevronDownIcon } from "@heroicons/react/20/solid"; +import { forwardRef } from "react"; + +import { Chain } from "@/hooks/useChains"; +import { cn } from "@/utils/ui"; + +interface Props { + chain?: Chain; +} + +const ChainSelectTrigger = forwardRef( + function ChainSelectTrigger({ chain, ...props }, ref) { + return ( + + ); + }, + // +); + +export default ChainSelectTrigger; diff --git a/src/components/ChainSelect/index.tsx b/src/components/ChainSelect/index.tsx new file mode 100644 index 00000000..f90ada4c --- /dev/null +++ b/src/components/ChainSelect/index.tsx @@ -0,0 +1,41 @@ +import { FC, Fragment, useState } from "react"; + +import { Dialog, DialogContent, DialogTrigger } from "@/components/Dialog"; +import { Chain } from "@/hooks/useChains"; + +import ChainSelectContent from "./ChainSelectContent"; +import ChainSelectTrigger from "./ChainSelectTrigger"; + +interface Props { + chain?: Chain; + chains: Chain[]; + onChange?: (chain: Chain) => void; +} + +const ChainSelect: FC = ({ chain, chains, onChange = () => {} }) => { + const [isOpen, setIsOpen] = useState(false); + return ( + + + + + + + { + onChange(_chain); + setIsOpen(false); + }} + onClose={() => setIsOpen(false)} + /> + + + + ); +}; + +export default ChainSelect; diff --git a/src/components/ChainSymbol.tsx b/src/components/ChainSymbol.tsx new file mode 100644 index 00000000..c11ffefe --- /dev/null +++ b/src/components/ChainSymbol.tsx @@ -0,0 +1,32 @@ +import { CubeIcon } from "@heroicons/react/20/solid"; +import { useMemo } from "react"; + +import { useChainByID } from "@/hooks/useChains"; + +interface Props { + chainId: string; +} + +export const ChainSymbol = ({ chainId }: Props) => { + const { data: chain } = useChainByID(chainId); + + const src = useMemo(() => { + if (!chain) return; + return chain.logoURI; + }, [chain]); + + const alt = chain?.prettyName || chain?.chainName || "UNKNOWN"; + + const Icon = src ? "img" : CubeIcon; + const iconProps = src ? { src, alt } : {}; + + return ( +
+ + {alt} +
+ ); +}; diff --git a/src/components/ClientOnly.tsx b/src/components/ClientOnly.tsx new file mode 100644 index 00000000..b9123806 --- /dev/null +++ b/src/components/ClientOnly.tsx @@ -0,0 +1,7 @@ +import { ReactNode, useEffect, useState } from "react"; + +export function ClientOnly({ children }: { children: ReactNode }) { + const [state, setState] = useState(false); + useEffect(() => setState(true), []); + return state ? <>{children} : null; +} diff --git a/src/components/ConnectWalletButtonSmall.tsx b/src/components/ConnectWalletButtonSmall.tsx new file mode 100644 index 00000000..6a58ff0c --- /dev/null +++ b/src/components/ConnectWalletButtonSmall.tsx @@ -0,0 +1,20 @@ +import { PlusIcon } from "@heroicons/react/20/solid"; +import { ComponentProps } from "react"; + +import { cn } from "@/utils/ui"; + +export function ConnectWalletButtonSmall({ className, ...props }: ComponentProps<"button">) { + return ( + + ); +} diff --git a/src/components/ConnectedWalletButton.tsx b/src/components/ConnectedWalletButton.tsx new file mode 100644 index 00000000..2c82f0ee --- /dev/null +++ b/src/components/ConnectedWalletButton.tsx @@ -0,0 +1,40 @@ +import { ComponentProps, forwardRef } from "react"; + +import { cn } from "@/utils/ui"; + +type Props = ComponentProps<"button"> & { + address: string; + walletName: string; + walletLogo?: string; +}; + +export const ConnectedWalletButton = forwardRef( + function Component(props, ref) { + const { address, walletLogo, walletName, className, ...rest } = props; + return ( + + ); + }, + // +); diff --git a/src/components/ConversionRate.tsx b/src/components/ConversionRate.tsx new file mode 100644 index 00000000..4c2c348b --- /dev/null +++ b/src/components/ConversionRate.tsx @@ -0,0 +1,59 @@ +import { Asset } from "@skip-router/core"; +import { ReactNode, useCallback, useMemo, useState } from "react"; + +export interface Props { + srcAsset: Asset; + destAsset: Asset; + amountIn: string; + amountOut: string; + defaultDirection?: ConvDirection; + + children: (args: RenderArgs) => ReactNode; +} + +interface RenderArgs { + left: Asset; + right: Asset; + conversion: number; + toggle: () => void; +} + +export const ConversionRate = ({ + srcAsset: src, + destAsset: dest, + amountIn, + amountOut, + defaultDirection = ConvDirection.DEST_SRC, + children, +}: Props) => { + const [direction, setState] = useState(() => defaultDirection); + + const toggle = useCallback(() => { + setState((prev) => { + return prev === ConvDirection.DEST_SRC ? ConvDirection.SRC_DEST : ConvDirection.DEST_SRC; + }); + }, []); + + const left = direction === ConvDirection.DEST_SRC ? dest : src; + const right = direction === ConvDirection.DEST_SRC ? src : dest; + + const conversion = useMemo(() => { + if (direction === ConvDirection.DEST_SRC) { + return +amountIn / +amountOut; + } else { + return +amountOut / +amountIn; + } + }, [amountIn, amountOut, direction]); + + const renderArgs: RenderArgs = useMemo( + () => ({ left, right, conversion, toggle }), + [conversion, left, right, toggle], + ); + + return children(renderArgs); +}; + +export enum ConvDirection { + DEST_SRC = "dest-src", + SRC_DEST = "src-dest", +} diff --git a/src/components/Dialog/Dialog.tsx b/src/components/Dialog/Dialog.tsx new file mode 100644 index 00000000..af10b587 --- /dev/null +++ b/src/components/Dialog/Dialog.tsx @@ -0,0 +1,25 @@ +import * as RadixDialog from "@radix-ui/react-dialog"; +import { PropsWithChildren, useRef } from "react"; + +import { DialogContext } from "./context"; + +interface Props extends PropsWithChildren { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function Dialog({ children, open, onOpenChange }: Props) { + const ref = useRef(null); + + return ( + + + {children} +
+ + + ); +} diff --git a/src/components/Dialog/DialogContent.tsx b/src/components/Dialog/DialogContent.tsx new file mode 100644 index 00000000..68e4c209 --- /dev/null +++ b/src/components/Dialog/DialogContent.tsx @@ -0,0 +1,26 @@ +import * as Dialog from "@radix-ui/react-dialog"; +import { DialogContentProps } from "@radix-ui/react-dialog"; +import { PropsWithChildren, useContext } from "react"; + +import { DialogContext } from "./context"; + +interface Props extends PropsWithChildren { + onInteractOutside?: DialogContentProps["onInteractOutside"]; +} + +export function DialogContent({ children, onInteractOutside }: Props) { + const { open, container } = useContext(DialogContext); + + if (!open) return null; + + return ( + + + {children} + + + ); +} diff --git a/src/components/Dialog/DialogTrigger.tsx b/src/components/Dialog/DialogTrigger.tsx new file mode 100644 index 00000000..c6ad2486 --- /dev/null +++ b/src/components/Dialog/DialogTrigger.tsx @@ -0,0 +1,19 @@ +import * as RadixDialog from "@radix-ui/react-dialog"; +import { ForwardedRef, forwardRef, PropsWithChildren } from "react"; + +interface Props extends PropsWithChildren {} + +export const DialogTrigger = forwardRef(function DialogTrigger( + { children, ...props }: Props, + ref: ForwardedRef, +) { + return ( + + {children} + + ); +}); diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts new file mode 100644 index 00000000..9d766a5f --- /dev/null +++ b/src/components/Dialog/context.ts @@ -0,0 +1,13 @@ +import { createContext, RefObject } from "react"; + +export interface DialogContext { + open: boolean; + onOpenChange: (open: boolean) => void; + container: RefObject; +} + +export const DialogContext = createContext({ + open: false, + onOpenChange: () => {}, + container: { current: null }, +}); diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx new file mode 100644 index 00000000..06a856fd --- /dev/null +++ b/src/components/Dialog/index.tsx @@ -0,0 +1,3 @@ +export * from "./Dialog"; +export * from "./DialogContent"; +export * from "./DialogTrigger"; diff --git a/src/components/EmbedButton.tsx b/src/components/EmbedButton.tsx new file mode 100644 index 00000000..3e8c6e19 --- /dev/null +++ b/src/components/EmbedButton.tsx @@ -0,0 +1,22 @@ +import { CodeBracketIcon } from "@heroicons/react/20/solid"; + +import { disclosure } from "@/context/disclosures"; +import { cn } from "@/utils/ui"; + +import { SimpleTooltip } from "./SimpleTooltip"; + +export const EmbedButton = () => { + return ( + + + + ); +}; diff --git a/src/components/EmbedDialog/index.tsx b/src/components/EmbedDialog/index.tsx new file mode 100644 index 00000000..5e497c4a --- /dev/null +++ b/src/components/EmbedDialog/index.tsx @@ -0,0 +1,85 @@ +import { ArrowLeftIcon } from "@heroicons/react/20/solid"; +import * as Dialog from "@radix-ui/react-dialog"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import toast from "react-hot-toast"; + +import { useDisclosureKey } from "@/context/disclosures"; +import { cn } from "@/utils/ui"; + +export const EmbedDialog = ({ embedLink }: { embedLink: string }) => { + const [isOpen, { close }] = useDisclosureKey("embedDialog"); + + const embedCode = `