Skip to content

Commit

Permalink
Merge pull request #447 from hypercerts-org/feat/upload-image
Browse files Browse the repository at this point in the history
feat: upload image
  • Loading branch information
tnkshuuhei authored Feb 18, 2025
2 parents fcdb0be + 4eecd1d commit e08fb71
Show file tree
Hide file tree
Showing 7 changed files with 318 additions and 23 deletions.
83 changes: 81 additions & 2 deletions collections/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useStepProcessDialogContext } from "@/components/global/step-process-di
import { useRouter } from "next/navigation";
import { isParseableNumber } from "@/lib/isParseableInteger";
import { isValidHypercertId } from "@/lib/utils";
import { base64ToBlob } from "@/components/image-uploader";

export interface HyperboardCreateRequest {
chainIds: number[];
Expand Down Expand Up @@ -50,6 +51,10 @@ export const useCreateHyperboard = () => {
}

setSteps([
{
id: "Pinning IPFS",
description: "Uploading Background Image to IPFS",
},
{
id: "Awaiting signature",
description: "Awaiting signature",
Expand All @@ -61,6 +66,43 @@ export const useCreateHyperboard = () => {
]);

setOpen(true);

let imageUrl = data.backgroundImg;
if (data.backgroundImg) {
setStep("Pinning IPFS", "active");

const formData = new FormData();
const blob = base64ToBlob(data.backgroundImg);
const file = new File([blob], "backgroundImg.jpg", {
type: "image/jpeg",
});
formData.append("files", file);

try {
setStep("Pinning IPFS", "active");
const response = await fetch(`${HYPERCERTS_API_URL_REST}/upload`, {
method: "POST",
body: formData,
});
const result = await response.json();
if (!response.ok) {
throw new Error(result?.data?.message || "Error pinning to IPFS");
}
if (result.success && result.data.results.length > 0) {
imageUrl = `https://${result.data.results[0].cid}.ipfs.w3s.link`;
await setStep("Pinning IPFS", "completed");
}
} catch (error) {
await setStep(
"Pinning IPFS",
"error",
error instanceof Error ? error.message : "Error pinning to IPFS",
);
}
} else {
await setStep("Pinning IPFS", "completed");
}

await setStep("Awaiting signature", "active");
let signature: string;

Expand Down Expand Up @@ -141,7 +183,7 @@ export const useCreateHyperboard = () => {
],
borderColor: data.borderColor,
chainIds: [chainId],
backgroundImg: data.backgroundImg,
backgroundImg: imageUrl,
adminAddress: address,
signature: signature,
};
Expand Down Expand Up @@ -238,6 +280,10 @@ export const useUpdateHyperboard = () => {
}

setSteps([
{
id: "Pinning IPFS",
description: "Uploading Background Image to IPFS",
},
{
id: "Awaiting signature",
description: "Awaiting signature",
Expand All @@ -249,6 +295,39 @@ export const useUpdateHyperboard = () => {
]);

setOpen(true);
let imageUrl: string | undefined;
if (data.backgroundImg) {
await setStep("Pinning IPFS", "active");

const formData = new FormData();
const blob = base64ToBlob(data.backgroundImg);
const file = new File([blob], "backgroundImg.jpg", {
type: "image/jpeg",
});
formData.append("files", file);

try {
const response = await fetch(`${HYPERCERTS_API_URL_REST}/upload`, {
method: "POST",
body: formData,
});
const result = await response.json();
if (!response.ok) {
throw new Error(result?.data?.message || "Error pinning to IPFS");
}
if (result.success && result.data.results.length > 0) {
imageUrl = `https://${result.data.results[0].cid}.ipfs.w3s.link`;
await setStep("Pinning IPFS", "completed");
}
} catch (error) {
await setStep(
"Pinning IPFS",
"error",
error instanceof Error ? error.message : "Error pinning to IPFS",
);
}
}

await setStep("Awaiting signature", "active");
let signature: string;

Expand Down Expand Up @@ -332,7 +411,7 @@ export const useUpdateHyperboard = () => {
],
borderColor: data.borderColor,
chainIds: [chainId],
backgroundImg: data.backgroundImg,
backgroundImg: imageUrl,
adminAddress: address,
signature: signature,
};
Expand Down
27 changes: 24 additions & 3 deletions components/collections/collection-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,14 @@ import { isValidHypercertId } from "@/lib/utils";
import { useQuery } from "@tanstack/react-query";
import { parseClaimOrFractionId } from "@hypercerts-org/sdk";
import React, { ReactNode } from "react";
import { ExternalLink, InfoIcon, LoaderCircle } from "lucide-react";
import { ExternalLink, InfoIcon, LoaderCircle, Trash2Icon } from "lucide-react";
import Link from "next/link";
import { useCreateHyperboard, useUpdateHyperboard } from "@/collections/hooks";
import { useBlueprintsByIds } from "@/blueprints/hooks/useBlueprintsByIds";
import { BlueprintFragment } from "@/blueprints/blueprint.fragment";
import { isParseableNumber } from "@/lib/isParseableInteger";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { ImageUploader, readAsBase64 } from "../image-uploader";

const idSchema = z
.string()
Expand Down Expand Up @@ -95,7 +96,7 @@ const formSchema = z
path: ["hypercerts"],
},
),
backgroundImg: z.union([z.literal(""), z.string().trim().url()]).optional(),
backgroundImg: z.string().optional(),
borderColor: z
.string()
.regex(/^#(?:[0-9a-f]{3}){1,2}$/i, "Must be a color hex code")
Expand Down Expand Up @@ -508,7 +509,27 @@ export const CollectionForm = ({
</InfoPopover>
</FormLabel>
<FormControl>
<Input {...field} />
<div className="flex flex-row items-center gap-x-4">
<ImageUploader
handleImage={async (e) => {
if (e.target.files) {
const file: File | null = e.target.files[0];
const base64 = await readAsBase64(file);
form.setValue("backgroundImg", base64);
}
}}
inputId="backgroundImg-upload"
/>
<Button
type="button"
size={"icon"}
variant={"destructive"}
disabled={!field.value}
onClick={() => form.setValue("backgroundImg", "")}
>
<Trash2Icon className="w-4 h-4" />
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
Expand Down
4 changes: 2 additions & 2 deletions components/hypercert/hypercert-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ const HypercertCard = forwardRef<HTMLDivElement, HypercertCardProps>(
<header className="relative h-[173px] w-full flex items-center justify-center rounded-b-xl overflow-clip">
{banner ? (
<Image
src={`https://cors-proxy.hypercerts.workers.dev/?url=${banner}`}
src={banner}
alt={`${title} banner`}
className="object-cover object-center"
fill
Expand All @@ -89,7 +89,7 @@ const HypercertCard = forwardRef<HTMLDivElement, HypercertCardProps>(
<div className="relative w-8 h-8 flex items-center justify-center border border-slate-300 rounded-full overflow-hidden">
{logo ? (
<Image
src={`https://cors-proxy.hypercerts.workers.dev/?url=${logo}`}
src={logo}
alt={`${title} logo`}
fill
unoptimized
Expand Down
49 changes: 45 additions & 4 deletions components/hypercert/hypercert-minting-form/form-steps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import { ChevronDown } from "lucide-react";
import Link from "next/link";
import { UseFormReturn } from "react-hook-form";
import { useAccount, useChainId } from "wagmi";
import { ImageUploader, readAsBase64 } from "@/components/image-uploader";

// import Image from "next/image";

Expand Down Expand Up @@ -174,10 +175,30 @@ const GeneralInformation = ({ form }: FormStepsProps) => {
<FormItem>
<FormLabel>Logo</FormLabel>
<FormControl>
<Input {...field} placeholder="https://" />
<div className="flex flex-row items-center gap-x-4">
<ImageUploader
handleImage={async (e) => {
if (e.target.files) {
const file: File | null = e.target.files[0];
const base64 = await readAsBase64(file);
form.setValue("logo", base64);
}
}}
inputId={"logo-upload"}
/>
<Button
type="button"
size={"icon"}
variant={"destructive"}
disabled={!field.value}
onClick={() => form.setValue("logo", "")}
>
<Trash2Icon className="w-4 h-4" />
</Button>
</div>
</FormControl>
<FormMessage />
<FormDescription>The URL to your project logo</FormDescription>
<FormDescription>Upload your project logo</FormDescription>
</FormItem>
)}
/>
Expand All @@ -188,11 +209,31 @@ const GeneralInformation = ({ form }: FormStepsProps) => {
<FormItem>
<FormLabel>Banner image</FormLabel>
<FormControl>
<Input {...field} placeholder="https://" />
<div className="flex flex-row items-center gap-x-4">
<ImageUploader
handleImage={async (e) => {
if (e.target.files) {
const file: File | null = e.target.files[0];
const base64 = await readAsBase64(file);
form.setValue("banner", base64);
}
}}
inputId={"banner-upload"}
/>
<Button
type="button"
size={"icon"}
variant={"destructive"}
disabled={!field.value}
onClick={() => form.setValue("banner", "")}
>
<Trash2Icon className="w-4 h-4" />
</Button>
</div>
</FormControl>
<FormMessage />
<FormDescription>
The URL to an image to be displayed as the banner
The image to be displayed as the banner
</FormDescription>
</FormItem>
)}
Expand Down
17 changes: 15 additions & 2 deletions components/hypercert/hypercert-minting-form/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { formatDate } from "@/lib/utils";
import { z } from "zod";
import { isAddress } from "viem";
import { useCreateBlueprint } from "@/blueprints/hooks/createBlueprint";
import { isValidImageData } from "@/components/image-uploader";

const formSchema = z.object({
blueprint_minter_address: z.string().refine((data) => isAddress(data), {
Expand All @@ -30,8 +31,20 @@ const formSchema = z.object({
.trim()
.min(1, "We need a title for your hypercert")
.max(100, "Max 100 characters"),
logo: z.string().url("Logo URL is not valid"),
banner: z.string().url("Banner URL is not valid"),
logo: z
.string()
.min(1, "Please upload a logo image")
.refine(
(value) => !value || isValidImageData(value),
"Please upload a valid image file or provide a valid URL",
),
banner: z
.string()
.min(1, "Please upload a banner image")
.refine(
(value) => !value || isValidImageData(value),
"Please upload a valid image file or provide a valid URL",
),
description: z
.string()
.trim()
Expand Down
65 changes: 65 additions & 0 deletions components/image-uploader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import React from "react";

import { Input } from "./ui/input";
import { Button } from "./ui/button";
import { Upload } from "lucide-react";
import isURL from "validator/lib/isURL";

export const isValidImageData = (value: string) => {
return value.startsWith("data:image/") || isURL(value);
};

export const base64ToBlob = (base64String: string) => {
// remove header
const base64Data = base64String.split(",")[1];
// decode base64
const binaryData = atob(base64Data);

// translate binary data to Uint8Array
const bytes = new Uint8Array(binaryData.length);
for (let i = 0; i < binaryData.length; i++) {
bytes[i] = binaryData.charCodeAt(i);
}
// create blob
return new Blob([bytes], { type: "image/jpeg" });
};

export const readAsBase64 = (file: File) => {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
resolve(reader.result as string);
};
reader.onerror = reject;
});
};

interface Props {
handleImage: (e: React.ChangeEvent<HTMLInputElement>) => void;
inputId: string;
disabled?: boolean;
}
export function ImageUploader({ handleImage, inputId, disabled }: Props) {
return (
<div className="flex items-center">
<Input
disabled={disabled}
id={inputId}
name={inputId}
type="file"
onChange={handleImage}
className="hidden"
accept="image/png, image/jpg, image/jpeg"
/>
<Button
disabled={disabled}
type="button"
variant="outline"
onClick={() => document.getElementById(inputId)!.click()}
>
<Upload className="mr-2 h-4 w-4" /> Upload Image
</Button>
</div>
);
}
Loading

0 comments on commit e08fb71

Please sign in to comment.