diff --git a/client/package.json b/client/package.json index 066f94a..5de1cb4 100644 --- a/client/package.json +++ b/client/package.json @@ -15,7 +15,7 @@ "@hookform/resolvers": "^5.4.0", "@tanstack/react-query": "^5.101.2", "axios": "^1.18.1", - "motion": "^12.38.0", + "motion": "^12.42.2", "react": "^19.2.4", "react-dom": "^19.2.4", "react-hook-form": "^7.80.0", diff --git a/client/src/components/admin/ImageUploadField.tsx b/client/src/components/admin/ImageUploadField.tsx new file mode 100644 index 0000000..8365e5e --- /dev/null +++ b/client/src/components/admin/ImageUploadField.tsx @@ -0,0 +1,75 @@ +import { useState } from "react"; +import { useImageUpload } from "../../hooks/useImageUpload"; + +const MAX_BYTES = 5 * 1024 * 1024; +const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"]; + +type ImageValue = { url: string; publicId: string }; + +type Props = { + value?: ImageValue; + onChange: (value: ImageValue) => void; + folder: string; +}; + +export function ImageUploadField({ value, onChange, folder }: Props) { + const [previewUrl, setPreviewUrl] = useState(value?.url); + const [validationError, setValidationError] = useState(); + const upload = useImageUpload(folder); + + function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + + if (file.size > MAX_BYTES) { + setValidationError("File is too large (max 5 MB)"); + return; + } + if (!ALLOWED_TYPES.includes(file.type)) { + setValidationError("Only JPEG, PNG or WebP images are allowed"); + return; + } + setValidationError(undefined); + + const localPreview = URL.createObjectURL(file); + setPreviewUrl(localPreview); + + upload.mutate(file, { + onSuccess: (result) => { + URL.revokeObjectURL(localPreview); + setPreviewUrl(result.url); + onChange(result); + }, + }); + } + + return ( +
+ + + + {previewUrl && ( + Preview + )} + + {upload.isPending &&

Uploading…

} + {validationError &&

{validationError}

} + {upload.isError && ( +

Upload failed. Please try again.

+ )} + + {value && ( +
+

{value.url}

+

{value.publicId}

+
+ )} +
+ ); +} \ No newline at end of file diff --git a/client/src/hooks/useImageUpload.ts b/client/src/hooks/useImageUpload.ts new file mode 100644 index 0000000..107da7c --- /dev/null +++ b/client/src/hooks/useImageUpload.ts @@ -0,0 +1,36 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "../lib/api"; +import axios from "axios"; +import { isAxiosError } from "axios"; +import { ME_KEY } from "./useAuth"; + +export const useImageUpload = (folder: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (file: File) => { + const { data: sig } = await api.post("/admin/uploads/signature", { folder }); + const form = new FormData(); + form.append("file", file); + form.append("api_key", sig.apiKey); + form.append("timestamp", sig.timestamp); + form.append("signature", sig.signature); + form.append("folder", sig.folder); + // ^send a request with post method + /admin/uploads/signature path + // include the folder from when useImageUpload was called with folder in param + // wait for express to respond with apiKey, timestamp, signature and folder + // new formData object and we append file, apiKey, timestamp, signature and folder + + const res = await axios.post(`https://api.cloudinary.com/v1_1/${sig.cloudName}/image/upload`, form); + return { url: res.data.secure_url, publicId: res.data.public_id }; + // ^send request with post method to cloudinary w/ our cloudname we got from backend + all the formdata + // wait for cloudinary to send back response + // return res.data.secure_url and res.data.public_id as url and publicId + }, + onError: (err) => { + if (isAxiosError(err) && err.response?.status === 401) { + // session died mid-edit -> nulls auth cache and AdminLayout redirects + queryClient.setQueryData(ME_KEY, null); + } + }, + }); +}; \ No newline at end of file diff --git a/client/src/layouts/AdminLayout.tsx b/client/src/layouts/AdminLayout.tsx index 5f4668a..5f2d533 100644 --- a/client/src/layouts/AdminLayout.tsx +++ b/client/src/layouts/AdminLayout.tsx @@ -3,6 +3,7 @@ import { useLogout, useMe } from "../hooks/useAuth"; // Adding an admin page = one entry here + one route in main.tsx. const MENU = [{ to: "/admin/home-content", label: "Homepage text" }]; +const ImageUpload = [{ to: "/admin/image-upload", label: "Image Upload(temp)" }];//remove later export default function AdminLayout() { const { data: me, isPending } = useMe(); @@ -41,6 +42,20 @@ export default function AdminLayout() { {item.label} ))} + {ImageUpload.map((item) => (//remve later from here + + `rounded-lg px-3 py-2 text-sm transition ${isActive + ? "bg-blue-500 text-white" + : "text-gray-400 hover:bg-gray-800 hover:text-white" + }` + } + > + {item.label} + //to the line below + ))}