Files
starr-philproperties/src/components/generic/AvatarUploadDialog.jsx
T
kennethobsequio fbef7cb6e6 ready to test
Testing

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-06-22 10:07:20 +08:00

298 lines
10 KiB
React

import { useState, useRef, useCallback } from 'react'
import Cropper from 'react-easy-crop'
import { Upload, Trash2, Loader2, ZoomIn, ZoomOut, ArrowLeft } from 'lucide-react'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
const MAX_MB = 5
const MAX_BYTES = MAX_MB * 1024 * 1024
const ALLOWED = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
function loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image()
img.addEventListener('load', () => resolve(img))
img.addEventListener('error', reject)
img.setAttribute('crossOrigin', 'anonymous')
img.src = src
})
}
async function cropToBlob(imageSrc, pixels, outputSize = 512) {
const img = await loadImage(imageSrc)
const canvas = document.createElement('canvas')
canvas.width = outputSize
canvas.height = outputSize
const ctx = canvas.getContext('2d')
ctx.drawImage(img, pixels.x, pixels.y, pixels.width, pixels.height, 0, 0, outputSize, outputSize)
return new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.93))
}
export default function AvatarUploadDialog({
open,
onClose,
currentAvatarUrl = '',
initials = '?',
onUpload,
onDelete,
loading = false,
}) {
const [step, setStep] = useState('pick') // 'pick' | 'crop'
const [imageSrc, setImageSrc] = useState(null)
const [fileName, setFileName] = useState('')
const [dragOver, setDragOver] = useState(false)
const [error, setError] = useState('')
const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [croppedAreaPixels, setCroppedAreaPixels] = useState(null)
const inputRef = useRef(null)
const reset = () => {
setStep('pick')
setImageSrc(null)
setFileName('')
setError('')
setDragOver(false)
setCrop({ x: 0, y: 0 })
setZoom(1)
setCroppedAreaPixels(null)
}
const handleClose = () => { reset(); onClose() }
const validate = (f) => {
if (!ALLOWED.includes(f.type)) {
setError('Unsupported format. Use JPEG, PNG, WebP or GIF.')
return false
}
if (f.size > MAX_BYTES) {
setError(`File too large — max is ${MAX_MB} MB.`)
return false
}
return true
}
const applyFile = (f) => {
setError('')
if (!validate(f)) return
const reader = new FileReader()
reader.onload = (e) => {
setImageSrc(e.target.result)
setFileName(f.name)
setCrop({ x: 0, y: 0 })
setZoom(1)
setStep('crop')
}
reader.readAsDataURL(f)
}
const handleInputChange = (e) => {
const f = e.target.files?.[0]
if (f) applyFile(f)
e.target.value = ''
}
const handleDrop = (e) => {
e.preventDefault()
setDragOver(false)
const f = e.dataTransfer.files?.[0]
if (f) applyFile(f)
}
const onCropComplete = useCallback((_, pixels) => {
setCroppedAreaPixels(pixels)
}, [])
const handleUpload = async () => {
if (!croppedAreaPixels) return
const blob = await cropToBlob(imageSrc, croppedAreaPixels)
const file = new File([blob], fileName || 'avatar.jpg', { type: 'image/jpeg' })
const result = await onUpload(file)
if (result?.success) handleClose()
}
const handleDelete = async () => {
const result = await onDelete()
if (result?.success) handleClose()
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md p-0 overflow-hidden gap-0">
{/* ── Header ── */}
<DialogHeader className="px-5 pt-5 pb-4 border-b">
<div className="flex items-center gap-2">
{step === 'crop' && (
<button
onClick={() => { setStep('pick'); setImageSrc(null) }}
className="p-1 -ml-1 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<ArrowLeft size={16} />
</button>
)}
<DialogTitle className="text-base">
{step === 'crop' ? 'Adjust photo' : 'Change Avatar'}
</DialogTitle>
</div>
</DialogHeader>
{/* ── Step: pick ── */}
{step === 'pick' && (
<div className="px-5 py-5 space-y-4">
<div className="flex justify-center pb-1">
<Avatar className="size-24 ring-2 ring-border">
<AvatarImage src={currentAvatarUrl} />
<AvatarFallback className="text-2xl font-semibold">{initials}</AvatarFallback>
</Avatar>
</div>
<Separator />
<div
onClick={() => inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
className={cn(
'border-2 border-dashed rounded-xl px-6 py-6 text-center cursor-pointer transition-colors select-none',
dragOver
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted/40',
error && !dragOver && 'border-destructive/60 bg-destructive/5'
)}
>
<input
ref={inputRef}
type="file"
accept={ALLOWED.join(',')}
className="hidden"
onChange={handleInputChange}
/>
<div className={cn(
'size-10 rounded-full flex items-center justify-center mx-auto mb-3',
error ? 'bg-destructive/10' : 'bg-muted'
)}>
<Upload className={cn('size-5', error ? 'text-destructive/70' : 'text-muted-foreground')} />
</div>
<p className="text-sm font-medium">
{dragOver ? 'Drop to select' : 'Click or drag & drop to upload'}
</p>
<p className="text-xs text-muted-foreground mt-1">
JPEG, PNG, WebP or GIF &middot; max {MAX_MB} MB
</p>
{error && (
<p className="text-xs text-destructive font-medium mt-2">{error}</p>
)}
</div>
</div>
)}
{/* ── Step: crop ── */}
{step === 'crop' && (
<div>
{/* Cropper canvas */}
<div className="relative h-72 bg-zinc-900">
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={1}
cropShape="round"
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
style={{
containerStyle: { borderRadius: 0 },
cropAreaStyle: { border: '2px solid rgba(255,255,255,0.85)', boxShadow: '0 0 0 9999px rgba(0,0,0,0.55)' },
}}
/>
</div>
{/* Zoom controls */}
<div className="px-5 py-4 space-y-2.5 border-b bg-background">
<div className="flex items-center gap-3">
<button
onClick={() => setZoom((z) => Math.max(1, +(z - 0.1).toFixed(2)))}
className="p-1.5 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<ZoomOut size={15} />
</button>
<input
type="range"
min={1}
max={3}
step={0.01}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
className="flex-1 h-1.5 appearance-none rounded-full bg-border cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:size-4
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-foreground
[&::-webkit-slider-thumb]:cursor-pointer
[&::-webkit-slider-thumb]:shadow-sm"
/>
<button
onClick={() => setZoom((z) => Math.min(3, +(z + 0.1).toFixed(2)))}
className="p-1.5 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<ZoomIn size={15} />
</button>
</div>
<p className="text-xs text-center text-muted-foreground">
Drag to reposition &middot; Scroll or pinch to zoom
</p>
</div>
</div>
)}
{/* ── Footer ── */}
{/* mx-0 mb-0 rounded-none bg-transparent override DialogFooter's negative margins
that assume p-4 on DialogContent — we use p-0 so those would overshoot */}
<DialogFooter className="mx-0 mb-0 rounded-none bg-transparent border-t px-5 py-4 sm:justify-end">
{step === 'pick' && currentAvatarUrl && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10 gap-1.5 sm:mr-auto"
onClick={handleDelete}
disabled={loading}
>
{loading
? <Loader2 className="size-3.5 animate-spin" />
: <Trash2 className="size-3.5" />
}
Remove
</Button>
)}
<DialogClose asChild>
<Button variant="outline" size="sm" disabled={loading}>
Cancel
</Button>
</DialogClose>
{step === 'crop' && (
<Button
size="sm"
onClick={handleUpload}
disabled={!croppedAreaPixels || loading}
className="gap-1.5"
>
{loading && <Loader2 className="size-3.5 animate-spin" />}
Upload photo
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}