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:
tigeren 2025-08-26 17:42:52 +00:00
parent 0ae51402f6
commit 444f6288fe
5 changed files with 195 additions and 9 deletions

6
public/placeholder.svg Normal file
View File

@ -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

View File

@ -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 });
}
}

View File

@ -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

View File

@ -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 */}

View File

@ -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" />