0.0.0.4
This commit is contained in:
@@ -3,15 +3,18 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { db } from "@/lib/db"
|
||||
import { CircleUser, CircleUserRound } from "lucide-react"
|
||||
import type { Gender } from "@/types/family"
|
||||
|
||||
interface AvatarDisplayProps {
|
||||
imageId?: string
|
||||
fallbackUrl?: string
|
||||
fallbackText?: string
|
||||
gender?: Gender
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function AvatarDisplay({ imageId, fallbackUrl, fallbackText, className }: AvatarDisplayProps) {
|
||||
export function AvatarDisplay({ imageId, fallbackUrl, fallbackText, gender, className }: AvatarDisplayProps) {
|
||||
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -28,10 +31,26 @@ export function AvatarDisplay({ imageId, fallbackUrl, fallbackText, className }:
|
||||
}
|
||||
}, [imageId])
|
||||
|
||||
// 如果有头像,显示头像
|
||||
if (blobUrl) {
|
||||
return (
|
||||
<Avatar className={className}>
|
||||
<AvatarImage src={blobUrl} />
|
||||
<AvatarFallback className="text-lg font-serif bg-muted">{fallbackText}</AvatarFallback>
|
||||
</Avatar>
|
||||
)
|
||||
}
|
||||
|
||||
// 没有头像,显示性别图标
|
||||
return (
|
||||
<Avatar className={className}>
|
||||
<AvatarImage src={blobUrl || fallbackUrl || "/placeholder.svg"} />
|
||||
<AvatarFallback className="text-lg font-serif bg-muted">{fallbackText}</AvatarFallback>
|
||||
<AvatarFallback className="bg-muted flex items-center justify-center">
|
||||
{gender === 'female' ? (
|
||||
<CircleUserRound className="h-8 w-8 text-pink-400" />
|
||||
) : (
|
||||
<CircleUser className="h-8 w-8 text-blue-400" />
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)
|
||||
}
|
||||
|
||||
+172
-42
@@ -3,9 +3,13 @@
|
||||
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Upload, X, Loader2, Crop } from "lucide-react"
|
||||
import { db } from "@/lib/db"
|
||||
import { v4 as uuidv4 } from "uuid"
|
||||
import ReactCrop, { type Crop as CropType } from "react-image-crop"
|
||||
import "react-image-crop/dist/ReactCrop.css"
|
||||
import imageCompression from "browser-image-compression"
|
||||
|
||||
interface ImageUploadProps {
|
||||
value?: string // The image ID
|
||||
@@ -16,6 +20,11 @@ interface ImageUploadProps {
|
||||
export function ImageUpload({ value, onChange, className }: ImageUploadProps) {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | undefined>(undefined)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [showCropDialog, setShowCropDialog] = useState(false)
|
||||
const [imageToCrop, setImageToCrop] = useState<string | null>(null)
|
||||
const [crop, setCrop] = useState<CropType>()
|
||||
const [completedCrop, setCompletedCrop] = useState<CropType>()
|
||||
const imgRef = useRef<HTMLImageElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Load preview if value (imageId) exists
|
||||
@@ -50,7 +59,6 @@ export function ImageUpload({ value, onChange, className }: ImageUploadProps) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Basic validation
|
||||
if (!file.type.startsWith("image/")) {
|
||||
@@ -58,28 +66,103 @@ export function ImageUpload({ value, onChange, className }: ImageUploadProps) {
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert("图片大小不能超过 5MB")
|
||||
return
|
||||
// 压缩图片
|
||||
const options = {
|
||||
maxSizeMB: 1,
|
||||
maxWidthOrHeight: 1024,
|
||||
useWebWorker: true,
|
||||
}
|
||||
|
||||
const compressedFile = await imageCompression(file, options)
|
||||
|
||||
// 显示裁剪对话框
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
setImageToCrop(reader.result as string)
|
||||
setShowCropDialog(true)
|
||||
}
|
||||
reader.readAsDataURL(compressedFile)
|
||||
} catch (error) {
|
||||
console.error("Failed to process image:", error)
|
||||
alert("图片处理失败")
|
||||
} finally {
|
||||
// Reset input
|
||||
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const handleCropComplete = async () => {
|
||||
if (!completedCrop || !imgRef.current) {
|
||||
// 如果没有裁剪,直接保存原图
|
||||
await saveImage(imageToCrop!)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const canvas = document.createElement('canvas')
|
||||
const scaleX = imgRef.current.naturalWidth / imgRef.current.width
|
||||
const scaleY = imgRef.current.naturalHeight / imgRef.current.height
|
||||
|
||||
canvas.width = completedCrop.width
|
||||
canvas.height = completedCrop.height
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
if (ctx) {
|
||||
ctx.drawImage(
|
||||
imgRef.current,
|
||||
completedCrop.x * scaleX,
|
||||
completedCrop.y * scaleY,
|
||||
completedCrop.width * scaleX,
|
||||
completedCrop.height * scaleY,
|
||||
0,
|
||||
0,
|
||||
completedCrop.width,
|
||||
completedCrop.height
|
||||
)
|
||||
}
|
||||
|
||||
canvas.toBlob(async (blob) => {
|
||||
if (blob) {
|
||||
await saveImage(URL.createObjectURL(blob), blob)
|
||||
}
|
||||
}, 'image/jpeg', 0.9)
|
||||
} catch (error) {
|
||||
console.error("Failed to crop image:", error)
|
||||
alert("图片裁剪失败")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setShowCropDialog(false)
|
||||
setImageToCrop(null)
|
||||
}
|
||||
}
|
||||
|
||||
const saveImage = async (dataUrl: string, blob?: Blob) => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
let imageBlob = blob
|
||||
if (!imageBlob) {
|
||||
const response = await fetch(dataUrl)
|
||||
imageBlob = await response.blob()
|
||||
}
|
||||
|
||||
// Save to DB
|
||||
const imageId = uuidv4()
|
||||
await db.images.add({
|
||||
id: imageId,
|
||||
blob: file,
|
||||
mimeType: file.type,
|
||||
blob: imageBlob,
|
||||
mimeType: imageBlob.type,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
onChange(imageId)
|
||||
setShowCropDialog(false)
|
||||
setImageToCrop(null)
|
||||
} catch (error) {
|
||||
console.error("Failed to save image:", error)
|
||||
alert("图片上传失败")
|
||||
alert("图片保存失败")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
// Reset input
|
||||
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,43 +171,90 @@ export function ImageUpload({ value, onChange, className }: ImageUploadProps) {
|
||||
}
|
||||
|
||||
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 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 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={handleRemove}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<X className="mr-2 h-3 w-3" /> 移除
|
||||
{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>
|
||||
</div>
|
||||
|
||||
{/* 裁剪对话框 */}
|
||||
<Dialog open={showCropDialog} onOpenChange={setShowCropDialog}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Crop className="h-5 w-5" />
|
||||
裁剪图片
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[60vh] overflow-auto">
|
||||
{imageToCrop && (
|
||||
<ReactCrop
|
||||
crop={crop}
|
||||
onChange={(c) => setCrop(c)}
|
||||
onComplete={(c) => setCompletedCrop(c)}
|
||||
aspect={1}
|
||||
circularCrop
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={imageToCrop}
|
||||
alt="待裁剪图片"
|
||||
className="max-w-full"
|
||||
/>
|
||||
</ReactCrop>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowCropDialog(false)
|
||||
setImageToCrop(null)
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleCropComplete} disabled={isLoading}>
|
||||
{isLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{completedCrop ? "确认裁剪" : "跳过裁剪"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user