import Image from "next/image"; import { t } from "@lingui/core/macro"; import { env } from "next-runtime-env"; import { useCallback, useRef, useState } from "react"; import ReactCrop from "react-image-crop"; import "react-image-crop/dist/ReactCrop.css"; import { generateUID } from "@kan/shared/utils"; import Button from "~/components/Button"; import Modal from "~/components/modal"; import { usePopup } from "~/providers/popup"; import { api } from "~/utils/api"; import { getAvatarUrl } from "~/utils/helpers"; interface PercentCrop { unit: "%"; x: number; y: number; width: number; height: number; } interface LocalPixelCrop { x: number; y: number; width: number; height: number; } interface ReactCropProps { crop: PercentCrop | undefined; onChange: (crop: LocalPixelCrop, percentCrop: PercentCrop) => void; aspect?: number; className?: string; circularCrop?: boolean; children: React.ReactNode; } const AnyReactCrop = ReactCrop as unknown as React.FC; export default function Avatar({ userId, userImage, }: { userId: string | undefined; userImage: string | null | undefined; }) { const utils = api.useUtils(); const { showPopup } = usePopup(); const [uploading, setUploading] = useState(false); const [cropDialogOpen, setCropDialogOpen] = useState(false); const [selectedFile, setSelectedFile] = useState(null); const [selectedPreviewUrl, setSelectedPreviewUrl] = useState( null, ); const [crop, setCrop] = useState(); const imgRef = useRef(null); const avatarUrl = userImage ? getAvatarUrl(userImage) : undefined; const onFileChange = (event: React.ChangeEvent) => { event.preventDefault(); const file = event.target.files?.[0] ?? null; if (!file || !userId) { return showPopup({ header: t`Error uploading profile image`, message: t`Please select a file to upload.`, icon: "error", }); } // Open crop dialog with preview setSelectedFile(file); const objUrl = URL.createObjectURL(file); setSelectedPreviewUrl(objUrl); setCropDialogOpen(true); }; const onImageLoad = useCallback( (e: React.SyntheticEvent) => { const { naturalWidth, naturalHeight } = e.currentTarget; // Create a centered square crop at ~90% of the smaller dimension // Compute width% so that the square fits within the image let widthPercent: number; let heightPercent: number; if (naturalWidth >= naturalHeight) { // landscape: height is limiting heightPercent = 90; widthPercent = (naturalHeight / naturalWidth) * heightPercent; } else { // portrait: width is limiting widthPercent = 90; heightPercent = (naturalWidth / naturalHeight) * widthPercent; } const x = (100 - widthPercent) / 2; const y = (100 - heightPercent) / 2; setCrop({ unit: "%", x, y, width: widthPercent, height: heightPercent }); }, [], ); const getCroppedBlob = useCallback(async (): Promise => { if (!imgRef.current || !crop) throw new Error("No crop to save"); const image = imgRef.current; const canvas = document.createElement("canvas"); const cropXpx = (crop.x / 100) * image.naturalWidth; const cropYpx = (crop.y / 100) * image.naturalHeight; const cropWpx = (crop.width / 100) * image.naturalWidth; const cropHpx = (crop.height / 100) * image.naturalHeight; // Cap output at 512x512 — avatars display at 64x64, so higher res is wasteful // and causes "File too large" errors on HiDPI screens const maxSize = 512; const scale = Math.min(maxSize / cropWpx, maxSize / cropHpx, 1); canvas.width = Math.max(1, Math.floor(cropWpx * scale)); canvas.height = Math.max(1, Math.floor(cropHpx * scale)); const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Canvas not supported"); ctx.imageSmoothingQuality = "high"; ctx.drawImage( image, cropXpx, cropYpx, cropWpx, cropHpx, 0, 0, canvas.width, canvas.height, ); const mime = selectedFile?.type ?? "image/jpeg"; const blob: Blob = await new Promise((resolve, reject) => { canvas.toBlob( (b) => (b ? resolve(b) : reject(new Error("toBlob failed"))), mime, 0.85, ); }); return blob; }, [crop, selectedFile]); const resetCropState = useCallback(() => { setCrop(undefined); setSelectedFile(null); if (selectedPreviewUrl) URL.revokeObjectURL(selectedPreviewUrl); setSelectedPreviewUrl(null); }, [selectedPreviewUrl]); const handleCancelCrop = useCallback(() => { setCropDialogOpen(false); resetCropState(); }, [resetCropState]); const handleSaveCrop = useCallback(async () => { try { if (!userId || !selectedFile) return; setUploading(true); const blob = await getCroppedBlob(); const originalExt = selectedFile.name.split(".").pop() ?? "jpg"; const fileName = `${userId}/avatar-${generateUID()}.${originalExt}`; const baseUrl = env("NEXT_PUBLIC_BASE_URL") ?? ""; const response = await fetch( `${baseUrl}/api/upload/avatar`, { method: "POST", headers: { "Content-Type": blob.type, "x-original-filename": fileName, }, body: blob, }, ); if (!response.ok) { throw new Error("Failed to upload profile image"); } // User image is updated in the backend, refresh user data await utils.user.getUser.refetch(); showPopup({ header: t`Profile image updated`, message: t`Your profile image has been updated.`, icon: "success", }); setCropDialogOpen(false); resetCropState(); } catch (error) { console.error(error); showPopup({ header: t`Error uploading profile image`, message: t`Please try again later, or contact customer support.`, icon: "error", }); } finally { setUploading(false); } }, [ getCroppedBlob, resetCropState, selectedFile, showPopup, utils.user.getUser, userId, ]); return (
{avatarUrl ? ( Avatar ) : ( )}
{/* Crop Dialog */} {cropDialogOpen && (

{t`Crop your avatar`}

{t`Adjust the square crop to fit your avatar.`}

setCrop(percentCrop) } aspect={1} circularCrop className="w-full" > {/* eslint-disable-next-line @next/next/no-img-element */} Avatar to crop
)}
); }