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

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

208 lines
7.1 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 { 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>
)
}