Skip to content

Commit

Permalink
feat: add pinana cloud image upload
Browse files Browse the repository at this point in the history
  • Loading branch information
brolag committed Feb 11, 2025
1 parent 723f969 commit 3736533
Show file tree
Hide file tree
Showing 10 changed files with 419 additions and 289 deletions.
12 changes: 12 additions & 0 deletions apps/web/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ const config = {
...nextI18NextConfig.i18n,
localeDetection: false,
},
reactStrictMode: true,
transpilePackages: ["@repo/ui"],
images: {
domains: ["gateway.pinata.cloud"],
},
webpack: (config) => {
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
};
return config;
},
};

export default config;
107 changes: 107 additions & 0 deletions apps/web/src/app/_components/features/ImageUpload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { CameraIcon } from "@heroicons/react/24/outline";
import Image from "next/image";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";

interface ImageUploadProps {
onImageUploaded: (ipfsHash: string) => void;
className?: string;
}

interface PinataResponse {
success: boolean;
ipfsHash: string;
error?: string;
}

export function ImageUpload({
onImageUploaded,
className = "",
}: ImageUploadProps) {
const { t } = useTranslation();
const [isUploading, setIsUploading] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);

const handleFileChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;

try {
// Create preview
const objectUrl = URL.createObjectURL(file);
setPreviewUrl(objectUrl);
setIsUploading(true);
setError(null);

// Create form data
const formData = new FormData();
formData.append("file", file);

// Upload to our API endpoint
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
});

const data = (await response.json()) as PinataResponse;

if (!response.ok || !data.success) {
throw new Error(data.error ?? "Failed to upload image");
}

onImageUploaded(data.ipfsHash);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Failed to upload image";
setError(errorMessage);
console.error("Upload error:", error);
} finally {
setIsUploading(false);
}
},
[onImageUploaded],
);

return (
<div className={`relative ${className}`}>
<input
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
id="image-upload"
disabled={isUploading}
/>

<label
htmlFor="image-upload"
className={`flex flex-col items-center justify-center w-full h-64 border-2 border-dashed rounded-lg cursor-pointer
${isUploading ? "bg-gray-100" : "hover:bg-gray-50"}
${error ? "border-red-300" : "border-gray-300"}`}
>
{previewUrl ? (
<div className="relative w-full h-full">
<Image
src={previewUrl}
alt="Preview"
fill
className="object-cover rounded-lg"
/>
</div>
) : (
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<CameraIcon className="w-12 h-12 mb-4 text-gray-400" />
<p className="mb-2 text-sm text-gray-500">
{isUploading ? t("uploading") : t("click_to_upload")}
</p>
<p className="text-xs text-gray-500">PNG, JPG, GIF up to 10MB</p>
</div>
)}
</label>

{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
</div>
);
}
2 changes: 0 additions & 2 deletions apps/web/src/app/_components/features/ProductDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,12 @@ import { HeartIcon, ShareIcon } from "@heroicons/react/24/outline";
import { HeartIcon as HeartSolidIcon } from "@heroicons/react/24/solid";
import Button from "@repo/ui/button";
import { DataCard } from "@repo/ui/dataCard";
import { InfoCard } from "@repo/ui/infoCard";
import PageHeader from "@repo/ui/pageHeader";
import { H1, H2, Text } from "@repo/ui/typography";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { api } from "~/trpc/react";
import { ProducerInfo } from "./ProducerInfo";
Expand Down
2 changes: 0 additions & 2 deletions apps/web/src/app/_components/features/WalletConnect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import {
useSignTypedData,
} from "@starknet-react/core";
import { signIn, signOut } from "next-auth/react";
import { useSession } from "next-auth/react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
Expand Down
33 changes: 33 additions & 0 deletions apps/web/src/app/api/upload/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { type NextRequest, NextResponse } from "next/server";
import { uploadToPinata, validateImage } from "../../../utils/pinata";

export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get("file") as File;

if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}

if (!validateImage(file)) {
return NextResponse.json(
{ error: "Invalid file type or size" },
{ status: 400 },
);
}

const ipfsHash = await uploadToPinata(file);

return NextResponse.json({
success: true,
ipfsHash,
});
} catch (error) {
console.error("Upload error:", error);
return NextResponse.json(
{ error: "Failed to upload file" },
{ status: 500 },
);
}
}
7 changes: 1 addition & 6 deletions apps/web/src/app/user/edit-profile/my-profile/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,7 @@ function EditMyProfile() {
const { data: session } = useSession();
const router = useRouter();

const {
handleSubmit,
formState: { errors },
control,
reset,
} = useForm<FormData>({
const { handleSubmit, control, reset } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
fullName: "",
Expand Down
Loading

0 comments on commit 3736533

Please sign in to comment.