Files
chinese-family-tree-2/components/members/photo-gallery.tsx
T
freedakgmail e7167f4340 0.6.2.0
2025-12-10 13:03:10 +08:00

397 lines
16 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, useCallback } 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, Video, Play } 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 isVideoFile = useCallback((url: string) => {
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv']
return videoExtensions.some(ext => url.toLowerCase().endsWith(ext))
}, [])
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)) {
const isImage = file.type.startsWith('image/')
const isVideo = file.type.startsWith('video/')
if (!isImage && !isVideo) continue
let fileToUpload: File | Blob = file
// 只对图片进行压缩
if (isImage) {
const options = {
maxSizeMB: 2,
maxWidthOrHeight: 1920,
useWebWorker: true,
}
fileToUpload = await imageCompression(file, options)
}
// 上传到服务器
const formData = new FormData()
// 对于视频文件,保留原始文件名;对于压缩后的图片,使用原始文件名
formData.append('file', fileToUpload, file.name)
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '上传失败')
}
const { url } = await response.json()
newPhotos.push({
url,
uploadedAt: new Date().toISOString(),
visibleInOverview: false,
adminVisibleOverride: true,
})
}
// 更新照片列表
onChange([...photos, ...newPhotos])
} catch (error: any) {
console.error('上传文件失败:', error)
await showAlert(error.message || '上传文件失败', '错误')
} 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/*,video/*"
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="space-y-6">
{/* 按月份分组显示 */}
{(() => {
// 按月份分组
const sortedPhotos = [...photos].sort((a, b) =>
new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime()
)
const groupedByMonth: Record<string, FamilyPhoto[]> = {}
sortedPhotos.forEach(photo => {
const monthKey = format(new Date(photo.uploadedAt), 'yyyy年MM月', { locale: zhCN })
if (!groupedByMonth[monthKey]) {
groupedByMonth[monthKey] = []
}
groupedByMonth[monthKey].push(photo)
})
return Object.entries(groupedByMonth).map(([month, monthPhotos]) => (
<div key={month}>
<h4 className="text-sm font-medium text-muted-foreground mb-3 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-primary"></span>
{month}
<span className="text-xs text-muted-foreground/70">({monthPhotos.length})</span>
</h4>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{monthPhotos.map((photo, index) => {
const isVideo = isVideoFile(photo.url)
return (
<div key={photo.url + index} className="relative group">
<div className="aspect-square">
{isVideo ? (
<div
className="w-full h-full bg-black rounded-t-lg border border-border cursor-pointer hover:opacity-90 transition-opacity flex items-center justify-center relative"
onClick={() => setSelectedPhoto(photo)}
>
<video
src={photo.url}
className="w-full h-full object-cover rounded-t-lg"
muted
preload="metadata"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<div className="w-12 h-12 rounded-full bg-white/90 flex items-center justify-center">
<Play className="h-6 w-6 text-black ml-1" />
</div>
</div>
<div className="absolute top-2 left-2 bg-black/70 text-white text-xs px-2 py-1 rounded flex items-center gap-1">
<Video className="h-3 w-3" />
</div>
</div>
) : (
<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), 'MM-dd HH:mm', { 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)}
>
{isVideo ? <Play className="h-3 w-3" /> : <ZoomIn className="h-3 w-3" />}
</Button>
</div>
)
})}
</div>
</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 || (selectedPhoto && isVideoFile(selectedPhoto.url) ? "视频预览" : "照片预览")}
</DialogTitle>
<DialogDescription id="photo-preview-desc" className="sr-only">
</DialogDescription>
</DialogHeader>
{selectedPhoto && (
<div className="space-y-2">
{isVideoFile(selectedPhoto.url) ? (
<video
src={selectedPhoto.url}
controls
autoPlay
className="w-full h-auto max-h-[70vh] object-contain bg-black rounded-lg"
/>
) : (
<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>
)
}