init ui
This commit is contained in:
185
ui/components/timeline-view.tsx
Normal file
185
ui/components/timeline-view.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Image from "next/image"
|
||||
import { TimelineStats } from "@/components/timeline-stats"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Calendar, MapPin, Camera } 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 TimelineViewProps {
|
||||
photos: Photo[]
|
||||
onPhotoClick: (photo: Photo) => void
|
||||
}
|
||||
|
||||
export function TimelineView({ photos, onPhotoClick }: TimelineViewProps) {
|
||||
const [loadedImages, setLoadedImages] = useState<Set<number>>(new Set())
|
||||
|
||||
const handleImageLoad = (photoId: number) => {
|
||||
setLoadedImages((prev) => new Set(prev).add(photoId))
|
||||
}
|
||||
|
||||
// Group photos by year and month
|
||||
const groupedPhotos = photos
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||
.reduce(
|
||||
(acc, photo) => {
|
||||
const date = new Date(photo.date)
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth()
|
||||
|
||||
if (!acc[year]) {
|
||||
acc[year] = {}
|
||||
}
|
||||
if (!acc[year][month]) {
|
||||
acc[year][month] = []
|
||||
}
|
||||
acc[year][month].push(photo)
|
||||
|
||||
return acc
|
||||
},
|
||||
{} as Record<number, Record<number, Photo[]>>,
|
||||
)
|
||||
|
||||
const monthNames = [
|
||||
"一月",
|
||||
"二月",
|
||||
"三月",
|
||||
"四月",
|
||||
"五月",
|
||||
"六月",
|
||||
"七月",
|
||||
"八月",
|
||||
"九月",
|
||||
"十月",
|
||||
"十一月",
|
||||
"十二月",
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<TimelineStats photos={photos} />
|
||||
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-8 md:left-1/2 top-0 bottom-0 w-0.5 bg-gray-200 transform md:-translate-x-0.5"></div>
|
||||
|
||||
{Object.entries(groupedPhotos).map(([year, months]) => (
|
||||
<div key={year} className="mb-16">
|
||||
{/* Year marker */}
|
||||
<div className="sticky top-20 z-10 mb-8">
|
||||
<div className="flex items-center">
|
||||
<div className="relative z-10 bg-white border-4 border-gray-200 rounded-full p-4 left-4 md:left-1/2 md:transform md:-translate-x-1/2">
|
||||
<span className="text-2xl font-light text-gray-900">{year}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Object.entries(months).map(([monthIndex, monthPhotos]) => (
|
||||
<div key={`${year}-${monthIndex}`} className="mb-12">
|
||||
{/* Month marker */}
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="relative z-10 bg-gray-100 rounded-full px-4 py-2 left-4 md:left-1/2 md:transform md:-translate-x-1/2">
|
||||
<span className="text-sm font-medium text-gray-700">{monthNames[Number.parseInt(monthIndex)]}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Photos for this month */}
|
||||
<div className="space-y-8">
|
||||
{monthPhotos.map((photo, index) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className={`relative flex items-center ${
|
||||
index % 2 === 0 ? "md:flex-row" : "md:flex-row-reverse"
|
||||
}`}
|
||||
>
|
||||
{/* Timeline dot */}
|
||||
<div className="absolute left-8 md:left-1/2 w-3 h-3 bg-gray-400 rounded-full transform -translate-x-1/2 z-10"></div>
|
||||
|
||||
{/* Photo card */}
|
||||
<div
|
||||
className={`w-full md:w-5/12 ml-16 md:ml-0 ${
|
||||
index % 2 === 0 ? "md:mr-auto md:pr-8" : "md:ml-auto md:pl-8"
|
||||
}`}
|
||||
>
|
||||
<Card
|
||||
className="group cursor-pointer overflow-hidden border-0 shadow-sm hover:shadow-xl transition-all duration-300 hover:scale-[1.02]"
|
||||
onClick={() => onPhotoClick(photo)}
|
||||
>
|
||||
<div className="md:flex">
|
||||
<div className="relative md:w-1/2 aspect-[4/3] md:aspect-auto overflow-hidden">
|
||||
{!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 ${
|
||||
loadedImages.has(photo.id) ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
onLoad={() => handleImageLoad(photo.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CardContent className="md:w-1/2 p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-light text-gray-900 mb-2">{photo.title}</h3>
|
||||
<p className="text-gray-600 text-sm leading-relaxed">{photo.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{photo.tags.slice(0, 3).map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-xs text-gray-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>{photo.date}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="h-3 w-3" />
|
||||
<span>{photo.exif.location}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Camera className="h-3 w-3" />
|
||||
<span>{photo.exif.camera}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user