Files
MOPC-Portal/src/components/shared/logo-upload.tsx

343 lines
10 KiB
TypeScript
Raw Normal View History

'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<Blob> {
const image = new Image()
image.crossOrigin = 'anonymous'
await new Promise<void>((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<Blob>((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<string | null>(null)
const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{children || (
<Button variant="outline" size="sm" className="gap-2">
<ImagePlus className="h-4 w-4" />
{currentLogoUrl ? 'Change Logo' : 'Add Logo'}
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Update Project Logo</DialogTitle>
<DialogDescription>
{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.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{imageSrc ? (
<>
{/* Cropper */}
<div className="relative w-full h-64 bg-muted rounded-lg overflow-hidden">
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={1}
cropShape="rect"
showGrid
onCropChange={setCrop}
onCropComplete={onCropComplete}
onZoomChange={setZoom}
/>
</div>
{/* Zoom slider */}
<div className="flex items-center gap-3 px-1">
<ZoomIn className="h-4 w-4 text-muted-foreground shrink-0" />
<Slider
value={[zoom]}
min={1}
max={3}
step={0.1}
onValueChange={([val]) => setZoom(val)}
className="flex-1"
/>
</div>
{/* Change image button */}
<Button
variant="ghost"
size="sm"
onClick={() => {
resetState()
fileInputRef.current?.click()
}}
className="w-full"
>
Choose a different image
</Button>
</>
) : (
<>
{/* Current logo preview */}
<div className="flex justify-center">
<ProjectLogo
project={project}
logoUrl={currentLogoUrl}
size="lg"
/>
</div>
{/* File input */}
<div className="space-y-2">
<Label htmlFor="logo">Select image</Label>
<Input
ref={fileInputRef}
id="logo"
type="file"
accept={ALLOWED_TYPES.join(',')}
onChange={handleFileSelect}
className="cursor-pointer"
/>
</div>
</>
)}
</div>
<DialogFooter className="flex-col gap-2 sm:flex-row">
{currentLogoUrl && !imageSrc && (
<Button
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
className="w-full sm:w-auto"
>
{isDeleting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
Remove
</Button>
)}
<div className="flex gap-2 w-full sm:w-auto">
<Button variant="outline" onClick={handleCancel} className="flex-1">
Cancel
</Button>
{imageSrc && (
<Button
onClick={handleUpload}
disabled={!croppedAreaPixels || isUploading}
className="flex-1"
>
{isUploading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
Upload
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}