'use client' import { useState, useRef, useCallback } from 'react' import Cropper from 'react-easy-crop' import type { Area } from 'react-easy-crop' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Slider } from '@/components/ui/slider' import { ProjectLogo } from './project-logo' import { Upload, Loader2, Trash2, ImagePlus, ZoomIn } from 'lucide-react' import { trpc } from '@/lib/trpc/client' import { toast } from 'sonner' type LogoUploadProps = { project: { id: string title: string logoKey?: string | null } currentLogoUrl?: string | null onUploadComplete?: () => void children?: React.ReactNode } const MAX_SIZE_MB = 5 const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] /** * Crop an image client-side using canvas and return a Blob. */ async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise { const image = new Image() image.crossOrigin = 'anonymous' await new Promise((resolve, reject) => { image.onload = () => resolve() image.onerror = reject image.src = imageSrc }) const canvas = document.createElement('canvas') canvas.width = pixelCrop.width canvas.height = pixelCrop.height const ctx = canvas.getContext('2d')! ctx.drawImage( image, pixelCrop.x, pixelCrop.y, pixelCrop.width, pixelCrop.height, 0, 0, pixelCrop.width, pixelCrop.height ) return new Promise((resolve, reject) => { canvas.toBlob( (blob) => { if (blob) resolve(blob) else reject(new Error('Canvas toBlob failed')) }, 'image/png', 0.9 ) }) } export function LogoUpload({ project, currentLogoUrl, onUploadComplete, children, }: LogoUploadProps) { const [open, setOpen] = useState(false) const [imageSrc, setImageSrc] = useState(null) const [crop, setCrop] = useState({ x: 0, y: 0 }) const [zoom, setZoom] = useState(1) const [croppedAreaPixels, setCroppedAreaPixels] = useState(null) const [isUploading, setIsUploading] = useState(false) const [isDeleting, setIsDeleting] = useState(false) const fileInputRef = useRef(null) const utils = trpc.useUtils() const getUploadUrl = trpc.logo.getUploadUrl.useMutation() const confirmUpload = trpc.logo.confirmUpload.useMutation() const deleteLogo = trpc.logo.delete.useMutation() const onCropComplete = useCallback((_croppedArea: Area, croppedPixels: Area) => { setCroppedAreaPixels(croppedPixels) }, []) const handleFileSelect = useCallback((e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return // Validate type if (!ALLOWED_TYPES.includes(file.type)) { toast.error('Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.') return } // Validate size if (file.size > MAX_SIZE_MB * 1024 * 1024) { toast.error(`File too large. Maximum size is ${MAX_SIZE_MB}MB.`) return } const reader = new FileReader() reader.onload = (ev) => { setImageSrc(ev.target?.result as string) setCrop({ x: 0, y: 0 }) setZoom(1) } reader.readAsDataURL(file) }, []) const handleUpload = async () => { if (!imageSrc || !croppedAreaPixels) return setIsUploading(true) try { // Crop the image client-side const croppedBlob = await getCroppedImg(imageSrc, croppedAreaPixels) // Get pre-signed upload URL const { uploadUrl, key, providerType } = await getUploadUrl.mutateAsync({ projectId: project.id, fileName: 'logo.png', contentType: 'image/png', }) // Upload cropped blob directly to storage let uploadResponse: Response try { uploadResponse = await fetch(uploadUrl, { method: 'PUT', body: croppedBlob, headers: { 'Content-Type': 'image/png', }, }) } catch (fetchError) { console.error('Logo upload network error (possible CORS issue):', fetchError) throw new Error('Network error uploading file. The storage server may not be reachable.') } if (!uploadResponse.ok) { console.error('Logo upload failed:', uploadResponse.status, uploadResponse.statusText) throw new Error(`Upload failed (${uploadResponse.status}). Please try again.`) } // Confirm upload with the provider type that was used await confirmUpload.mutateAsync({ projectId: project.id, key, providerType }) // Invalidate logo query utils.logo.getUrl.invalidate({ projectId: project.id }) toast.success('Logo updated successfully') setOpen(false) resetState() onUploadComplete?.() } catch (error) { console.error('Upload error:', error) toast.error('Failed to upload logo. Please try again.') } finally { setIsUploading(false) } } const handleDelete = async () => { setIsDeleting(true) try { await deleteLogo.mutateAsync({ projectId: project.id }) utils.logo.getUrl.invalidate({ projectId: project.id }) toast.success('Logo removed') setOpen(false) onUploadComplete?.() } catch (error) { console.error('Delete error:', error) toast.error('Failed to remove logo') } finally { setIsDeleting(false) } } const resetState = () => { setImageSrc(null) setCrop({ x: 0, y: 0 }) setZoom(1) setCroppedAreaPixels(null) if (fileInputRef.current) fileInputRef.current.value = '' } const handleCancel = () => { resetState() setOpen(false) } return ( {children || ( )} Update Project Logo {imageSrc ? 'Drag to reposition and use the slider to zoom. The logo will be cropped to a square.' : `Upload a logo for "${project.title}". Allowed formats: JPEG, PNG, GIF, WebP. Max size: ${MAX_SIZE_MB}MB.`}
{imageSrc ? ( <> {/* Cropper */}
{/* Zoom slider */}
setZoom(val)} className="flex-1" />
{/* Change image button */} ) : ( <> {/* Current logo preview */}
{/* File input */}
)}
{currentLogoUrl && !imageSrc && ( )}
{imageSrc && ( )}
) }