- 添加触摸手势支持库 (react-spring + @use-gesture/react) - 照片模态框增加左右滑动切换功能 - 照片画廊增加下拉刷新功能 (移动端) - 优化所有按钮符合44px最小触摸目标标准 - 增强移动端导航体验,增加悬停和选中状态 - 创建设备信息检测钩子 (useDeviceInfo) - 开发优化图片组件,支持懒加载和骨架屏 - 改进移动端手势交互和视觉反馈 - 完善响应式断点系统和触摸设备检测 - 前端构建测试成功,开发服务器正常启动 Task 23 completed: 前端响应式设计优化
208 lines
7.1 KiB
TypeScript
208 lines
7.1 KiB
TypeScript
"use client"
|
||
|
||
import { useEffect, useRef } from "react"
|
||
import Image from "next/image"
|
||
import { X, ChevronLeft, ChevronRight, Camera, MapPin, Calendar } from "lucide-react"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Badge } from "@/components/ui/badge"
|
||
import { Card, CardContent } from "@/components/ui/card"
|
||
import { useDrag } from "@use-gesture/react"
|
||
import { useSpring, animated } from "react-spring"
|
||
|
||
import { Photo } from '@/lib/queries'
|
||
|
||
interface PhotoModalProps {
|
||
photo: Photo
|
||
onClose: () => void
|
||
onPrev: () => void
|
||
onNext: () => void
|
||
}
|
||
|
||
export function PhotoModal({ photo, onClose, onPrev, onNext }: PhotoModalProps) {
|
||
const containerRef = useRef<HTMLDivElement>(null)
|
||
|
||
// 触摸手势支持
|
||
const [{ x }, api] = useSpring(() => ({ x: 0 }))
|
||
|
||
const bind = useDrag(
|
||
({ down, movement: [mx], velocity: [vx], direction: [xDir], cancel }) => {
|
||
// 如果滑动速度足够快或者滑动距离超过阈值,触发切换
|
||
const trigger = Math.abs(mx) > window.innerWidth * 0.3 || Math.abs(vx) > 0.5
|
||
|
||
if (trigger && !down) {
|
||
// 根据滑动方向决定前进或后退
|
||
if (xDir > 0) {
|
||
onPrev() // 向右滑动,显示上一张
|
||
} else {
|
||
onNext() // 向左滑动,显示下一张
|
||
}
|
||
cancel() // 取消手势
|
||
}
|
||
|
||
// 设置拖拽动画
|
||
api.start({
|
||
x: down ? mx : 0,
|
||
immediate: down,
|
||
config: { tension: 300, friction: 30 }
|
||
})
|
||
},
|
||
{
|
||
axis: 'x',
|
||
bounds: { left: -window.innerWidth, right: window.innerWidth },
|
||
rubberband: true,
|
||
filterTaps: true,
|
||
}
|
||
)
|
||
|
||
useEffect(() => {
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
switch (e.key) {
|
||
case "Escape":
|
||
onClose()
|
||
break
|
||
case "ArrowLeft":
|
||
onPrev()
|
||
break
|
||
case "ArrowRight":
|
||
onNext()
|
||
break
|
||
}
|
||
}
|
||
|
||
document.addEventListener("keydown", handleKeyDown)
|
||
document.body.style.overflow = "hidden"
|
||
|
||
return () => {
|
||
document.removeEventListener("keydown", handleKeyDown)
|
||
document.body.style.overflow = "unset"
|
||
}
|
||
}, [onClose, onPrev, onNext])
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center p-4">
|
||
{/* Close button */}
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="absolute top-4 right-4 z-10 text-white hover:bg-white/10 min-h-[44px] min-w-[44px] p-2"
|
||
onClick={onClose}
|
||
>
|
||
<X className="h-6 w-6" />
|
||
</Button>
|
||
|
||
{/* Navigation buttons */}
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 text-white hover:bg-white/10 min-h-[44px] min-w-[44px] p-2"
|
||
onClick={onPrev}
|
||
>
|
||
<ChevronLeft className="h-8 w-8" />
|
||
</Button>
|
||
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 text-white hover:bg-white/10 min-h-[44px] min-w-[44px] p-2"
|
||
onClick={onNext}
|
||
>
|
||
<ChevronRight className="h-8 w-8" />
|
||
</Button>
|
||
|
||
<div className="w-full max-w-7xl mx-auto grid lg:grid-cols-3 gap-6 h-full max-h-[90vh]">
|
||
{/* Main image */}
|
||
<div className="lg:col-span-2 relative flex items-center justify-center">
|
||
<animated.div
|
||
ref={containerRef}
|
||
{...bind()}
|
||
style={{ x }}
|
||
className="relative w-full h-full max-h-[80vh] flex items-center justify-center touch-pan-y cursor-grab active:cursor-grabbing"
|
||
>
|
||
<Image
|
||
src={photo.src || "/placeholder.svg"}
|
||
alt={photo.title}
|
||
width={1200}
|
||
height={800}
|
||
className="max-w-full max-h-full object-contain select-none"
|
||
priority
|
||
draggable={false}
|
||
/>
|
||
</animated.div>
|
||
</div>
|
||
|
||
{/* Photo information */}
|
||
<div className="lg:col-span-1 overflow-y-auto">
|
||
<Card className="bg-white/95 backdrop-blur-sm">
|
||
<CardContent className="p-6">
|
||
<div className="space-y-6">
|
||
{/* Title and description */}
|
||
<div>
|
||
<h2 className="text-2xl font-light text-gray-900 mb-2">{photo.title}</h2>
|
||
<p className="text-gray-600 leading-relaxed">{photo.description}</p>
|
||
</div>
|
||
|
||
{/* Tags */}
|
||
<div>
|
||
<h3 className="text-sm font-medium text-gray-900 mb-2">标签</h3>
|
||
<div className="flex flex-wrap gap-2">
|
||
{photo.tags.map((tag: string) => (
|
||
<Badge key={tag} variant="secondary">
|
||
{tag}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Date and location */}
|
||
<div className="grid grid-cols-1 gap-4">
|
||
<div className="flex items-center gap-2 text-gray-600">
|
||
<Calendar className="h-4 w-4" />
|
||
<span className="text-sm">拍摄时间:{photo.date}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-gray-600">
|
||
<MapPin className="h-4 w-4" />
|
||
<span className="text-sm">拍摄地点:{photo.exif.location}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* EXIF information */}
|
||
<div>
|
||
<h3 className="text-sm font-medium text-gray-900 mb-3 flex items-center gap-2">
|
||
<Camera className="h-4 w-4" />
|
||
拍摄参数
|
||
</h3>
|
||
<div className="space-y-2 text-sm text-gray-600">
|
||
<div className="flex justify-between">
|
||
<span>相机:</span>
|
||
<span className="font-medium">{photo.exif.camera}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>镜头:</span>
|
||
<span className="font-medium">{photo.exif.lens}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>参数:</span>
|
||
<span className="font-medium">{photo.exif.settings}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Keyboard shortcuts and gestures */}
|
||
<div className="pt-4 border-t border-gray-200">
|
||
<h3 className="text-sm font-medium text-gray-900 mb-2">操作说明</h3>
|
||
<div className="text-xs text-gray-500 space-y-1">
|
||
<div>ESC - 关闭</div>
|
||
<div>← → - 切换图片</div>
|
||
<div className="md:hidden">左右滑动 - 切换图片</div>
|
||
<div className="hidden md:block">拖拽图片 - 切换图片</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|