Files
photography/frontend/components/photo-gallery.tsx
xujiang 9046befcf1 feat: 完成前端展示网站核心功能开发
 新增功能:
- 增强照片展示页面,支持3种视图模式(网格/瀑布流/列表)
- 实现分页加载和无限滚动功能
- 完整的搜索和过滤系统,支持实时搜索、标签筛选、排序
- 新增分类浏览页面,提供分类统计和预览
- 新增标签云页面,热度可视化显示
- 面包屑导航和页面间无缝跳转

🎨 用户体验优化:
- 响应式设计,完美适配移动端和桌面端
- 智能loading状态和空状态处理
- 悬停效果和交互动画
- 视觉化统计仪表盘

 性能优化:
- 图片懒加载和智能分页
- 优化的组件渲染和状态管理
- 构建大小优化(187kB gzipped)

📝 更新任务进度文档,完成率达到32.5%

Phase 3核心功能基本完成,前端展示网站达到完全可用状态。
2025-07-11 12:27:36 +08:00

265 lines
8.3 KiB
TypeScript

"use client"
import { useState, useEffect, useCallback, useRef } from "react"
import Image from "next/image"
import { Card } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Calendar, MapPin, Grid, List, Eye, Heart } from "lucide-react"
interface Photo {
id: number
src: string
title: string
description: string
category: string
tags: string[]
date: string
exif: {
camera: string
lens: string
settings: string
location: string
}
}
interface PhotoGalleryProps {
photos: Photo[]
onPhotoClick: (photo: Photo) => void
}
type ViewMode = 'grid' | 'masonry' | 'list'
export function PhotoGallery({ photos, onPhotoClick }: PhotoGalleryProps) {
const [loadedImages, setLoadedImages] = useState<Set<number>>(new Set())
const [isHydrated, setIsHydrated] = useState(false)
const [viewMode, setViewMode] = useState<ViewMode>('grid')
const [page, setPage] = useState(1)
const [displayedPhotos, setDisplayedPhotos] = useState<Photo[]>([])
const [isLoading, setIsLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const loaderRef = useRef<HTMLDivElement>(null)
const photosPerPage = 12
useEffect(() => {
setIsHydrated(true)
}, [])
// 初始化显示的照片
useEffect(() => {
if (photos.length > 0) {
setDisplayedPhotos(photos.slice(0, photosPerPage))
setHasMore(photos.length > photosPerPage)
setPage(1)
}
}, [photos])
// 加载更多照片
const loadMorePhotos = useCallback(() => {
if (isLoading || !hasMore) return
setIsLoading(true)
// 模拟API加载延迟
setTimeout(() => {
const nextPage = page + 1
const startIndex = (nextPage - 1) * photosPerPage
const endIndex = startIndex + photosPerPage
const newPhotos = photos.slice(startIndex, endIndex)
if (newPhotos.length > 0) {
setDisplayedPhotos(prev => [...prev, ...newPhotos])
setPage(nextPage)
setHasMore(endIndex < photos.length)
} else {
setHasMore(false)
}
setIsLoading(false)
}, 500)
}, [page, photos, isLoading, hasMore, photosPerPage])
// 滚动检测
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && hasMore && !isLoading) {
loadMorePhotos()
}
},
{ threshold: 0.1 }
)
if (loaderRef.current) {
observer.observe(loaderRef.current)
}
return () => observer.disconnect()
}, [hasMore, isLoading, loadMorePhotos])
const handleImageLoad = (photoId: number) => {
setLoadedImages((prev) => new Set(prev).add(photoId))
}
const getGridClasses = () => {
switch (viewMode) {
case 'grid':
return 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6'
case 'masonry':
return 'columns-1 md:columns-2 lg:columns-3 xl:columns-4 gap-6 space-y-6'
case 'list':
return 'space-y-6'
default:
return 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6'
}
}
const renderPhotoCard = (photo: Photo) => {
const isListView = viewMode === 'list'
return (
<Card
key={photo.id}
className={`group cursor-pointer overflow-hidden border-0 shadow-sm hover:shadow-xl transition-all duration-300 hover:scale-[1.02] ${
viewMode === 'masonry' ? 'break-inside-avoid mb-6' : ''
} ${isListView ? 'flex flex-row' : ''}`}
onClick={() => onPhotoClick(photo)}
>
<div className={`relative overflow-hidden ${
isListView ? 'w-48 h-32 flex-shrink-0' : 'aspect-[4/3]'
}`}>
{isHydrated && !loadedImages.has(photo.id) && (
<div className="absolute inset-0 bg-gray-100 animate-pulse" />
)}
<Image
src={photo.src || "/placeholder.svg"}
alt={photo.title}
fill
className={`object-cover transition-all duration-500 group-hover:scale-110 ${
isHydrated && loadedImages.has(photo.id) ? "opacity-100" : !isHydrated ? "opacity-100" : "opacity-0"
}`}
onLoad={() => handleImageLoad(photo.id)}
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all duration-300" />
{/* Hover overlay */}
<div className="absolute inset-0 p-4 flex flex-col justify-end opacity-0 group-hover:opacity-100 transition-all duration-300">
<div className="text-white">
<div className="flex items-center gap-2 text-xs text-white/70">
<Eye className="h-3 w-3" />
<span></span>
<Heart className="h-3 w-3 ml-2" />
</div>
</div>
</div>
</div>
{/* Card content */}
<div className={`p-4 ${isListView ? 'flex-1' : ''}`}>
<h3 className="font-medium text-gray-900 mb-2">{photo.title}</h3>
{isListView && (
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
{photo.description}
</p>
)}
<div className="flex flex-wrap gap-1 mb-3">
{photo.tags.slice(0, isListView ? 4 : 2).map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
<div className="text-xs text-gray-500">
<div className="flex items-center gap-2 mb-1">
<Calendar className="h-3 w-3" />
<span>{photo.date}</span>
</div>
<div className="flex items-center gap-2 mb-1">
<MapPin className="h-3 w-3" />
<span>{photo.exif.location}</span>
</div>
<div>
{photo.exif.camera} {photo.exif.settings}
</div>
</div>
</div>
</Card>
)
}
return (
<div className="space-y-6">
{/* 视图模式切换 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant={viewMode === 'grid' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('grid')}
>
<Grid className="h-4 w-4" />
</Button>
<Button
variant={viewMode === 'masonry' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('masonry')}
>
<div className="h-4 w-4 flex items-center justify-center">
<div className="grid grid-cols-2 gap-px">
<div className="w-1 h-1 bg-current"></div>
<div className="w-1 h-2 bg-current"></div>
<div className="w-1 h-2 bg-current"></div>
<div className="w-1 h-1 bg-current"></div>
</div>
</div>
</Button>
<Button
variant={viewMode === 'list' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('list')}
>
<List className="h-4 w-4" />
</Button>
</div>
<div className="text-sm text-gray-500">
{displayedPhotos.length} / {photos.length}
</div>
</div>
{/* 照片网格 */}
<div className={getGridClasses()}>
{displayedPhotos.map(renderPhotoCard)}
</div>
{/* 加载更多 */}
{hasMore && (
<div ref={loaderRef} className="py-8 text-center">
{isLoading ? (
<div className="flex items-center justify-center gap-2">
<div className="animate-spin rounded-full h-5 w-5 border-2 border-gray-300 border-t-gray-600"></div>
<span className="text-gray-600">...</span>
</div>
) : (
<Button
variant="outline"
onClick={loadMorePhotos}
className="px-8"
>
</Button>
)}
</div>
)}
{/* 无更多照片 */}
{!hasMore && displayedPhotos.length > photosPerPage && (
<div className="py-8 text-center text-gray-500">
{photos.length}
</div>
)}
</div>
)
}