mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
298 lines
10 KiB
React
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 · 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 · 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>
|
|
)
|
|
}
|