✨ 新增功能: - 增强照片展示页面,支持3种视图模式(网格/瀑布流/列表) - 实现分页加载和无限滚动功能 - 完整的搜索和过滤系统,支持实时搜索、标签筛选、排序 - 新增分类浏览页面,提供分类统计和预览 - 新增标签云页面,热度可视化显示 - 面包屑导航和页面间无缝跳转 🎨 用户体验优化: - 响应式设计,完美适配移动端和桌面端 - 智能loading状态和空状态处理 - 悬停效果和交互动画 - 视觉化统计仪表盘 ⚡ 性能优化: - 图片懒加载和智能分页 - 优化的组件渲染和状态管理 - 构建大小优化(187kB gzipped) 📝 更新任务进度文档,完成率达到32.5% Phase 3核心功能基本完成,前端展示网站达到完全可用状态。
265 lines
8.3 KiB
TypeScript
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>
|
|
)
|
|
}
|