From 444f6288fe479838237f56549d48db3d6df1d8aa Mon Sep 17 00:00:00 2001 From: tigeren Date: Tue, 26 Aug 2025 17:42:52 +0000 Subject: [PATCH] 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. --- public/placeholder.svg | 6 ++ src/app/api/photos/[id]/route.ts | 59 ++++++++++++++++ src/app/folder-viewer/page.tsx | 113 ++++++++++++++++++++++++++++++- src/app/photos/page.tsx | 22 ++++-- src/app/videos/page.tsx | 4 +- 5 files changed, 195 insertions(+), 9 deletions(-) create mode 100644 public/placeholder.svg create mode 100644 src/app/api/photos/[id]/route.ts diff --git a/public/placeholder.svg b/public/placeholder.svg new file mode 100644 index 0000000..4e1b65a --- /dev/null +++ b/public/placeholder.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/app/api/photos/[id]/route.ts b/src/app/api/photos/[id]/route.ts new file mode 100644 index 0000000..8c44136 --- /dev/null +++ b/src/app/api/photos/[id]/route.ts @@ -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 }); + } +} diff --git a/src/app/folder-viewer/page.tsx b/src/app/folder-viewer/page.tsx index 5388460..fdc0a4a 100644 --- a/src/app/folder-viewer/page.tsx +++ b/src/app/folder-viewer/page.tsx @@ -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(null); const [isPlayerOpen, setIsPlayerOpen] = useState(false); const [isVideoLoading, setIsVideoLoading] = useState(false); + const [selectedPhoto, setSelectedPhoto] = useState(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 (
@@ -136,13 +177,17 @@ const FolderViewerPage = () => {
{items.map((item) => (
+ 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' : ''}`}> { 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); } }}>
@@ -156,7 +201,7 @@ const FolderViewerPage = () => { ) : isMediaFile(item) ? (
{item.name} @@ -227,6 +272,70 @@ const FolderViewerPage = () => { )}
+ {/* Photo Viewer Modal */} + {selectedPhoto && isPhotoViewerOpen && typeof window !== 'undefined' && createPortal( +
+
e.stopPropagation()}> +
+ +
+ + {/* Navigation Arrows */} + {items.filter(item => item.type === 'photo' && item.id).length > 1 && ( + <> + + + + )} + + {isPhotoLoading && ( +
+
+
+ )} + {selectedPhoto.name} setIsPhotoLoading(false)} + onError={() => setIsPhotoLoading(false)} + /> + + {/* Photo Info Bar */} +
+
+
+

{selectedPhoto.name}

+

{formatFileSize(selectedPhoto.size)}

+
+
+
+
+
, + document.body + )} + {/* Inline Video Player - Rendered as Portal */} {selectedVideo && isPlayerOpen && typeof window !== 'undefined' && createPortal( (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() { >
{photo.title} { - (e.target as HTMLImageElement).src = '/placeholder.svg'; + (e.target as HTMLImageElement).src = '/placeholder-photo.svg'; }} />
@@ -276,7 +281,7 @@ export default function PhotosPage() { {/* Photo Viewer Modal */} {selectedPhoto && isViewerOpen && typeof window !== 'undefined' && createPortal(
-
e.stopPropagation()}> +
e.stopPropagation()}>