feat: implement photo viewer and enhance media handling
- Added a new SVG placeholder for photos and updated the photos page to utilize it. - Implemented a photo viewer modal with navigation capabilities, allowing users to view photos in a dedicated interface. - Enhanced folder viewer to support photo selection and viewing, including loading indicators and improved UI for photo items. - Updated error handling and content type determination for photo retrieval in the API.
This commit is contained in:
parent
0ae51402f6
commit
444f6288fe
|
|
@ -0,0 +1,6 @@
|
|||
<svg width="320" height="240" viewBox="0 0 320 240" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="320" height="240" fill="#f3f4f6"/>
|
||||
<path d="M160 120m-40 0a40 40 0 1 0 80 0a40 40 0 1 0 -80 0" stroke="#9ca3af" stroke-width="2" fill="none"/>
|
||||
<path d="M120 80h80v80h-80z" stroke="#9ca3af" stroke-width="2" fill="none"/>
|
||||
<circle cx="140" cy="100" r="8" fill="#9ca3af"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 397 B |
|
|
@ -0,0 +1,59 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import db from "@/db";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const photoId = parseInt(id);
|
||||
|
||||
if (isNaN(photoId)) {
|
||||
return NextResponse.json({ error: "Invalid photo ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
const photo = db.prepare("SELECT * FROM media WHERE id = ? AND type = 'photo'").get(photoId) as { path: string, title: string } | undefined;
|
||||
|
||||
if (!photo) {
|
||||
return NextResponse.json({ error: "Photo not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const photoPath = photo.path;
|
||||
|
||||
if (!fs.existsSync(photoPath)) {
|
||||
return NextResponse.json({ error: "Photo file not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const stat = fs.statSync(photoPath);
|
||||
const fileSize = stat.size;
|
||||
const ext = path.extname(photoPath).toLowerCase();
|
||||
|
||||
// Determine content type based on file extension
|
||||
let contentType = 'image/jpeg'; // default
|
||||
if (ext === '.png') contentType = 'image/png';
|
||||
else if (ext === '.gif') contentType = 'image/gif';
|
||||
else if (ext === '.webp') contentType = 'image/webp';
|
||||
else if (ext === '.bmp') contentType = 'image/bmp';
|
||||
else if (ext === '.tiff' || ext === '.tif') contentType = 'image/tiff';
|
||||
else if (ext === '.svg') contentType = 'image/svg+xml';
|
||||
|
||||
const headers = new Headers({
|
||||
"Content-Length": fileSize.toString(),
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=31536000", // Cache for 1 year
|
||||
});
|
||||
|
||||
const file = fs.createReadStream(photoPath);
|
||||
|
||||
return new Response(file as any, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error serving photo:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ import Link from "next/link";
|
|||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import InlineVideoPlayer from "@/components/inline-video-player";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
interface FileSystemItem {
|
||||
name: string;
|
||||
|
|
@ -27,6 +29,10 @@ const FolderViewerPage = () => {
|
|||
const [selectedVideo, setSelectedVideo] = useState<FileSystemItem | null>(null);
|
||||
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
|
||||
const [isVideoLoading, setIsVideoLoading] = useState(false);
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<FileSystemItem | null>(null);
|
||||
const [isPhotoViewerOpen, setIsPhotoViewerOpen] = useState(false);
|
||||
const [isPhotoLoading, setIsPhotoLoading] = useState(false);
|
||||
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (path) {
|
||||
|
|
@ -81,6 +87,41 @@ const FolderViewerPage = () => {
|
|||
setSelectedVideo(null);
|
||||
};
|
||||
|
||||
const handlePhotoClick = (item: FileSystemItem, index: number) => {
|
||||
if (item.type === 'photo' && item.id) {
|
||||
setSelectedPhoto(item);
|
||||
setCurrentPhotoIndex(index);
|
||||
setIsPhotoViewerOpen(true);
|
||||
setIsPhotoLoading(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClosePhotoViewer = () => {
|
||||
setIsPhotoViewerOpen(false);
|
||||
setSelectedPhoto(null);
|
||||
setIsPhotoLoading(false);
|
||||
};
|
||||
|
||||
const handleNextPhoto = () => {
|
||||
const photoItems = items.filter(item => item.type === 'photo' && item.id);
|
||||
if (photoItems.length > 0) {
|
||||
const nextIndex = (currentPhotoIndex + 1) % photoItems.length;
|
||||
setCurrentPhotoIndex(nextIndex);
|
||||
setSelectedPhoto(photoItems[nextIndex]);
|
||||
setIsPhotoLoading(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevPhoto = () => {
|
||||
const photoItems = items.filter(item => item.type === 'photo' && item.id);
|
||||
if (photoItems.length > 0) {
|
||||
const prevIndex = (currentPhotoIndex - 1 + photoItems.length) % photoItems.length;
|
||||
setCurrentPhotoIndex(prevIndex);
|
||||
setSelectedPhoto(photoItems[prevIndex]);
|
||||
setIsPhotoLoading(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (!path) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
|
|
@ -136,13 +177,17 @@ const FolderViewerPage = () => {
|
|||
<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">
|
||||
{items.map((item) => (
|
||||
<div key={item.name}
|
||||
className={`group relative bg-white dark:bg-slate-800 rounded-xl shadow-sm hover:shadow-lg transition-all duration-300 hover:-translate-y-1 overflow-hidden ${item.type === 'video' ? 'cursor-pointer' : ''}`}>
|
||||
className={`group relative bg-white dark:bg-slate-800 rounded-xl shadow-sm hover:shadow-lg transition-all duration-300 hover:-translate-y-1 overflow-hidden ${(item.type === 'video' || item.type === 'photo') ? 'cursor-pointer' : ''}`}>
|
||||
<Link href={item.isDirectory ? `/folder-viewer?path=${item.path}` : '#'}
|
||||
className="block"
|
||||
onClick={(e) => {
|
||||
if (item.type === 'video' && item.id) {
|
||||
e.preventDefault();
|
||||
handleVideoClick(item);
|
||||
} else if (item.type === 'photo' && item.id) {
|
||||
e.preventDefault();
|
||||
const photoIndex = items.filter(i => i.type === 'photo' && i.id).findIndex(i => i.id === item.id);
|
||||
handlePhotoClick(item, photoIndex);
|
||||
}
|
||||
}}>
|
||||
<div className="aspect-square relative overflow-hidden">
|
||||
|
|
@ -156,7 +201,7 @@ const FolderViewerPage = () => {
|
|||
) : isMediaFile(item) ? (
|
||||
<div className="relative w-full h-full">
|
||||
<img
|
||||
src={item.thumbnail || '/placeholder.svg'}
|
||||
src={item.thumbnail || (item.type === 'video' ? '/placeholder-video.svg' : '/placeholder-photo.svg')}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
|
|
@ -227,6 +272,70 @@ const FolderViewerPage = () => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Photo Viewer Modal */}
|
||||
{selectedPhoto && isPhotoViewerOpen && typeof window !== 'undefined' && createPortal(
|
||||
<div className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center" onClick={handleClosePhotoViewer}>
|
||||
<div className="relative w-full h-full max-w-7xl max-h-full p-4 flex items-center justify-center" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={handleClosePhotoViewer}
|
||||
className="bg-black/50 backdrop-blur-sm hover:bg-black/70"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Navigation Arrows */}
|
||||
{items.filter(item => item.type === 'photo' && item.id).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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isPhotoLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={`/api/photos/${selectedPhoto.id}`}
|
||||
alt={selectedPhoto.name}
|
||||
className={`max-w-full max-h-[90vh] w-auto h-auto object-contain rounded-lg ${isPhotoLoading ? 'hidden' : ''}`}
|
||||
onLoad={() => setIsPhotoLoading(false)}
|
||||
onError={() => setIsPhotoLoading(false)}
|
||||
/>
|
||||
|
||||
{/* 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.name}</h3>
|
||||
<p className="text-gray-300 text-sm">{formatFileSize(selectedPhoto.size)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Inline Video Player - Rendered as Portal */}
|
||||
{selectedVideo && isPlayerOpen && typeof window !== 'undefined' && createPortal(
|
||||
<InlineVideoPlayer
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export default function PhotosPage() {
|
|||
const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(null);
|
||||
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
||||
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
||||
const [isPhotoLoading, setIsPhotoLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPhotos();
|
||||
|
|
@ -61,11 +62,13 @@ export default function PhotosPage() {
|
|||
setSelectedPhoto(photo);
|
||||
setCurrentPhotoIndex(index);
|
||||
setIsViewerOpen(true);
|
||||
setIsPhotoLoading(true);
|
||||
};
|
||||
|
||||
const handleCloseViewer = () => {
|
||||
setIsViewerOpen(false);
|
||||
setSelectedPhoto(null);
|
||||
setIsPhotoLoading(false);
|
||||
};
|
||||
|
||||
const handleNextPhoto = () => {
|
||||
|
|
@ -73,6 +76,7 @@ export default function PhotosPage() {
|
|||
const nextIndex = (currentPhotoIndex + 1) % filteredPhotos.length;
|
||||
setCurrentPhotoIndex(nextIndex);
|
||||
setSelectedPhoto(filteredPhotos[nextIndex]);
|
||||
setIsPhotoLoading(true);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -81,6 +85,7 @@ export default function PhotosPage() {
|
|||
const prevIndex = (currentPhotoIndex - 1 + filteredPhotos.length) % filteredPhotos.length;
|
||||
setCurrentPhotoIndex(prevIndex);
|
||||
setSelectedPhoto(filteredPhotos[prevIndex]);
|
||||
setIsPhotoLoading(true);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -181,11 +186,11 @@ export default function PhotosPage() {
|
|||
>
|
||||
<div className="aspect-square relative overflow-hidden bg-muted">
|
||||
<img
|
||||
src={photo.thumbnail || "/placeholder.svg"}
|
||||
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.svg';
|
||||
(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" />
|
||||
|
|
@ -276,7 +281,7 @@ export default function PhotosPage() {
|
|||
{/* 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="relative w-full h-full max-w-7xl max-h-full p-4 flex items-center justify-center" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
|
@ -310,10 +315,17 @@ export default function PhotosPage() {
|
|||
</>
|
||||
)}
|
||||
|
||||
{isPhotoLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={selectedPhoto.thumbnail}
|
||||
src={`/api/photos/${selectedPhoto.id}`}
|
||||
alt={selectedPhoto.title}
|
||||
className="max-w-full max-h-[80vh] object-contain rounded-lg"
|
||||
className={`max-w-full max-h-[90vh] w-auto h-auto object-contain rounded-lg ${isPhotoLoading ? 'hidden' : ''}`}
|
||||
onLoad={() => setIsPhotoLoading(false)}
|
||||
onError={() => setIsPhotoLoading(false)}
|
||||
/>
|
||||
|
||||
{/* Photo Info Bar */}
|
||||
|
|
|
|||
|
|
@ -130,11 +130,11 @@ const VideosPage = () => {
|
|||
>
|
||||
<div className="aspect-video relative overflow-hidden bg-muted">
|
||||
<img
|
||||
src={video.thumbnail || "/placeholder.svg"}
|
||||
src={video.thumbnail || "/placeholder-video.svg"}
|
||||
alt={video.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';
|
||||
(e.target as HTMLImageElement).src = '/placeholder-video.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" />
|
||||
|
|
|
|||
Loading…
Reference in New Issue