feat: enhance media retrieval and bookmarks functionality
- Updated the media retrieval query to include bookmark counts and average ratings for photos. - Refactored the bookmarks page to support both videos and photos, improving state management and UI consistency. - Added search functionality to the photos page, allowing users to filter photos by title or path. - Implemented a photo viewer modal for enhanced viewing experience, including navigation and bookmarking features.
This commit is contained in:
parent
89bb05d3fc
commit
0ae51402f6
|
|
@ -2,6 +2,27 @@ import { NextResponse } from 'next/server';
|
||||||
import db from '@/db';
|
import db from '@/db';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const photos = db.prepare('SELECT * FROM media WHERE type = ?').all('photo');
|
const photos = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
m.*,
|
||||||
|
COALESCE(b.bookmark_count, 0) as bookmark_count,
|
||||||
|
COALESCE(s.avg_rating, 0) as avg_rating,
|
||||||
|
COALESCE(s.star_count, 0) as star_count
|
||||||
|
FROM media m
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT media_id, COUNT(*) as bookmark_count
|
||||||
|
FROM bookmarks
|
||||||
|
GROUP BY media_id
|
||||||
|
) b ON m.id = b.media_id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT
|
||||||
|
media_id,
|
||||||
|
AVG(rating) as avg_rating,
|
||||||
|
COUNT(*) as star_count
|
||||||
|
FROM stars
|
||||||
|
GROUP BY media_id
|
||||||
|
) s ON m.id = s.media_id
|
||||||
|
WHERE m.type = ?
|
||||||
|
`).all('photo');
|
||||||
return NextResponse.json(photos);
|
return NextResponse.json(photos);
|
||||||
}
|
}
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import InlineVideoPlayer from "@/components/inline-video-player";
|
import InlineVideoPlayer from "@/components/inline-video-player";
|
||||||
import { Bookmark, Heart, Star, Film } from 'lucide-react';
|
import { Bookmark, Heart, Star, Film, Image as ImageIcon } from 'lucide-react';
|
||||||
|
|
||||||
interface Video {
|
interface MediaItem {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
|
@ -19,23 +19,23 @@ interface Video {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BookmarksPage() {
|
export default function BookmarksPage() {
|
||||||
const [videos, setVideos] = useState<Video[]>([]);
|
const [items, setItems] = useState<MediaItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
|
const [selectedItem, setSelectedItem] = useState<MediaItem | null>(null);
|
||||||
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
|
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
|
||||||
const [scrollPosition, setScrollPosition] = useState(0);
|
const [scrollPosition, setScrollPosition] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchBookmarkedVideos();
|
fetchBookmarkedItems();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchBookmarkedVideos = async () => {
|
const fetchBookmarkedItems = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/bookmarks');
|
const response = await fetch('/api/bookmarks');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setVideos(data);
|
setItems(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching bookmarked videos:', error);
|
console.error('Error fetching bookmarked items:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -49,16 +49,16 @@ export default function BookmarksPage() {
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVideoClick = (video: Video) => {
|
const handleItemClick = (item: MediaItem) => {
|
||||||
setScrollPosition(window.scrollY);
|
setScrollPosition(window.scrollY);
|
||||||
setSelectedVideo(video);
|
setSelectedItem(item);
|
||||||
setIsPlayerOpen(true);
|
setIsPlayerOpen(true);
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClosePlayer = () => {
|
const handleClosePlayer = () => {
|
||||||
setIsPlayerOpen(false);
|
setIsPlayerOpen(false);
|
||||||
setSelectedVideo(null);
|
setSelectedItem(null);
|
||||||
// Restore scroll position
|
// Restore scroll position
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.scrollTo({ top: scrollPosition, behavior: 'smooth' });
|
window.scrollTo({ top: scrollPosition, behavior: 'smooth' });
|
||||||
|
|
@ -70,7 +70,7 @@ export default function BookmarksPage() {
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||||
<p className="text-muted-foreground">Loading bookmarked videos...</p>
|
<p className="text-muted-foreground">Loading bookmarked items...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -85,15 +85,15 @@ export default function BookmarksPage() {
|
||||||
<Bookmark className="h-6 w-6 text-white" />
|
<Bookmark className="h-6 w-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-white">Bookmarked Videos</h1>
|
<h1 className="text-3xl font-bold text-white">Bookmarked Items</h1>
|
||||||
<p className="text-zinc-400 mt-1">
|
<p className="text-zinc-400 mt-1">
|
||||||
{videos.length} {videos.length === 1 ? 'video' : 'videos'} bookmarked
|
{items.length} {items.length === 1 ? 'item' : 'items'} bookmarked
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{videos.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center min-h-[60vh]">
|
<div className="flex flex-col items-center justify-center min-h-[60vh]">
|
||||||
<div className="text-center max-w-md">
|
<div className="text-center max-w-md">
|
||||||
<div className="w-20 h-20 bg-zinc-900 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg">
|
<div className="w-20 h-20 bg-zinc-900 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg">
|
||||||
|
|
@ -101,29 +101,33 @@ export default function BookmarksPage() {
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold text-white mb-2">No Bookmarks Yet</h2>
|
<h2 className="text-2xl font-bold text-white mb-2">No Bookmarks Yet</h2>
|
||||||
<p className="text-zinc-400">
|
<p className="text-zinc-400">
|
||||||
Start bookmarking videos by clicking the bookmark icon in the video player.
|
Start bookmarking videos and photos by clicking the bookmark icon.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<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-4">
|
<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-4">
|
||||||
{videos.map((video) => (
|
{items.map((item) => (
|
||||||
<Card
|
<Card
|
||||||
key={video.id}
|
key={item.id}
|
||||||
className="group bg-white dark:bg-slate-800 border-0 shadow-sm hover:shadow-lg transition-all duration-300 hover:-translate-y-1 overflow-hidden cursor-pointer"
|
className="group bg-white dark:bg-slate-800 border-0 shadow-sm hover:shadow-lg transition-all duration-300 hover:-translate-y-1 overflow-hidden cursor-pointer"
|
||||||
onClick={() => handleVideoClick(video)}
|
onClick={() => handleItemClick(item)}
|
||||||
>
|
>
|
||||||
<CardHeader className="p-0">
|
<CardHeader className="p-0">
|
||||||
<div className="aspect-video bg-slate-200 dark:bg-slate-700 relative overflow-hidden">
|
<div className={`relative overflow-hidden ${item.type === 'photo' ? 'aspect-square' : 'aspect-video'} bg-slate-200 dark:bg-slate-700`}>
|
||||||
{video.thumbnail ? (
|
{item.thumbnail ? (
|
||||||
<img
|
<img
|
||||||
src={video.thumbnail}
|
src={item.thumbnail}
|
||||||
alt={video.title}
|
alt={item.title}
|
||||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-slate-200 to-slate-300 dark:from-slate-700 dark:to-slate-800">
|
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-slate-200 to-slate-300 dark:from-slate-700 dark:to-slate-800">
|
||||||
<Film className="w-12 h-12 text-slate-400" />
|
{item.type === 'photo' ? (
|
||||||
|
<ImageIcon className="w-12 h-12 text-slate-400" />
|
||||||
|
) : (
|
||||||
|
<Film className="w-12 h-12 text-slate-400" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<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" />
|
<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" />
|
||||||
|
|
@ -131,26 +135,34 @@ export default function BookmarksPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-3">
|
<CardContent className="p-3">
|
||||||
<CardTitle className="text-sm font-semibold text-slate-900 dark:text-slate-100 truncate mb-1">
|
<CardTitle className="text-sm font-semibold text-slate-900 dark:text-slate-100 truncate mb-1">
|
||||||
{video.title || video.path.split('/').pop()}
|
{item.title || item.path.split('/').pop()}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-xs text-slate-600 dark:text-slate-400">
|
<CardDescription className="text-xs text-slate-600 dark:text-slate-400">
|
||||||
{formatFileSize(video.size)}
|
{formatFileSize(item.size)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="flex items-center gap-3 mt-2 text-xs text-slate-500 dark:text-slate-400">
|
<div className="flex items-center gap-3 mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Heart className="h-3 w-3" />
|
<Heart className="h-3 w-3" />
|
||||||
<span>{video.bookmark_count || 0}</span>
|
<span>{item.bookmark_count || 0}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Star className="h-3 w-3" />
|
<Star className="h-3 w-3" />
|
||||||
<span>{video.avg_rating?.toFixed(1) || '0.0'}</span>
|
<span>{item.avg_rating?.toFixed(1) || '0.0'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1 truncate">
|
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1 truncate">
|
||||||
{video.library_path?.split('/').pop()}
|
{item.type === 'photo' ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<ImageIcon className="h-3 w-3" /> {item.library_path?.split('/').pop()}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Film className="h-3 w-3" /> {item.library_path?.split('/').pop()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -160,14 +172,14 @@ export default function BookmarksPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Inline Video Player */}
|
{/* Inline Video Player */}
|
||||||
{selectedVideo && (
|
{selectedItem && selectedItem.type === 'video' && (
|
||||||
<InlineVideoPlayer
|
<InlineVideoPlayer
|
||||||
video={{
|
video={{
|
||||||
id: selectedVideo.id,
|
id: selectedItem.id,
|
||||||
title: selectedVideo.title || selectedVideo.path.split('/').pop() || 'Untitled',
|
title: selectedItem.title || selectedItem.path.split('/').pop() || 'Untitled',
|
||||||
path: selectedVideo.path,
|
path: selectedItem.path,
|
||||||
size: selectedVideo.size,
|
size: selectedItem.size,
|
||||||
thumbnail: selectedVideo.thumbnail || '',
|
thumbnail: selectedItem.thumbnail || '',
|
||||||
}}
|
}}
|
||||||
isOpen={isPlayerOpen}
|
isOpen={isPlayerOpen}
|
||||||
onClose={handleClosePlayer}
|
onClose={handleClosePlayer}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Image as ImageIcon, Search, Filter, Star, Bookmark, HardDrive, X, ZoomIn, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Image as ImageIcon } from 'lucide-react';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
interface Photo {
|
interface Photo {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -13,11 +15,18 @@ interface Photo {
|
||||||
size: number;
|
size: number;
|
||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
bookmark_count: number;
|
||||||
|
avg_rating: number;
|
||||||
|
star_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PhotosPage() {
|
export default function PhotosPage() {
|
||||||
const [photos, setPhotos] = useState<Photo[]>([]);
|
const [photos, setPhotos] = useState<Photo[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
fetchPhotos();
|
fetchPhotos();
|
||||||
|
|
@ -43,88 +52,302 @@ export default function PhotosPage() {
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
|
<div className="min-h-screen p-6">
|
||||||
<div className="text-center">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="w-16 h-16 bg-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 animate-pulse shadow-lg shadow-purple-600/20">
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
<ImageIcon className="h-8 w-8 text-white" />
|
<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>
|
||||||
<p className="text-zinc-400 font-medium">Loading photos...</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-950">
|
<>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="min-h-screen p-6">
|
||||||
<div className="mb-8">
|
<div className="max-w-7xl mx-auto">
|
||||||
<h1 className="text-5xl font-bold text-white tracking-tight mb-2">
|
{/* Header */}
|
||||||
Photos
|
<div className="mb-8">
|
||||||
</h1>
|
<div className="flex items-center gap-4 mb-4">
|
||||||
<p className="text-zinc-400 text-lg">
|
<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">
|
||||||
{photos.length} {photos.length === 1 ? 'photo' : 'photos'} found
|
<ImageIcon className="h-6 w-6 text-white" />
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{photos.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">
|
|
||||||
{photos.map((photo) => (
|
|
||||||
<div key={photo.id}
|
|
||||||
className="group relative bg-zinc-900 rounded-lg overflow-hidden cursor-pointer transition-all duration-300 hover:scale-105 hover:shadow-2xl hover:shadow-purple-600/20">
|
|
||||||
<div className="aspect-square relative overflow-hidden">
|
|
||||||
<img
|
|
||||||
src={photo.thumbnail}
|
|
||||||
alt={photo.title}
|
|
||||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
|
||||||
onError={(e) => {
|
|
||||||
(e.target as HTMLImageElement).src = '/placeholder-image.jpg';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
|
||||||
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-4 transform translate-y-full group-hover:translate-y-0 transition-transform duration-300">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-8 h-8 bg-purple-600 rounded-full flex items-center justify-center">
|
|
||||||
<ImageIcon className="h-4 w-4 text-white" />
|
|
||||||
</div>
|
|
||||||
<span className="text-white text-sm font-medium">View</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-4">
|
|
||||||
<p className="text-white font-medium text-sm line-clamp-2 mb-1">
|
|
||||||
{photo.title}
|
|
||||||
</p>
|
|
||||||
<p className="text-zinc-400 text-xs">
|
|
||||||
{formatFileSize(photo.size)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div>
|
||||||
</div>
|
<h1 className="text-3xl font-bold text-foreground tracking-tight">
|
||||||
) : (
|
Photos
|
||||||
<div className="text-center py-20">
|
</h1>
|
||||||
<div className="max-w-sm mx-auto">
|
<p className="text-muted-foreground">
|
||||||
<div className="w-20 h-20 bg-zinc-900 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
|
{photos.length} {photos.length === 1 ? 'photo' : 'photos'} in your library
|
||||||
<ImageIcon className="h-10 w-10 text-purple-500" />
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-semibold text-white mb-2">
|
</div>
|
||||||
No Photos Found
|
|
||||||
</h3>
|
{/* Search and Filter Bar */}
|
||||||
<p className="text-zinc-400 mb-6">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
Add media libraries to scan for photos
|
<div className="relative flex-1">
|
||||||
</p>
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Link href="/settings">
|
<Input
|
||||||
<button className="bg-purple-600 hover:bg-purple-700 text-white px-6 py-3 rounded-lg text-sm font-medium transition-colors shadow-lg shadow-purple-600/20">
|
placeholder="Search photos..."
|
||||||
Add Library
|
value={searchTerm}
|
||||||
</button>
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
</Link>
|
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>
|
||||||
</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.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.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>
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* Photo Viewer Modal */}
|
||||||
|
{selectedPhoto && isViewerOpen && typeof window !== 'undefined' && createPortal(
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center" onClick={handleCloseViewer}>
|
||||||
|
<div className="relative max-w-7xl max-h-full p-4" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="absolute top-4 right-4 z-10">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleCloseViewer}
|
||||||
|
className="bg-black/50 backdrop-blur-sm hover:bg-black/70"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Arrows */}
|
||||||
|
{filteredPhotos.length > 1 && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
onClick={handlePrevPhoto}
|
||||||
|
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 bg-black/50 backdrop-blur-sm hover:bg-black/70"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleNextPhoto}
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 bg-black/50 backdrop-blur-sm hover:bg-black/70"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={selectedPhoto.thumbnail}
|
||||||
|
alt={selectedPhoto.title}
|
||||||
|
className="max-w-full max-h-[80vh] object-contain rounded-lg"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Photo Info Bar */}
|
||||||
|
<div className="absolute bottom-4 left-4 right-4 bg-black/70 backdrop-blur-sm rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-medium">{selectedPhoto.title}</h3>
|
||||||
|
<p className="text-gray-300 text-sm">{formatFileSize(selectedPhoto.size)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => selectedPhoto.bookmark_count > 0 ? handleUnbookmark(selectedPhoto.id) : handleBookmark(selectedPhoto.id)}
|
||||||
|
className="flex items-center gap-1 text-white hover:text-yellow-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Bookmark className={`h-4 w-4 ${selectedPhoto.bookmark_count > 0 ? 'fill-yellow-400 text-yellow-400' : ''}`} />
|
||||||
|
</button>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((rating) => (
|
||||||
|
<button
|
||||||
|
key={rating}
|
||||||
|
onClick={() => handleRate(selectedPhoto.id, rating)}
|
||||||
|
className="text-white hover:text-yellow-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Star className={`h-4 w-4 ${rating <= Math.round(selectedPhoto.avg_rating) ? 'fill-yellow-400 text-yellow-400' : ''}`} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue