From 88d44aff50cd1f870071264931e3128a9a0ef468 Mon Sep 17 00:00:00 2001 From: MananTank Date: Thu, 24 Jul 2025 18:17:47 +0000 Subject: [PATCH] Dashboard: Migrate Batch Upload NFTs from chakra to tailwind, UI improvements (#7689) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ## PR-Codex overview This PR focuses on refactoring and enhancing the `FilePreview` component, improving the upload process for NFTs, and updating various components to utilize the new structure. Several files related to example CSVs were deleted, and new features were added for better file handling. ### Detailed summary - Deleted example CSV files. - Added `FilePreview` import in multiple components. - Enhanced `FilePreview` styling and functionality. - Updated `DownloadFileButton` to accept additional props. - Refactored `BatchTable` to improve pagination and display logic. - Improved `UploadStep` for better user experience during file uploads. - Introduced `DelayedRevealConfiguration` and `SelectReveal` components for managing NFT reveal types. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` ## Summary by CodeRabbit * **Refactor** * Replaced Chakra UI and third-party libraries with a custom UI system across batch upload components for a more unified look and feel. * Simplified and restyled batch minting forms, tables, upload steps, and reveal configuration. * Updated drag-and-drop functionality to use a dedicated DropZone component. * Streamlined table rendering and pagination with manual state management and local UI components. * Improved layout, styling, and accessibility for option selection, shuffle toggle, and modal dialogs. * Enhanced media preview styling and standardized file preview imports for improved consistency. * Added tabbed format selection and enriched instructional content with downloadable CSV/JSON examples in the upload step. * Improved submit button placement and added help links for user guidance during batch minting. * **New Features** * Added customizable button variants and styling options to the file download button for better UI flexibility. --- .../assets/examples/example-with-ipfs.csv | 2 - .../assets/examples/example-with-maps.csv | 4 - .../public/assets/examples/example.csv | 2 - .../batch-upload/batch-lazy-mint.tsx | 788 +++++++++--------- .../@/components/batch-upload/batch-table.tsx | 366 ++++---- .../lazy-mint-form/select-option.tsx | 14 +- .../@/components/batch-upload/upload-step.tsx | 444 +++++++--- .../src/@/components/blocks/FileInput.tsx | 2 +- .../components/blocks}/file-preview.tsx | 12 +- .../components/batch-lazy-mint-button.tsx | 100 +-- .../create/_common/download-file-button.tsx | 9 +- .../tokens/create/nft/launch/launch-nft.tsx | 2 +- .../batch-upload/batch-upload-nfts.tsx | 2 +- .../create/token/launch/launch-token.tsx | 2 +- 14 files changed, 969 insertions(+), 780 deletions(-) delete mode 100644 apps/dashboard/public/assets/examples/example-with-ipfs.csv delete mode 100644 apps/dashboard/public/assets/examples/example-with-maps.csv delete mode 100644 apps/dashboard/public/assets/examples/example.csv rename apps/dashboard/src/{app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common => @/components/blocks}/file-preview.tsx (81%) diff --git a/apps/dashboard/public/assets/examples/example-with-ipfs.csv b/apps/dashboard/public/assets/examples/example-with-ipfs.csv deleted file mode 100644 index d46ea39a909..00000000000 --- a/apps/dashboard/public/assets/examples/example-with-ipfs.csv +++ /dev/null @@ -1,2 +0,0 @@ -name,description,image,animation_url,external_url,background_color,youtube_url,additional,properties,can be,added -Token 0 Name,Token 0 Description,ipfs://ipfsHash/0,ipfs://ipfsHash/0,https://thirdweb.com,#0098EE,,every,row,is a ,property diff --git a/apps/dashboard/public/assets/examples/example-with-maps.csv b/apps/dashboard/public/assets/examples/example-with-maps.csv deleted file mode 100644 index fd1879f1422..00000000000 --- a/apps/dashboard/public/assets/examples/example-with-maps.csv +++ /dev/null @@ -1,4 +0,0 @@ -name,description,image,animation_url,external_url,background_color,youtube_url,additional,properties,can be,added -Token 0 Name,Token 0 Description,0.png,0.mp4,https://thirdweb.com,#0098EE,,every,row,is a ,property -Token 1 Name,Token 1 Description,0.png,0.mp4,https://thirdweb.com,#0098EE,,every,row,is a ,property -Token 2 Name,Token 2 Description,0.png,0.mp4,https://thirdweb.com,#0098EE,,every,row,is a ,property diff --git a/apps/dashboard/public/assets/examples/example.csv b/apps/dashboard/public/assets/examples/example.csv deleted file mode 100644 index 60232fb7f37..00000000000 --- a/apps/dashboard/public/assets/examples/example.csv +++ /dev/null @@ -1,2 +0,0 @@ -name,description,external_url,background_color,youtube_url,additional,properties,can be,added -Token 0 Name,Token 0 Description,https://thirdweb.com,#0098EE,,every,row,is a ,property \ No newline at end of file diff --git a/apps/dashboard/src/@/components/batch-upload/batch-lazy-mint.tsx b/apps/dashboard/src/@/components/batch-upload/batch-lazy-mint.tsx index 73a6d1aad41..d9b184501db 100644 --- a/apps/dashboard/src/@/components/batch-upload/batch-lazy-mint.tsx +++ b/apps/dashboard/src/@/components/batch-upload/batch-lazy-mint.tsx @@ -1,23 +1,18 @@ "use client"; -import { - Alert, - AlertIcon, - Flex, - FormControl, - Input, - InputGroup, - InputRightElement, - Textarea, -} from "@chakra-ui/react"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Button } from "chakra/button"; -import { FormErrorMessage, FormHelperText, FormLabel } from "chakra/form"; -import { Heading } from "chakra/heading"; -import { Text } from "chakra/text"; -import { ChevronLeftIcon, EyeIcon, EyeOffIcon } from "lucide-react"; -import { useRef, useState } from "react"; -import { useDropzone } from "react-dropzone"; +import { + ArrowLeftIcon, + ArrowRightIcon, + EyeIcon, + EyeOffIcon, + HelpCircleIcon, + LockKeyholeIcon, + RefreshCcwIcon, + ShuffleIcon, +} from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; import { useForm } from "react-hook-form"; import type { ThirdwebClient } from "thirdweb"; import type { CreateDelayedRevealBatchParams } from "thirdweb/extensions/erc721"; @@ -25,12 +20,14 @@ import type { NFTInput } from "thirdweb/utils"; import { z } from "zod"; import { FileInput } from "@/components/blocks/FileInput"; import { TransactionButton } from "@/components/tx-button"; -import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox"; -import { UnderlineLink } from "@/components/ui/UnderlineLink"; -import type { ComponentWithChildren } from "@/types/component-with-children"; +import { Button } from "@/components/ui/button"; +import { Form, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; import { processInputData, shuffleData } from "@/utils/batch"; +import { TabButtons } from "../ui/tabs"; import { BatchTable } from "./batch-table"; -import { SelectOption } from "./lazy-mint-form/select-option"; import { UploadStep } from "./upload-step"; type DelayedSubmit = { @@ -44,16 +41,6 @@ type InstantSubmit = { type SubmitType = DelayedSubmit | InstantSubmit; -interface BatchLazyMintEVMProps { - nextTokenIdToMint: bigint; - canCreateDelayedRevealBatch: boolean; - onSubmit: (formData: SubmitType) => Promise; - chainId: number; - client: ThirdwebClient; -} - -type BatchLazyMintProps = BatchLazyMintEVMProps; - const BatchLazyMintFormSchema = z .object({ confirmPassword: z @@ -73,7 +60,7 @@ const BatchLazyMintFormSchema = z name: z.string().min(1, "A name is required"), }) .optional(), - revealType: z.literal("instant").or(z.literal("delayed")).optional(), + revealType: z.literal("instant").or(z.literal("delayed")), // shared logic shuffle: z.boolean().default(false), @@ -91,18 +78,21 @@ function useBatchLazyMintForm() { return useForm({ defaultValues: { metadatas: [], - revealType: undefined, + revealType: "instant", shuffle: false, }, resolver: zodResolver(BatchLazyMintFormSchema), }); } -export const BatchLazyMint: ComponentWithChildren< - BatchLazyMintProps & { - isLoggedIn: boolean; - } -> = (props) => { +export function BatchLazyMint(props: { + nextTokenIdToMint: bigint; + canCreateDelayedRevealBatch: boolean; + onSubmit: (formData: SubmitType) => Promise; + chainId: number; + client: ThirdwebClient; + isLoggedIn: boolean; +}) { const [step, setStep] = useState(0); const form = useBatchLazyMintForm(); @@ -110,369 +100,415 @@ export const BatchLazyMint: ComponentWithChildren< const nftMetadatas = form.watch("metadatas"); const hasError = !!form.getFieldState("metadatas", form.formState).error; - const { getRootProps, getInputProps, isDragActive } = useDropzone({ - onDrop: async (acceptedFiles) => { - try { - await processInputData(acceptedFiles, (data) => - form.setValue("metadatas", data), - ); - } catch { - form.setError("metadatas", { - message: "Invalid metadata files", - type: "validate", - }); - } - - if (nftMetadatas.length === 0) { - form.setError("metadatas", { - message: "Invalid metadata files", - type: "validate", - }); - } - }, - }); - - const paginationPortalRef = useRef(null); - return ( -
{ - // first shuffle - const shuffledMetadatas = data.shuffle - ? shuffleData(data.metadatas) - : data.metadatas; - - // check submit is instant - if (data.revealType === "instant") { - return props.onSubmit({ - data: { metadatas: shuffledMetadatas }, - revealType: "instant", - }); - } - // validate password - if (!data.password) { - form.setError("password", { - message: "A password is required for delayed reveal.", - type: "validate", - }); - return; - } - // validate placeholder - if (!data.placeHolder?.name) { - form.setError("placeHolder.name", { - message: "A name is required for delayed reveal.", - type: "validate", - }); - } - // submit - return props.onSubmit({ - data: { - metadata: shuffledMetadatas, - password: data.password, - placeholderMetadata: { - description: data.placeHolder?.description, - image: data.placeHolder?.image, - name: data.placeHolder?.name, + + { + // first shuffle + const shuffledMetadatas = data.shuffle + ? shuffleData(data.metadatas) + : data.metadatas; + + // check submit is instant + if (data.revealType === "instant") { + return props.onSubmit({ + data: { metadatas: shuffledMetadatas }, + revealType: "instant", + }); + } + // validate password + if (!data.password) { + form.setError("password", { + message: "A password is required for delayed reveal.", + type: "validate", + }); + return; + } + // validate placeholder + if (!data.placeHolder?.name) { + form.setError("placeHolder.name", { + message: "A name is required for delayed reveal.", + type: "validate", + }); + } + // submit + await props.onSubmit({ + data: { + metadata: shuffledMetadatas, + password: data.password, + placeholderMetadata: { + description: data.placeHolder?.description, + image: data.placeHolder?.image, + name: data.placeHolder?.name, + }, }, - }, - revealType: "delayed", - }); - })} - > - {step === 0 ? ( -
- {nftMetadatas.length > 0 ? ( - <> - -
-
-
-
- - -
+ revealType: "delayed", + }); + })} + > + {step === 0 ? ( +
+ {nftMetadatas.length > 0 ? ( +
+

+ Upload NFTs +

+ + +
+ +
- - ) : ( - - )} -
- ) : ( - <> - -
+ ) : ( + { + form.reset(); + }} + onDrop={async (acceptedFiles) => { + try { + await processInputData(acceptedFiles, (data) => + form.setValue("metadatas", data), + ); + } catch { + form.setError("metadatas", { + message: "Invalid metadata files", + type: "validate", + }); + } + + if (nftMetadatas.length === 0) { + form.setError("metadatas", { + message: "Invalid metadata files", + type: "validate", + }); + } + }} + /> + )} +
+ ) : ( +
+
- - When will you reveal your NFTs? -
- - - {form.watch("revealType") && ( - <> - - form.setValue("shuffle", !!val)} - /> -
-

Shuffle the order of the NFTs before uploading.

- This is an off-chain operation and is not provable. + +

+ Upload NFTs +

+ + + +
+ +
+ {form.watch("revealType") === "delayed" && ( + + )} + + {/* shuffle */} + form.setValue("shuffle", val)} + /> + +
+
+ + {form.formState.isSubmitting + ? `Uploading ${nftMetadatas.length} NFTs` + : `Upload ${nftMetadatas.length} NFTs`} + +
+ +
+ + + Experiencing issues uploading your files? +
- -
- - {form.formState.isSubmitting - ? `Uploading ${nftMetadatas.length} NFTs` - : `Upload ${nftMetadatas.length} NFTs`} - - {props.children}
- - - Experiencing issues uploading your files? - - - - )} - - )} - +
+
+ )} + + ); -}; +} -interface SelectRevealProps { +function ShuffleNFTsCard(props: { + isShuffleEnabled: boolean; + onShuffleChange: (val: boolean) => void; +}) { + return ( +
+
+
+ +
+
+ +

+ Shuffle NFTs +

+ +

+ Shuffle the order of the NFTs before uploading. This is an off-chain + operation and is not provable +

+ + props.onShuffleChange(!!val)} + className="absolute right-4 top-4 lg:right-6 lg:top-8" + /> +
+ ); +} + +function SelectReveal(props: { form: ReturnType; canCreateDelayedRevealBatch: boolean; client: ThirdwebClient; +}) { + const { form, canCreateDelayedRevealBatch } = props; + + return ( +
+ {canCreateDelayedRevealBatch && ( + { + form.setValue("revealType", "instant"); + // reset all fields related to delayed reveal + form.resetField("password"); + form.resetField("confirmPassword"); + form.resetField("placeHolder"); + }, + isActive: form.watch("revealType") === "instant", + }, + { + name: "Delayed Reveal", + onClick: () => form.setValue("revealType", "delayed"), + isActive: form.watch("revealType") === "delayed", + }, + ]} + /> + )} + + {form.watch("revealType") === "instant" && ( +
+

+ Reveal upon mint +

+

+ Collectors will immediately see the final NFT when they complete the + minting +

+
+ )} + + {form.watch("revealType") === "delayed" && ( +
+

+ Delayed Reveal +

+

+ Collectors will mint your placeholder image, then you reveal at a + later time +

+
+ )} +
+ ); } -const SelectReveal: React.FC = ({ - form, - client, - canCreateDelayedRevealBatch, -}) => { +function DelayedRevealConfiguration(props: { + form: ReturnType; + client: ThirdwebClient; +}) { + const { form, client } = props; const [show, setShow] = useState(false); - const imageUrl = form.watch("placeHolder.image"); return ( - - - form.setValue("revealType", "instant")} - /> - form.setValue("revealType", "delayed")} - /> - -
- {form.watch("revealType") === "delayed" && ( - <> - Let's set a password - - - You'll need this password to reveal your NFTs. Please save it - somewhere safe. - - - - - Password - - - - {show ? ( - setShow(!show)} - /> - ) : ( - setShow(!show)} - /> - )} - - - - - { - form.getFieldState("password", form.formState).error - ?.message - } - - - - Confirm password - - - { - form.getFieldState("confirmPassword", form.formState).error - ?.message - } - - - -
- Placeholder - + {/* password */} +
+
+
+ +
+
+

+ Set password +

+

+ You'll need this password to reveal your NFTs. Please save it + somewhere safe +

+ +
+ {/* select password */} + + Password +
+ + +
+ + {form.getFieldState("password", form.formState).error?.message} + +
+ + {/* confirm password */} + + Confirm password +
+ +
+ + { + form.getFieldState("confirmPassword", form.formState).error + ?.message + } + +
+
+
+ + {/* placeholder */} +
+
+
+ +
+
+

+ Set placeholder metadata +

+

+ The placeholder metadata will be displayed for all NFTs until you + reveal your NFTs +

+
+ + Image + form.setValue("placeHolder.image", file)} + value={imageUrl} + /> + + + { + form.getFieldState("placeHolder.image", form.formState).error + ?.message + } + + +
+ + Name + + + { + form.getFieldState("placeHolder.name", form.formState).error + ?.message } - isRequired - > - Name - - - { - form.getFieldState("placeHolder.name", form.formState).error - ?.message - } - - - + + + Description +