Files
chinese-family-tree-2/components/members/photo-gallery.tsx
T
freedakgmail debcce71bf 0.6.0.0
2025-11-30 23:09:54 +08:00

321 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { X, Upload, Image as ImageIcon, Plus, ZoomIn, Edit2 } from "lucide-react"
import { useDialog } from "@/components/ui/alert-dialog-custom"
import imageCompression from "browser-image-compression"
import type { FamilyPhoto } from "@/types/family"
import { format } from "date-fns"
import { zhCN } from "date-fns/locale"
interface PhotoGalleryProps {
photos?: FamilyPhoto[]
photoIds?: string[] // 兼容旧格式
onChange: (photos: FamilyPhoto[]) => Promise<void> | void
readonly?: boolean
}
export function PhotoGallery({ photos: propPhotos = [], photoIds = [], onChange, readonly = false }: PhotoGalleryProps) {
const { showAlert, showConfirm, showPrompt } = useDialog()
const [photos, setPhotos] = useState<FamilyPhoto[]>([])
const [selectedPhoto, setSelectedPhoto] = useState<FamilyPhoto | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [editingPhoto, setEditingPhoto] = useState<FamilyPhoto | null>(null)
const [editCaption, setEditCaption] = useState("")
const [pendingVisibilityUrl, setPendingVisibilityUrl] = useState<string | null>(null)
// 加载照片 - 兼容新旧格式
useEffect(() => {
// 优先使用新格式 photos
if (propPhotos && propPhotos.length > 0) {
const normalized = propPhotos.map(photo => ({
...photo,
visibleInOverview: photo.visibleInOverview ?? false,
adminVisibleOverride: photo.adminVisibleOverride ?? true,
}))
setPhotos(normalized)
} else if (photoIds && photoIds.length > 0) {
// 兼容旧格式:将 string[] 转换为 FamilyPhoto[]
const convertedPhotos: FamilyPhoto[] = photoIds.map(url => ({
url,
uploadedAt: new Date().toISOString(), // 旧数据没有时间,用当前时间
visibleInOverview: false,
adminVisibleOverride: true,
}))
setPhotos(convertedPhotos)
} else {
setPhotos([])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(propPhotos), JSON.stringify(photoIds)])
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files || files.length === 0) return
setIsUploading(true)
try {
const newPhotos: FamilyPhoto[] = []
for (const file of Array.from(files)) {
if (!file.type.startsWith('image/')) continue
// 压缩图片
const options = {
maxSizeMB: 2,
maxWidthOrHeight: 1920,
useWebWorker: true,
}
const compressedFile = await imageCompression(file, options)
// 上传到服务器
const formData = new FormData()
formData.append('file', compressedFile)
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
if (!response.ok) {
throw new Error('上传失败')
}
const { url } = await response.json()
newPhotos.push({
url,
uploadedAt: new Date().toISOString(),
visibleInOverview: false,
adminVisibleOverride: true,
})
}
// 更新照片列表
onChange([...photos, ...newPhotos])
} catch (error) {
console.error('上传照片失败:', error)
await showAlert('上传照片失败', '错误')
} finally {
setIsUploading(false)
e.target.value = ''
}
}
const handleDelete = async (photo: FamilyPhoto) => {
const confirmed = await showConfirm('确定要删除这张照片吗?', '删除照片')
if (confirmed) {
onChange(photos.filter(p => p.url !== photo.url))
}
}
const handleToggleOverview = async (photo: FamilyPhoto, value: boolean) => {
if (readonly) return
setPendingVisibilityUrl(photo.url)
const previous = photos
const updatedPhotos = photos.map(p =>
p.url === photo.url ? { ...p, visibleInOverview: value } : p
)
setPhotos(updatedPhotos)
try {
await onChange(updatedPhotos)
} catch (error) {
console.error('更新照片展示状态失败:', error)
setPhotos(previous)
await showAlert('更新照片展示状态失败,请重试', '错误')
} finally {
setPendingVisibilityUrl(null)
}
}
const handleEditCaption = (photo: FamilyPhoto) => {
setEditingPhoto(photo)
setEditCaption(photo.caption || "")
}
const handleSaveCaption = async () => {
if (!editingPhoto) return
const updatedPhotos = photos.map(p =>
p.url === editingPhoto.url
? { ...p, caption: editCaption.trim() || undefined }
: p
)
try {
await onChange(updatedPhotos)
setEditingPhoto(null)
setEditCaption("")
} catch (error) {
console.error("保存说明失败:", error)
await showAlert("保存说明失败,请重试", "错误")
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium"> ({photos.length})</h3>
{!readonly && (
<label>
<input
type="file"
multiple
accept="image/*"
className="hidden"
onChange={handleUpload}
disabled={isUploading}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={isUploading}
asChild
>
<span>
<Plus className="mr-2 h-4 w-4" />
{isUploading ? '上传中...' : '添加照片'}
</span>
</Button>
</label>
)}
</div>
{photos.length > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{photos.map((photo, index) => (
<div key={photo.url + index} className="relative group">
<div className="aspect-square">
<img
src={photo.url}
alt={photo.caption || "家族照片"}
className="w-full h-full object-cover rounded-t-lg border border-border cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => setSelectedPhoto(photo)}
/>
</div>
{/* 照片信息区域 */}
<div className="p-2 bg-muted/50 rounded-b-lg border border-t-0 border-border text-xs">
<div className="text-muted-foreground">
{format(new Date(photo.uploadedAt), 'yyyy-MM-dd', { locale: zhCN })}
</div>
{photo.caption && (
<div className="text-foreground mt-1 line-clamp-2">{photo.caption}</div>
)}
<div className="mt-2 flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
<span></span>
<Switch
checked={photo.visibleInOverview ?? false}
onCheckedChange={(checked) => handleToggleOverview(photo, checked)}
disabled={readonly || pendingVisibilityUrl === photo.url}
/>
</div>
</div>
{/* 操作按钮 */}
{!readonly && (
<>
<Button
type="button"
variant="destructive"
size="icon"
className="absolute top-2 right-2 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => handleDelete(photo)}
>
<X className="h-3 w-3" />
</Button>
<Button
type="button"
variant="secondary"
size="icon"
className="absolute top-2 right-10 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => handleEditCaption(photo)}
title="编辑说明"
>
<Edit2 className="h-3 w-3" />
</Button>
</>
)}
<Button
type="button"
variant="secondary"
size="icon"
className="absolute bottom-12 right-2 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => setSelectedPhoto(photo)}
>
<ZoomIn className="h-3 w-3" />
</Button>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground text-sm border border-dashed rounded-lg">
</div>
)}
{/* 照片预览对话框 */}
<Dialog open={!!selectedPhoto} onOpenChange={() => setSelectedPhoto(null)}>
<DialogContent className="max-w-4xl" aria-describedby="photo-preview-desc">
<DialogHeader>
<DialogTitle>
{selectedPhoto?.caption || "照片预览"}
</DialogTitle>
<DialogDescription id="photo-preview-desc" className="sr-only">
</DialogDescription>
</DialogHeader>
{selectedPhoto && (
<div className="space-y-2">
<img
src={selectedPhoto.url}
alt={selectedPhoto.caption || "照片预览"}
className="w-full h-auto max-h-[70vh] object-contain"
/>
<div className="text-sm text-muted-foreground">
{format(new Date(selectedPhoto.uploadedAt), 'yyyy年MM月dd日', { locale: zhCN })}
</div>
</div>
)}
</DialogContent>
</Dialog>
{/* 编辑说明对话框 */}
<Dialog open={!!editingPhoto} onOpenChange={() => setEditingPhoto(null)}>
<DialogContent aria-describedby="photo-edit-desc">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription id="photo-edit-desc" className="sr-only">
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="caption"></Label>
<Input
id="caption"
value={editCaption}
onChange={(e) => setEditCaption(e.target.value)}
placeholder="输入照片说明..."
maxLength={200}
/>
<p className="text-xs text-muted-foreground">{editCaption.length}/200</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setEditingPhoto(null)}>
</Button>
<Button onClick={handleSaveCaption}>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
)
}