293 lines
11 KiB
TypeScript
293 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import Link from 'next/link';
|
|
import { Image as ImageIcon, Search, Filter, Star, Bookmark, HardDrive } from 'lucide-react';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import PhotoViewer from '@/components/photo-viewer';
|
|
|
|
interface Photo {
|
|
id: number;
|
|
path: string;
|
|
title: string;
|
|
size: number;
|
|
thumbnail: string;
|
|
type: string;
|
|
bookmark_count: number;
|
|
avg_rating: number;
|
|
star_count: number;
|
|
}
|
|
|
|
export default function PhotosPage() {
|
|
const [photos, setPhotos] = useState<Photo[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(null);
|
|
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
|
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetchPhotos();
|
|
}, []);
|
|
|
|
const fetchPhotos = async () => {
|
|
try {
|
|
const response = await fetch('/api/photos');
|
|
const data = await response.json();
|
|
setPhotos(data);
|
|
} catch (error) {
|
|
console.error('Error fetching photos:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const formatFileSize = (bytes: number) => {
|
|
if (bytes === 0) return '0 Bytes';
|
|
const k = 1024;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
};
|
|
|
|
const filteredPhotos = photos.filter(photo =>
|
|
photo.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
photo.path.toLowerCase().includes(searchTerm.toLowerCase())
|
|
);
|
|
|
|
const handlePhotoClick = (photo: Photo, index: number) => {
|
|
setSelectedPhoto(photo);
|
|
setCurrentPhotoIndex(index);
|
|
setIsViewerOpen(true);
|
|
};
|
|
|
|
const handleCloseViewer = () => {
|
|
setIsViewerOpen(false);
|
|
setSelectedPhoto(null);
|
|
};
|
|
|
|
const handleNextPhoto = () => {
|
|
if (filteredPhotos.length > 0) {
|
|
const nextIndex = (currentPhotoIndex + 1) % filteredPhotos.length;
|
|
setCurrentPhotoIndex(nextIndex);
|
|
setSelectedPhoto(filteredPhotos[nextIndex]);
|
|
}
|
|
};
|
|
|
|
const handlePrevPhoto = () => {
|
|
if (filteredPhotos.length > 0) {
|
|
const prevIndex = (currentPhotoIndex - 1 + filteredPhotos.length) % filteredPhotos.length;
|
|
setCurrentPhotoIndex(prevIndex);
|
|
setSelectedPhoto(filteredPhotos[prevIndex]);
|
|
}
|
|
};
|
|
|
|
const handleBookmark = async (photoId: number) => {
|
|
try {
|
|
await fetch(`/api/bookmarks/${photoId}`, { method: 'POST' });
|
|
fetchPhotos();
|
|
} catch (error) {
|
|
console.error('Error bookmarking photo:', error);
|
|
}
|
|
};
|
|
|
|
const handleUnbookmark = async (photoId: number) => {
|
|
try {
|
|
await fetch(`/api/bookmarks/${photoId}`, { method: 'DELETE' });
|
|
fetchPhotos();
|
|
} catch (error) {
|
|
console.error('Error unbookmarking photo:', error);
|
|
}
|
|
};
|
|
|
|
const handleRate = async (photoId: number, rating: number) => {
|
|
try {
|
|
await fetch(`/api/stars/${photoId}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ rating })
|
|
});
|
|
fetchPhotos();
|
|
} catch (error) {
|
|
console.error('Error rating photo:', error);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen p-6">
|
|
<div className="max-w-7xl mx-auto">
|
|
<div className="flex items-center justify-center min-h-[60vh]">
|
|
<div className="text-center">
|
|
<div className="w-16 h-16 bg-gradient-to-br from-primary to-primary/80 rounded-2xl flex items-center justify-center mx-auto mb-4 animate-pulse shadow-lg">
|
|
<ImageIcon className="h-8 w-8 text-primary-foreground" />
|
|
</div>
|
|
<p className="text-muted-foreground font-medium">Loading photos...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="min-h-screen p-6">
|
|
<div className="max-w-7xl mx-auto">
|
|
{/* Header */}
|
|
<div className="mb-8">
|
|
<div className="flex items-center gap-4 mb-4">
|
|
<div className="w-12 h-12 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center shadow-lg">
|
|
<ImageIcon className="h-6 w-6 text-white" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-foreground tracking-tight">
|
|
Photos
|
|
</h1>
|
|
<p className="text-muted-foreground">
|
|
{photos.length} {photos.length === 1 ? 'photo' : 'photos'} in your library
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search and Filter Bar */}
|
|
<div className="flex flex-col sm:flex-row gap-4">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search photos..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="pl-10 bg-background border-border"
|
|
/>
|
|
</div>
|
|
<Button variant="outline" className="shrink-0">
|
|
<Filter className="h-4 w-4 mr-2" />
|
|
Filter
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Photos Grid */}
|
|
{filteredPhotos.length > 0 ? (
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-6">
|
|
{filteredPhotos.map((photo, index) => (
|
|
<Card
|
|
key={photo.id}
|
|
className="group hover:shadow-lg transition-all duration-300 hover:-translate-y-1 cursor-pointer border-border overflow-hidden"
|
|
onClick={() => handlePhotoClick(photo, index)}
|
|
>
|
|
<div className="aspect-square relative overflow-hidden bg-muted">
|
|
<img
|
|
src={photo.thumbnail || "/placeholder-photo.svg"}
|
|
alt={photo.title}
|
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
onError={(e) => {
|
|
(e.target as HTMLImageElement).src = '/placeholder-photo.svg';
|
|
}}
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
|
|
|
{/* Photo Type Badge */}
|
|
<div className="absolute top-2 right-2">
|
|
<div className="bg-black/70 backdrop-blur-sm rounded-full px-2 py-1">
|
|
<ImageIcon className="h-3 w-3 text-white" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bookmark and Rating Overlay */}
|
|
<div className="absolute top-2 left-2 flex gap-2">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
photo.bookmark_count > 0 ? handleUnbookmark(photo.id) : handleBookmark(photo.id);
|
|
}}
|
|
className="bg-black/70 backdrop-blur-sm rounded-full p-1.5 hover:bg-black/90 transition-colors"
|
|
>
|
|
<Bookmark className={`h-3 w-3 ${photo.bookmark_count > 0 ? 'fill-yellow-400 text-yellow-400' : 'text-white'}`} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Rating Stars */}
|
|
{photo.avg_rating > 0 && (
|
|
<div className="absolute bottom-2 right-2 flex gap-0.5 bg-black/70 backdrop-blur-sm rounded-full px-1.5 py-1">
|
|
{[...Array(5)].map((_, i) => (
|
|
<Star key={i} className={`h-2.5 w-2.5 ${i < Math.round(photo.avg_rating) ? 'fill-yellow-400 text-yellow-400' : 'text-gray-400'}`} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<CardContent className="p-3">
|
|
<h3 className="font-medium text-foreground text-sm line-clamp-2 mb-1 group-hover:text-primary transition-colors">
|
|
{photo.title}
|
|
</h3>
|
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
<div className="flex items-center gap-1">
|
|
<HardDrive className="h-3 w-3" />
|
|
<span>{formatFileSize(photo.size)}</span>
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">
|
|
{photo.path}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
) : searchTerm ? (
|
|
<div className="text-center py-20">
|
|
<div className="max-w-sm mx-auto">
|
|
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center mx-auto mb-4">
|
|
<Search className="h-8 w-8 text-muted-foreground" />
|
|
</div>
|
|
<h3 className="text-xl font-semibold text-foreground mb-2">No photos found</h3>
|
|
<p className="text-muted-foreground mb-4">Try adjusting your search terms</p>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setSearchTerm('')}
|
|
>
|
|
Clear search
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-20">
|
|
<div className="max-w-sm mx-auto">
|
|
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center mx-auto mb-4">
|
|
<ImageIcon className="h-8 w-8 text-muted-foreground" />
|
|
</div>
|
|
<h3 className="text-xl font-semibold text-foreground mb-2">No Photos Found</h3>
|
|
<p className="text-muted-foreground mb-6">Add media libraries and scan for photos to get started</p>
|
|
<Link href="/settings">
|
|
<Button>
|
|
<ImageIcon className="h-4 w-4 mr-2" />
|
|
Add Library
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Photo Viewer */}
|
|
<PhotoViewer
|
|
photo={selectedPhoto!}
|
|
isOpen={isViewerOpen}
|
|
onClose={handleCloseViewer}
|
|
onNext={handleNextPhoto}
|
|
onPrev={handlePrevPhoto}
|
|
showNavigation={filteredPhotos.length > 1}
|
|
showBookmarks={true}
|
|
showRatings={true}
|
|
formatFileSize={formatFileSize}
|
|
onBookmark={handleBookmark}
|
|
onUnbookmark={handleUnbookmark}
|
|
onRate={handleRate}
|
|
/>
|
|
</>
|
|
);
|
|
} |