Skip to content

Commit e08fb71

Browse files
authored
Merge pull request #447 from hypercerts-org/feat/upload-image
feat: upload image
2 parents fcdb0be + 4eecd1d commit e08fb71

File tree

7 files changed

+318
-23
lines changed

7 files changed

+318
-23
lines changed

collections/hooks.ts

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useStepProcessDialogContext } from "@/components/global/step-process-di
77
import { useRouter } from "next/navigation";
88
import { isParseableNumber } from "@/lib/isParseableInteger";
99
import { isValidHypercertId } from "@/lib/utils";
10+
import { base64ToBlob } from "@/components/image-uploader";
1011

1112
export interface HyperboardCreateRequest {
1213
chainIds: number[];
@@ -50,6 +51,10 @@ export const useCreateHyperboard = () => {
5051
}
5152

5253
setSteps([
54+
{
55+
id: "Pinning IPFS",
56+
description: "Uploading Background Image to IPFS",
57+
},
5358
{
5459
id: "Awaiting signature",
5560
description: "Awaiting signature",
@@ -61,6 +66,43 @@ export const useCreateHyperboard = () => {
6166
]);
6267

6368
setOpen(true);
69+
70+
let imageUrl = data.backgroundImg;
71+
if (data.backgroundImg) {
72+
setStep("Pinning IPFS", "active");
73+
74+
const formData = new FormData();
75+
const blob = base64ToBlob(data.backgroundImg);
76+
const file = new File([blob], "backgroundImg.jpg", {
77+
type: "image/jpeg",
78+
});
79+
formData.append("files", file);
80+
81+
try {
82+
setStep("Pinning IPFS", "active");
83+
const response = await fetch(`${HYPERCERTS_API_URL_REST}/upload`, {
84+
method: "POST",
85+
body: formData,
86+
});
87+
const result = await response.json();
88+
if (!response.ok) {
89+
throw new Error(result?.data?.message || "Error pinning to IPFS");
90+
}
91+
if (result.success && result.data.results.length > 0) {
92+
imageUrl = `https://${result.data.results[0].cid}.ipfs.w3s.link`;
93+
await setStep("Pinning IPFS", "completed");
94+
}
95+
} catch (error) {
96+
await setStep(
97+
"Pinning IPFS",
98+
"error",
99+
error instanceof Error ? error.message : "Error pinning to IPFS",
100+
);
101+
}
102+
} else {
103+
await setStep("Pinning IPFS", "completed");
104+
}
105+
64106
await setStep("Awaiting signature", "active");
65107
let signature: string;
66108

@@ -141,7 +183,7 @@ export const useCreateHyperboard = () => {
141183
],
142184
borderColor: data.borderColor,
143185
chainIds: [chainId],
144-
backgroundImg: data.backgroundImg,
186+
backgroundImg: imageUrl,
145187
adminAddress: address,
146188
signature: signature,
147189
};
@@ -238,6 +280,10 @@ export const useUpdateHyperboard = () => {
238280
}
239281

240282
setSteps([
283+
{
284+
id: "Pinning IPFS",
285+
description: "Uploading Background Image to IPFS",
286+
},
241287
{
242288
id: "Awaiting signature",
243289
description: "Awaiting signature",
@@ -249,6 +295,39 @@ export const useUpdateHyperboard = () => {
249295
]);
250296

251297
setOpen(true);
298+
let imageUrl: string | undefined;
299+
if (data.backgroundImg) {
300+
await setStep("Pinning IPFS", "active");
301+
302+
const formData = new FormData();
303+
const blob = base64ToBlob(data.backgroundImg);
304+
const file = new File([blob], "backgroundImg.jpg", {
305+
type: "image/jpeg",
306+
});
307+
formData.append("files", file);
308+
309+
try {
310+
const response = await fetch(`${HYPERCERTS_API_URL_REST}/upload`, {
311+
method: "POST",
312+
body: formData,
313+
});
314+
const result = await response.json();
315+
if (!response.ok) {
316+
throw new Error(result?.data?.message || "Error pinning to IPFS");
317+
}
318+
if (result.success && result.data.results.length > 0) {
319+
imageUrl = `https://${result.data.results[0].cid}.ipfs.w3s.link`;
320+
await setStep("Pinning IPFS", "completed");
321+
}
322+
} catch (error) {
323+
await setStep(
324+
"Pinning IPFS",
325+
"error",
326+
error instanceof Error ? error.message : "Error pinning to IPFS",
327+
);
328+
}
329+
}
330+
252331
await setStep("Awaiting signature", "active");
253332
let signature: string;
254333

@@ -332,7 +411,7 @@ export const useUpdateHyperboard = () => {
332411
],
333412
borderColor: data.borderColor,
334413
chainIds: [chainId],
335-
backgroundImg: data.backgroundImg,
414+
backgroundImg: imageUrl,
336415
adminAddress: address,
337416
signature: signature,
338417
};

components/collections/collection-form.tsx

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,14 @@ import { isValidHypercertId } from "@/lib/utils";
2626
import { useQuery } from "@tanstack/react-query";
2727
import { parseClaimOrFractionId } from "@hypercerts-org/sdk";
2828
import React, { ReactNode } from "react";
29-
import { ExternalLink, InfoIcon, LoaderCircle } from "lucide-react";
29+
import { ExternalLink, InfoIcon, LoaderCircle, Trash2Icon } from "lucide-react";
3030
import Link from "next/link";
3131
import { useCreateHyperboard, useUpdateHyperboard } from "@/collections/hooks";
3232
import { useBlueprintsByIds } from "@/blueprints/hooks/useBlueprintsByIds";
3333
import { BlueprintFragment } from "@/blueprints/blueprint.fragment";
3434
import { isParseableNumber } from "@/lib/isParseableInteger";
3535
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
36+
import { ImageUploader, readAsBase64 } from "../image-uploader";
3637

3738
const idSchema = z
3839
.string()
@@ -95,7 +96,7 @@ const formSchema = z
9596
path: ["hypercerts"],
9697
},
9798
),
98-
backgroundImg: z.union([z.literal(""), z.string().trim().url()]).optional(),
99+
backgroundImg: z.string().optional(),
99100
borderColor: z
100101
.string()
101102
.regex(/^#(?:[0-9a-f]{3}){1,2}$/i, "Must be a color hex code")
@@ -508,7 +509,27 @@ export const CollectionForm = ({
508509
</InfoPopover>
509510
</FormLabel>
510511
<FormControl>
511-
<Input {...field} />
512+
<div className="flex flex-row items-center gap-x-4">
513+
<ImageUploader
514+
handleImage={async (e) => {
515+
if (e.target.files) {
516+
const file: File | null = e.target.files[0];
517+
const base64 = await readAsBase64(file);
518+
form.setValue("backgroundImg", base64);
519+
}
520+
}}
521+
inputId="backgroundImg-upload"
522+
/>
523+
<Button
524+
type="button"
525+
size={"icon"}
526+
variant={"destructive"}
527+
disabled={!field.value}
528+
onClick={() => form.setValue("backgroundImg", "")}
529+
>
530+
<Trash2Icon className="w-4 h-4" />
531+
</Button>
532+
</div>
512533
</FormControl>
513534
<FormMessage />
514535
</FormItem>

components/hypercert/hypercert-card.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ const HypercertCard = forwardRef<HTMLDivElement, HypercertCardProps>(
7373
<header className="relative h-[173px] w-full flex items-center justify-center rounded-b-xl overflow-clip">
7474
{banner ? (
7575
<Image
76-
src={`https://cors-proxy.hypercerts.workers.dev/?url=${banner}`}
76+
src={banner}
7777
alt={`${title} banner`}
7878
className="object-cover object-center"
7979
fill
@@ -89,7 +89,7 @@ const HypercertCard = forwardRef<HTMLDivElement, HypercertCardProps>(
8989
<div className="relative w-8 h-8 flex items-center justify-center border border-slate-300 rounded-full overflow-hidden">
9090
{logo ? (
9191
<Image
92-
src={`https://cors-proxy.hypercerts.workers.dev/?url=${logo}`}
92+
src={logo}
9393
alt={`${title} logo`}
9494
fill
9595
unoptimized

components/hypercert/hypercert-minting-form/form-steps.tsx

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import { ChevronDown } from "lucide-react";
7373
import Link from "next/link";
7474
import { UseFormReturn } from "react-hook-form";
7575
import { useAccount, useChainId } from "wagmi";
76+
import { ImageUploader, readAsBase64 } from "@/components/image-uploader";
7677

7778
// import Image from "next/image";
7879

@@ -174,10 +175,30 @@ const GeneralInformation = ({ form }: FormStepsProps) => {
174175
<FormItem>
175176
<FormLabel>Logo</FormLabel>
176177
<FormControl>
177-
<Input {...field} placeholder="https://" />
178+
<div className="flex flex-row items-center gap-x-4">
179+
<ImageUploader
180+
handleImage={async (e) => {
181+
if (e.target.files) {
182+
const file: File | null = e.target.files[0];
183+
const base64 = await readAsBase64(file);
184+
form.setValue("logo", base64);
185+
}
186+
}}
187+
inputId={"logo-upload"}
188+
/>
189+
<Button
190+
type="button"
191+
size={"icon"}
192+
variant={"destructive"}
193+
disabled={!field.value}
194+
onClick={() => form.setValue("logo", "")}
195+
>
196+
<Trash2Icon className="w-4 h-4" />
197+
</Button>
198+
</div>
178199
</FormControl>
179200
<FormMessage />
180-
<FormDescription>The URL to your project logo</FormDescription>
201+
<FormDescription>Upload your project logo</FormDescription>
181202
</FormItem>
182203
)}
183204
/>
@@ -188,11 +209,31 @@ const GeneralInformation = ({ form }: FormStepsProps) => {
188209
<FormItem>
189210
<FormLabel>Banner image</FormLabel>
190211
<FormControl>
191-
<Input {...field} placeholder="https://" />
212+
<div className="flex flex-row items-center gap-x-4">
213+
<ImageUploader
214+
handleImage={async (e) => {
215+
if (e.target.files) {
216+
const file: File | null = e.target.files[0];
217+
const base64 = await readAsBase64(file);
218+
form.setValue("banner", base64);
219+
}
220+
}}
221+
inputId={"banner-upload"}
222+
/>
223+
<Button
224+
type="button"
225+
size={"icon"}
226+
variant={"destructive"}
227+
disabled={!field.value}
228+
onClick={() => form.setValue("banner", "")}
229+
>
230+
<Trash2Icon className="w-4 h-4" />
231+
</Button>
232+
</div>
192233
</FormControl>
193234
<FormMessage />
194235
<FormDescription>
195-
The URL to an image to be displayed as the banner
236+
The image to be displayed as the banner
196237
</FormDescription>
197238
</FormItem>
198239
)}

components/hypercert/hypercert-minting-form/index.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { formatDate } from "@/lib/utils";
2020
import { z } from "zod";
2121
import { isAddress } from "viem";
2222
import { useCreateBlueprint } from "@/blueprints/hooks/createBlueprint";
23+
import { isValidImageData } from "@/components/image-uploader";
2324

2425
const formSchema = z.object({
2526
blueprint_minter_address: z.string().refine((data) => isAddress(data), {
@@ -30,8 +31,20 @@ const formSchema = z.object({
3031
.trim()
3132
.min(1, "We need a title for your hypercert")
3233
.max(100, "Max 100 characters"),
33-
logo: z.string().url("Logo URL is not valid"),
34-
banner: z.string().url("Banner URL is not valid"),
34+
logo: z
35+
.string()
36+
.min(1, "Please upload a logo image")
37+
.refine(
38+
(value) => !value || isValidImageData(value),
39+
"Please upload a valid image file or provide a valid URL",
40+
),
41+
banner: z
42+
.string()
43+
.min(1, "Please upload a banner image")
44+
.refine(
45+
(value) => !value || isValidImageData(value),
46+
"Please upload a valid image file or provide a valid URL",
47+
),
3548
description: z
3649
.string()
3750
.trim()

components/image-uploader.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import React from "react";
2+
3+
import { Input } from "./ui/input";
4+
import { Button } from "./ui/button";
5+
import { Upload } from "lucide-react";
6+
import isURL from "validator/lib/isURL";
7+
8+
export const isValidImageData = (value: string) => {
9+
return value.startsWith("data:image/") || isURL(value);
10+
};
11+
12+
export const base64ToBlob = (base64String: string) => {
13+
// remove header
14+
const base64Data = base64String.split(",")[1];
15+
// decode base64
16+
const binaryData = atob(base64Data);
17+
18+
// translate binary data to Uint8Array
19+
const bytes = new Uint8Array(binaryData.length);
20+
for (let i = 0; i < binaryData.length; i++) {
21+
bytes[i] = binaryData.charCodeAt(i);
22+
}
23+
// create blob
24+
return new Blob([bytes], { type: "image/jpeg" });
25+
};
26+
27+
export const readAsBase64 = (file: File) => {
28+
return new Promise<string>((resolve, reject) => {
29+
const reader = new FileReader();
30+
reader.readAsDataURL(file);
31+
reader.onload = () => {
32+
resolve(reader.result as string);
33+
};
34+
reader.onerror = reject;
35+
});
36+
};
37+
38+
interface Props {
39+
handleImage: (e: React.ChangeEvent<HTMLInputElement>) => void;
40+
inputId: string;
41+
disabled?: boolean;
42+
}
43+
export function ImageUploader({ handleImage, inputId, disabled }: Props) {
44+
return (
45+
<div className="flex items-center">
46+
<Input
47+
disabled={disabled}
48+
id={inputId}
49+
name={inputId}
50+
type="file"
51+
onChange={handleImage}
52+
className="hidden"
53+
accept="image/png, image/jpg, image/jpeg"
54+
/>
55+
<Button
56+
disabled={disabled}
57+
type="button"
58+
variant="outline"
59+
onClick={() => document.getElementById(inputId)!.click()}
60+
>
61+
<Upload className="mr-2 h-4 w-4" /> Upload Image
62+
</Button>
63+
</div>
64+
);
65+
}

0 commit comments

Comments
 (0)