import { useRef, useState } from "react"; import { ReactCrop, centerCrop, convertToPixelCrop, makeAspectCrop, type Crop, type PixelCrop, } from "react-image-crop"; import "react-image-crop/dist/ReactCrop.css"; import { ImageIcon, Trash2, Upload } from "lucide-react"; import { Button } from "~/components/ui/button"; import { cn } from "~/lib/utils"; interface AvatarUploaderProps { uploadUrl: string; teamId?: string; savedPhotoUrl?: string | null; removePhotoIntent?: string; } const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"]; function centerSquareCrop(width: number, height: number): Crop { return centerCrop( makeAspectCrop({ unit: "%", width: 85 }, 1, width, height), width, height ); } async function cropImageToBlob(image: HTMLImageElement, crop: PixelCrop): Promise { const canvas = document.createElement("canvas"); const scaleX = image.naturalWidth / image.width; const scaleY = image.naturalHeight / image.height; canvas.width = 256; canvas.height = 256; const ctx = canvas.getContext("2d"); if (!ctx) { throw new Error("Canvas is not available"); } ctx.drawImage( image, crop.x * scaleX, crop.y * scaleY, crop.width * scaleX, crop.height * scaleY, 0, 0, 256, 256 ); return await new Promise((resolve, reject) => { canvas.toBlob((blob) => { if (blob) resolve(blob); else reject(new Error("Could not prepare image")); }, "image/jpeg", 0.9); }); } export function AvatarUploader({ uploadUrl, teamId, savedPhotoUrl, removePhotoIntent = "remove-avatar-photo", }: AvatarUploaderProps) { const imageRef = useRef(null); const fileInputRef = useRef(null); const [imageSrc, setImageSrc] = useState(null); const [shouldRemoveSavedPhoto, setShouldRemoveSavedPhoto] = useState(false); const [fileName, setFileName] = useState("avatar.jpg"); const [crop, setCrop] = useState(); const [completedCrop, setCompletedCrop] = useState(); const [status, setStatus] = useState<"idle" | "submitting" | "pending" | "error">("idle"); const [error, setError] = useState(null); const [isDragging, setIsDragging] = useState(false); function prepareFile(file: File | undefined) { setError(null); setStatus("idle"); setShouldRemoveSavedPhoto(false); if (!file) return; if (!ACCEPTED_TYPES.includes(file.type)) { setError("Choose a JPG, PNG, WebP, or GIF image."); return; } if (file.size > 5 * 1024 * 1024) { setError("Choose an image under 5 MB."); return; } setFileName(file.name); const reader = new FileReader(); reader.addEventListener("load", () => setImageSrc(String(reader.result))); reader.readAsDataURL(file); } function handleFileChange(event: React.ChangeEvent) { prepareFile(event.target.files?.[0]); event.target.value = ""; } function clearImage() { setImageSrc(null); setFileName("avatar.jpg"); setCrop(undefined); setCompletedCrop(undefined); setError(null); setStatus("idle"); } function handleRemoveSavedPhoto() { setShouldRemoveSavedPhoto(true); clearImage(); } function handleDrop(event: React.DragEvent) { event.preventDefault(); setIsDragging(false); prepareFile(event.dataTransfer.files[0]); } async function handleSubmit() { const image = imageRef.current; const cropToUse = completedCrop ?? (image && crop ? convertToPixelCrop(crop, image.width, image.height) : undefined); if (!image || !cropToUse) { setError("Choose and crop an image first."); return; } setStatus("submitting"); setError(null); try { const blob = await cropImageToBlob(image, cropToUse); const formData = new FormData(); formData.append("file", blob, fileName.replace(/\.[^.]+$/, ".jpg")); if (teamId) formData.append("teamId", teamId); const response = await fetch(uploadUrl, { method: "POST", body: formData }); const result = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(typeof result.error === "string" ? result.error : "Upload failed."); } setStatus("pending"); } catch (err) { setStatus("error"); setError(err instanceof Error ? err.message : "Upload failed."); } } return (
{savedPhotoUrl && !shouldRemoveSavedPhoto && !imageSrc && (
Current avatar
)} {(!savedPhotoUrl || shouldRemoveSavedPhoto) && !imageSrc && ( )} {imageSrc && ( <>
setCrop(percentCrop)} onComplete={(pixelCrop) => setCompletedCrop(pixelCrop)} aspect={1} > { const { width, height } = event.currentTarget; setCrop(centerSquareCrop(width, height)); }} className="max-h-[360px]" />
)} {status === "pending" && (

Photo submitted for review. It will appear after approval.

)} {error &&

{error}

} {shouldRemoveSavedPhoto && !imageSrc ? (
) : ( )}
); }