131 lines
3.4 KiB
TypeScript
131 lines
3.4 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useRef, useEffect } from "react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
|
import { Upload, X, Loader2 } from "lucide-react"
|
|
import { db } from "@/lib/db"
|
|
import { v4 as uuidv4 } from "uuid"
|
|
|
|
interface ImageUploadProps {
|
|
value?: string // The image ID
|
|
onChange: (imageId: string | undefined) => void
|
|
className?: string
|
|
}
|
|
|
|
export function ImageUpload({ value, onChange, className }: ImageUploadProps) {
|
|
const [previewUrl, setPreviewUrl] = useState<string | undefined>(undefined)
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
// Load preview if value (imageId) exists
|
|
useEffect(() => {
|
|
let objectUrl: string | undefined
|
|
|
|
const loadPreview = async () => {
|
|
if (!value) {
|
|
setPreviewUrl(undefined)
|
|
return
|
|
}
|
|
|
|
try {
|
|
const image = await db.images.get(value)
|
|
if (image) {
|
|
objectUrl = URL.createObjectURL(image.blob)
|
|
setPreviewUrl(objectUrl)
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to load image:", error)
|
|
}
|
|
}
|
|
|
|
loadPreview()
|
|
|
|
return () => {
|
|
if (objectUrl) URL.revokeObjectURL(objectUrl)
|
|
}
|
|
}, [value])
|
|
|
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0]
|
|
if (!file) return
|
|
|
|
setIsLoading(true)
|
|
try {
|
|
// Basic validation
|
|
if (!file.type.startsWith("image/")) {
|
|
alert("请选择图片文件")
|
|
return
|
|
}
|
|
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
alert("图片大小不能超过 5MB")
|
|
return
|
|
}
|
|
|
|
// Save to DB
|
|
const imageId = uuidv4()
|
|
await db.images.add({
|
|
id: imageId,
|
|
blob: file,
|
|
mimeType: file.type,
|
|
createdAt: new Date().toISOString(),
|
|
})
|
|
|
|
onChange(imageId)
|
|
} catch (error) {
|
|
console.error("Failed to save image:", error)
|
|
alert("图片上传失败")
|
|
} finally {
|
|
setIsLoading(false)
|
|
// Reset input
|
|
if (fileInputRef.current) fileInputRef.current.value = ""
|
|
}
|
|
}
|
|
|
|
const handleRemove = () => {
|
|
onChange(undefined)
|
|
}
|
|
|
|
return (
|
|
<div className={`flex items-center gap-4 ${className}`}>
|
|
<Avatar className="h-20 w-20 border-2 border-dashed border-muted-foreground/50">
|
|
<AvatarImage src={previewUrl} className="object-cover" />
|
|
<AvatarFallback className="bg-transparent">
|
|
{isLoading ? <Loader2 className="h-6 w-6 animate-spin" /> : <Upload className="h-6 w-6 text-muted-foreground" />}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<input
|
|
type="file"
|
|
ref={fileInputRef}
|
|
className="hidden"
|
|
accept="image/*"
|
|
onChange={handleFileSelect}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={isLoading}
|
|
>
|
|
{value ? "更换头像" : "上传头像"}
|
|
</Button>
|
|
{value && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-destructive hover:text-destructive"
|
|
onClick={handleRemove}
|
|
>
|
|
<X className="mr-2 h-3 w-3" /> 移除
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|