mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
ready to test
Testing Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user