Files
photography/frontend/components/photo-gallery.tsx
xujiang 494d98bee5 feat: 完成前端响应式设计优化,增强移动端体验
- 添加触摸手势支持库 (react-spring + @use-gesture/react)
- 照片模态框增加左右滑动切换功能
- 照片画廊增加下拉刷新功能 (移动端)
- 优化所有按钮符合44px最小触摸目标标准
- 增强移动端导航体验,增加悬停和选中状态
- 创建设备信息检测钩子 (useDeviceInfo)
- 开发优化图片组件,支持懒加载和骨架屏
- 改进移动端手势交互和视觉反馈
- 完善响应式断点系统和触摸设备检测
- 前端构建测试成功,开发服务器正常启动

Task 23 completed: 前端响应式设计优化
2025-07-11 12:40:46 +08:00

355 lines
11 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, RefreshCw } from "lucide-react"
import { useDrag } from "@use-gesture/react"
import { useSpring, animated } from "react-spring"
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
onRefresh?: () => Promise<void>
}
type ViewMode = 'grid' | 'masonry' | 'list'
export function PhotoGallery({ photos, onPhotoClick, onRefresh }: 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 [isRefreshing, setIsRefreshing] = useState(false)
const loaderRef = useRef<HTMLDivElement>(null)
const galleryRef = useRef<HTMLDivElement>(null)
// 下拉刷新动画
const [{ y }, api] = useSpring(() => ({ y: 0 }))
// 下拉刷新手势
const bindRefresh = useDrag(
({ down, movement: [, my], velocity: [, vy], direction: [, yDir] }) => {
// 只在页面顶部且向下拖拽时触发
const isAtTop = window.scrollY === 0
const isPullingDown = yDir > 0 && my > 0
if (!isAtTop || !isPullingDown) return
const refreshThreshold = 60
const shouldRefresh = my > refreshThreshold || vy > 0.3
if (shouldRefresh && !down && !isRefreshing && onRefresh) {
handleRefresh()
}
api.start({
y: down ? Math.min(my, 80) : 0,
immediate: down,
config: { tension: 300, friction: 30 }
})
},
{
axis: 'y',
bounds: { top: 0 },
rubberband: true,
filterTaps: true,
}
)
const handleRefresh = async () => {
if (!onRefresh || isRefreshing) return
setIsRefreshing(true)
try {
await onRefresh()
setPage(1)
setHasMore(true)
} catch (error) {
console.error('刷新失败:', error)
} finally {
setIsRefreshing(false)
}
}
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 (
<animated.div
ref={galleryRef}
{...bindRefresh()}
style={{ transform: y.to(py => `translateY(${py}px)`) }}
className="space-y-6 touch-pan-x"
>
{/* 下拉刷新指示器 */}
{onRefresh && (
<animated.div
style={{
opacity: y.to([0, 30, 60], [0, 0.5, 1]),
transform: y.to(py => `translateY(${Math.max(0, py - 60)}px)`)
}}
className="fixed top-16 left-1/2 transform -translate-x-1/2 z-40 bg-white/90 backdrop-blur-sm rounded-full p-3 shadow-lg border md:hidden"
>
<RefreshCw
className={`h-5 w-5 text-gray-600 ${
isRefreshing ? 'animate-spin' : ''
}`}
/>
</animated.div>
)}
{/* 视图模式切换 */}
<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')}
className="min-h-[44px] min-w-[44px] p-2"
>
<Grid className="h-4 w-4" />
</Button>
<Button
variant={viewMode === 'masonry' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('masonry')}
className="min-h-[44px] min-w-[44px] p-2"
>
<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')}
className="min-h-[44px] min-w-[44px] p-2"
>
<List className="h-4 w-4" />
</Button>
{onRefresh && (
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing}
className="min-h-[44px] px-4 hidden md:flex"
>
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
</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>
)}
</animated.div>
)
}