Skip to content

Commit 99d6b3b

Browse files
committed
Dashboard: Add NFT creation wizard in Asset page (#7315)
<!-- ## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes" If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000): ## Notes for the reviewer Anything important to call out? Be sure to also clarify these in your comments. ## How to test Unit tests, playground, etc. --> <!-- start pr-codex --> --- ## PR-Codex overview This PR focuses on improving the handling of file inputs and media uploads within the application. It introduces new functionalities for managing blob URLs, refines error handling, and enhances the user interface for uploading assets. ### Detailed summary - Added `fileToBlobUrl` function to convert files to blob URLs. - Enhanced `FileInput` components to accept a `client` prop for better integration. - Updated error handling in several components to provide more detailed feedback. - Refined layouts and styles for asset creation pages. - Introduced new schemas for validating NFT and social URL data. - Improved tracking functionality for NFT creation steps. - Added support for handling multiple file types in uploads. - Enhanced the user interface for attributes and social URLs input fields. > The following files were skipped due to too many changes: `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/upload-nfts.tsx`, `apps/dashboard/src/core-ui/batch-upload/batch-table.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/page.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/sales/sales-settings.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/collection-info/nft-collection-info-fieldset.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/_components/claim-conditions/snapshot-upload.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/token-info-fieldset.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-upload.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/modules/components/nft/NFTMediaFormGroup.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/create-nft-page-ui.tsx`, `apps/dashboard/src/components/shared/FileInput.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/shared-metadata-form.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/distribution/token-airdrop.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/lazy-mint-form.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/batch-upload/process-files.ts`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/mint-form.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/single-upload/single-upload-nft.tsx`, `apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/[tokenId]/components/update-metadata-form.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/batch-upload/batch-upload-instructions.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/launch/launch-token.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/create-nft-page.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/upload-nfts/batch-upload/batch-upload-nfts.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/nft/launch/launch-nft.tsx` > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added logic to always display the selected token in token selectors. - Introduced a styled drag-and-drop file upload area with error handling and reset options. - Added reusable components: file preview for files and URLs, step cards with navigation and tracking, social URLs fieldset, downloadable file button, batch upload instructions, and NFT attribute management. - Launched a multi-step NFT creation workflow covering collection info, asset upload (single and batch), sales settings, and launch steps. - Added schema validation for NFT metadata including pricing, social URLs, and blockchain addresses. - Enabled batch uploading and inline editing of NFTs with price, currency, and supply management. - Added advanced sales and royalty settings with real-time validation and analytics tracking. - **Enhancements** - Improved error reporting in toast notifications with detailed parsed messages. - Centralized and streamlined media file handling by removing redundant hooks and consolidating upload logic. - Enhanced navigation and event tracking across multi-step asset creation flows. - Updated UI components to accept client context for consistent file handling. - Refined form validation by reusing common schemas. - **Bug Fixes** - Corrected NFT supply column label to "Circulating Supply". - Improved loading placeholder text in token selectors. - **Refactor** - Consolidated CSV upload logic and replaced custom drag-and-drop UIs with a unified DropZone component. - Replaced multiple media preview components with a single FilePreview component. - Updated prop types and component interfaces for better consistency and maintainability. - Removed deprecated hooks and unused imports to reduce technical debt. - Reorganized and standardized import paths across modules. - Simplified step execution logic in launch flows and enhanced retry capabilities. - **Documentation** - Added Storybook stories for DropZone, multi-step status, and NFT upload components to support visual testing and documentation. - **Chores** - Exported utility functions and constants for broader use. - Improved styling flexibility by enhancing component className handling. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 81c66f4 commit 99d6b3b

File tree

94 files changed

+5066
-1362
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

94 files changed

+5066
-1362
lines changed

.changeset/better-owls-flash.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"thirdweb": patch
3+
---
4+
5+
- Add support for blob urls in `MediaRenderer` component
6+
- Fix `className` prop not set in loading state of `MediaRenderer` component

apps/dashboard/src/@/components/blocks/Img.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ type imgElementProps = React.DetailedHTMLProps<
1111
skeleton?: React.ReactNode;
1212
fallback?: React.ReactNode;
1313
src: string | undefined;
14+
containerClassName?: string;
1415
};
1516

1617
export function Img(props: imgElementProps) {
@@ -23,7 +24,8 @@ export function Img(props: imgElementProps) {
2324
: props.src === ""
2425
? "fallback"
2526
: _status;
26-
const { className, fallback, skeleton, ...restProps } = props;
27+
const { className, fallback, skeleton, containerClassName, ...restProps } =
28+
props;
2729
const defaultSkeleton = <div className="animate-pulse bg-accent" />;
2830
const defaultFallback = <div className="bg-accent" />;
2931
const imgRef = useRef<HTMLImageElement>(null);
@@ -47,7 +49,7 @@ export function Img(props: imgElementProps) {
4749
}, []);
4850

4951
return (
50-
<div className="relative shrink-0">
52+
<div className={cn("relative shrink-0", containerClassName)}>
5153
<img
5254
{...restProps}
5355
// avoid setting empty src string to prevent request to the entire page

apps/dashboard/src/@/components/blocks/TokenSelector.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,17 @@ export function TokenSelector(props: {
159159
? `${props.selectedToken.chainId}:${props.selectedToken.address}`
160160
: undefined;
161161

162+
// if selected value is not in options, add it
163+
if (
164+
selectedValue &&
165+
!options.find((option) => option.value === selectedValue)
166+
) {
167+
options.push({
168+
label: props.selectedToken?.address || "Unknown",
169+
value: selectedValue,
170+
});
171+
}
172+
162173
return (
163174
<SelectWithSearch
164175
searchPlaceholder="Search by name or symbol"
@@ -175,7 +186,7 @@ export function TokenSelector(props: {
175186
showCheck={props.showCheck}
176187
placeholder={
177188
tokensQuery.isPending
178-
? "Loading Tokens..."
189+
? "Loading Tokens"
179190
: props.placeholder || "Select Token"
180191
}
181192
overrideSearchFn={searchFn}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { DropZone } from "./drop-zone";
3+
4+
const meta = {
5+
title: "blocks/DropZone",
6+
component: DropZone,
7+
decorators: [
8+
(Story) => (
9+
<div className="container max-w-6xl py-10">
10+
<Story />
11+
</div>
12+
),
13+
],
14+
} satisfies Meta<typeof DropZone>;
15+
16+
export default meta;
17+
type Story = StoryObj<typeof meta>;
18+
19+
export const Default: Story = {
20+
args: {
21+
isError: false,
22+
onDrop: () => {},
23+
title: "This is a title",
24+
description: "This is a description for drop zone",
25+
accept: undefined,
26+
resetButton: undefined,
27+
},
28+
};
29+
30+
export const ErrorState: Story = {
31+
args: {
32+
isError: true,
33+
onDrop: () => {},
34+
title: "this is title",
35+
description: "This is a description",
36+
accept: undefined,
37+
resetButton: undefined,
38+
},
39+
};
40+
41+
export const ErrorStateWithResetButton: Story = {
42+
args: {
43+
isError: true,
44+
onDrop: () => {},
45+
title: "this is title",
46+
description: "This is a description",
47+
accept: undefined,
48+
resetButton: {
49+
label: "Remove Files",
50+
onClick: () => {},
51+
},
52+
},
53+
};
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { Button } from "@/components/ui/button";
2+
import { cn } from "@/lib/utils";
3+
import { UploadIcon, XIcon } from "lucide-react";
4+
import { useDropzone } from "react-dropzone";
5+
6+
export function DropZone(props: {
7+
isError: boolean;
8+
onDrop: (acceptedFiles: File[]) => void;
9+
title: string;
10+
description: string;
11+
resetButton:
12+
| {
13+
label: string;
14+
onClick: () => void;
15+
}
16+
| undefined;
17+
className?: string;
18+
accept: string | undefined;
19+
}) {
20+
const { getRootProps, getInputProps } = useDropzone({
21+
onDrop: props.onDrop,
22+
});
23+
24+
const { resetButton } = props;
25+
26+
return (
27+
<div
28+
className={cn(
29+
"flex cursor-pointer items-center justify-center rounded-md border border-dashed bg-card py-10 hover:border-active-border",
30+
props.isError &&
31+
"border-red-500 bg-red-200/30 text-red-500 hover:border-red-600 dark:border-red-900 dark:bg-red-900/30 dark:hover:border-red-800",
32+
props.className,
33+
)}
34+
{...getRootProps()}
35+
>
36+
<input {...getInputProps()} accept={props.accept} />
37+
<div className="flex flex-col items-center justify-center gap-3">
38+
{!props.isError && (
39+
<div className="flex flex-col items-center">
40+
<div className="mb-3 flex size-11 items-center justify-center rounded-full border bg-card">
41+
<UploadIcon className="size-5" />
42+
</div>
43+
<h2 className="mb-0.5 text-center font-medium text-lg">
44+
{props.title}
45+
</h2>
46+
<p className="text-center font-medium text-muted-foreground text-sm">
47+
{props.description}
48+
</p>
49+
</div>
50+
)}
51+
52+
{props.isError && (
53+
<div className="flex flex-col items-center">
54+
<div className="mb-3 flex size-11 items-center justify-center rounded-full border border-red-500 bg-red-200/50 text-red-500 dark:border-red-900 dark:bg-red-900/30 dark:text-foreground">
55+
<XIcon className="size-5" />
56+
</div>
57+
<h2 className="mb-0.5 text-center font-medium text-foreground text-lg">
58+
{props.title}
59+
</h2>
60+
<p className="text-balance text-center text-sm">
61+
{props.description}
62+
</p>
63+
64+
{resetButton && (
65+
<Button
66+
className="relative z-50 mt-4"
67+
size="sm"
68+
onClick={(e) => {
69+
e.stopPropagation();
70+
resetButton.onClick();
71+
}}
72+
>
73+
{resetButton.label}
74+
</Button>
75+
)}
76+
</div>
77+
)}
78+
</div>
79+
</div>
80+
);
81+
}

apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.stories.tsx

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,45 +16,34 @@ const meta = {
1616
export default meta;
1717
type Story = StoryObj<typeof meta>;
1818

19-
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
20-
2119
export const AllStates: Story = {
2220
args: {
21+
onRetry: () => {},
2322
steps: [
2423
{
2524
status: { type: "completed" },
2625
label: "Connect Wallet",
27-
execute: async () => {
28-
await sleep(1000);
29-
},
26+
id: "connect-wallet",
3027
},
3128
{
3229
status: { type: "pending" },
3330
label: "Sign Message",
34-
execute: async () => {
35-
await sleep(1000);
36-
},
31+
id: "sign-message",
3732
},
3833
{
3934
status: { type: "error", message: "This is an error message" },
4035
label: "Approve Transaction",
41-
execute: async () => {
42-
await sleep(1000);
43-
},
36+
id: "approve-transaction",
4437
},
4538
{
4639
status: { type: "idle" },
4740
label: "Confirm Transaction",
48-
execute: async () => {
49-
await sleep(1000);
50-
},
41+
id: "confirm-transaction",
5142
},
5243
{
5344
status: { type: "idle" },
5445
label: "Finalize",
55-
execute: async () => {
56-
await sleep(1000);
57-
},
46+
id: "finalize",
5847
},
5948
],
6049
},

apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,23 @@ import {
1010
import { DynamicHeight } from "../../ui/DynamicHeight";
1111
import { Spinner } from "../../ui/Spinner/Spinner";
1212

13-
export type MultiStepState = {
13+
export type MultiStepState<T extends string> = {
14+
id: T;
1415
status:
1516
| {
1617
type: "idle" | "pending" | "completed";
1718
}
1819
| {
1920
type: "error";
20-
message: string | React.ReactNode;
21+
message: React.ReactNode;
2122
};
2223
label: string;
23-
execute: () => Promise<void>;
24+
description?: string;
2425
};
2526

26-
export function MultiStepStatus(props: {
27-
steps: MultiStepState[];
27+
export function MultiStepStatus<T extends string>(props: {
28+
steps: MultiStepState<T>[];
29+
onRetry: (step: MultiStepState<T>) => void;
2830
}) {
2931
return (
3032
<DynamicHeight>
@@ -55,6 +57,15 @@ export function MultiStepStatus(props: {
5557
{step.label}
5658
</p>
5759

60+
{/* show description when this step is active */}
61+
{(step.status.type === "pending" ||
62+
step.status.type === "error") &&
63+
step.description && (
64+
<p className="text-muted-foreground text-sm">
65+
{step.description}
66+
</p>
67+
)}
68+
5869
{step.status.type === "error" && (
5970
<div className="mt-1 space-y-2">
6071
<p className="mb-1 text-red-500 text-sm">
@@ -64,7 +75,7 @@ export function MultiStepStatus(props: {
6475
variant="destructive"
6576
size="sm"
6677
className="gap-2"
67-
onClick={() => step.execute()}
78+
onClick={() => props.onRetry(step)}
6879
>
6980
<RefreshCwIcon className="size-4" />
7081
Retry

apps/dashboard/src/@/components/ui/decimal-input.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export function DecimalInput(props: {
66
id?: string;
77
className?: string;
88
placeholder?: string;
9+
disabled?: boolean;
910
}) {
1011
return (
1112
<Input
@@ -15,6 +16,7 @@ export function DecimalInput(props: {
1516
className={props.className}
1617
inputMode="decimal"
1718
placeholder={props.placeholder}
19+
disabled={props.disabled}
1820
onChange={(e) => {
1921
const number = Number(e.target.value);
2022
// ignore if string becomes invalid number
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export function fileToBlobUrl(file: File) {
2+
const blob = new Blob([file], { type: file.type });
3+
return URL.createObjectURL(blob);
4+
}

apps/dashboard/src/app/(app)/(dashboard)/(bridge)/routes/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1+
import { getAuthToken } from "@app/api/lib/getAuthToken";
12
import { ArrowUpRightIcon } from "lucide-react";
23
import type { Metadata } from "next";
34
import { headers } from "next/headers";
4-
import { getAuthToken } from "../../../api/lib/getAuthToken";
55
import { SearchInput } from "./components/client/search";
66
import { QueryType } from "./components/client/type";
77
import { RouteListView } from "./components/client/view";

apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/FaucetButton.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
useSwitchActiveWalletChain,
5050
useWalletBalance,
5151
} from "thirdweb/react";
52+
import { parseError } from "utils/errorParser";
5253
import { z } from "zod";
5354

5455
function formatTime(seconds: number) {
@@ -234,7 +235,12 @@ export function FaucetButton({
234235
const claimPromise = claimMutation.mutateAsync(values.turnstileToken);
235236
toast.promise(claimPromise, {
236237
success: `${amount} ${chain.nativeCurrency.symbol} sent successfully`,
237-
error: `Failed to claim ${amount} ${chain.nativeCurrency.symbol}`,
238+
error: (err) => {
239+
return {
240+
message: `Failed to claim ${amount} ${chain.nativeCurrency.symbol}`,
241+
description: parseError(err),
242+
};
243+
},
238244
});
239245
};
240246

apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/layout.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,16 @@ import {
1414
DropdownMenuTrigger,
1515
} from "@/components/ui/dropdown-menu";
1616
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
17+
import {
18+
getAuthToken,
19+
getAuthTokenWalletAddress,
20+
} from "@app/api/lib/getAuthToken";
1721
import { ChevronDownIcon, TicketCheckIcon } from "lucide-react";
1822
import type { Metadata } from "next";
1923
import Link from "next/link";
2024
import { redirect } from "next/navigation";
2125
import { mapV4ChainToV5Chain } from "../../../../../../contexts/map-chains";
2226
import { NebulaChatButton } from "../../../../../nebula-app/(app)/components/FloatingChat/FloatingChat";
23-
import {
24-
getAuthToken,
25-
getAuthTokenWalletAddress,
26-
} from "../../../../api/lib/getAuthToken";
2727
import { TeamHeader } from "../../../../team/components/TeamHeader/team-header";
2828
import { StarButton } from "../../components/client/star-button";
2929
import { getChain, getChainMetadata } from "../../utils";

0 commit comments

Comments
 (0)