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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import InlineVideoPlayer from "@/components/inline-video-player";
|
import InlineVideoPlayer from "@/components/inline-video-player";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { X, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
interface FileSystemItem {
|
interface FileSystemItem {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -27,6 +29,10 @@ const FolderViewerPage = () => {
|
||||||
const [selectedVideo, setSelectedVideo] = useState<FileSystemItem | null>(null);
|
const [selectedVideo, setSelectedVideo] = useState<FileSystemItem | null>(null);
|
||||||
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
|
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
|
||||||
const [isVideoLoading, setIsVideoLoading] = 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(() => {
|
useEffect(() => {
|
||||||
if (path) {
|
if (path) {
|
||||||
|
|
@ -81,6 +87,41 @@ const FolderViewerPage = () => {
|
||||||
setSelectedVideo(null);
|
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) {
|
if (!path) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<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">
|
<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) => (
|
{items.map((item) => (
|
||||||
<div key={item.name}
|
<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}` : '#'}
|
<Link href={item.isDirectory ? `/folder-viewer?path=${item.path}` : '#'}
|
||||||
className="block"
|
className="block"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (item.type === 'video' && item.id) {
|
if (item.type === 'video' && item.id) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleVideoClick(item);
|
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">
|
<div className="aspect-square relative overflow-hidden">
|
||||||
|
|
@ -156,7 +201,7 @@ const FolderViewerPage = () => {
|
||||||
) : isMediaFile(item) ? (
|
) : isMediaFile(item) ? (
|
||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
<img
|
<img
|
||||||
src={item.thumbnail || '/placeholder.svg'}
|
src={item.thumbnail || (item.type === 'video' ? '/placeholder-video.svg' : '/placeholder-photo.svg')}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
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"
|
||||||
/>
|
/>
|
||||||
|
|
@ -227,6 +272,70 @@ const FolderViewerPage = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Inline Video Player - Rendered as Portal */}
|
||||||
{selectedVideo && isPlayerOpen && typeof window !== 'undefined' && createPortal(
|
{selectedVideo && isPlayerOpen && typeof window !== 'undefined' && createPortal(
|
||||||
<InlineVideoPlayer
|
<InlineVideoPlayer
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export default function PhotosPage() {
|
||||||
const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(null);
|
const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(null);
|
||||||
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
||||||
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
||||||
|
const [isPhotoLoading, setIsPhotoLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPhotos();
|
fetchPhotos();
|
||||||
|
|
@ -61,11 +62,13 @@ export default function PhotosPage() {
|
||||||
setSelectedPhoto(photo);
|
setSelectedPhoto(photo);
|
||||||
setCurrentPhotoIndex(index);
|
setCurrentPhotoIndex(index);
|
||||||
setIsViewerOpen(true);
|
setIsViewerOpen(true);
|
||||||
|
setIsPhotoLoading(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseViewer = () => {
|
const handleCloseViewer = () => {
|
||||||
setIsViewerOpen(false);
|
setIsViewerOpen(false);
|
||||||
setSelectedPhoto(null);
|
setSelectedPhoto(null);
|
||||||
|
setIsPhotoLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNextPhoto = () => {
|
const handleNextPhoto = () => {
|
||||||
|
|
@ -73,6 +76,7 @@ export default function PhotosPage() {
|
||||||
const nextIndex = (currentPhotoIndex + 1) % filteredPhotos.length;
|
const nextIndex = (currentPhotoIndex + 1) % filteredPhotos.length;
|
||||||
setCurrentPhotoIndex(nextIndex);
|
setCurrentPhotoIndex(nextIndex);
|
||||||
setSelectedPhoto(filteredPhotos[nextIndex]);
|
setSelectedPhoto(filteredPhotos[nextIndex]);
|
||||||
|
setIsPhotoLoading(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -81,6 +85,7 @@ export default function PhotosPage() {
|
||||||
const prevIndex = (currentPhotoIndex - 1 + filteredPhotos.length) % filteredPhotos.length;
|
const prevIndex = (currentPhotoIndex - 1 + filteredPhotos.length) % filteredPhotos.length;
|
||||||
setCurrentPhotoIndex(prevIndex);
|
setCurrentPhotoIndex(prevIndex);
|
||||||
setSelectedPhoto(filteredPhotos[prevIndex]);
|
setSelectedPhoto(filteredPhotos[prevIndex]);
|
||||||
|
setIsPhotoLoading(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -181,11 +186,11 @@ export default function PhotosPage() {
|
||||||
>
|
>
|
||||||
<div className="aspect-square relative overflow-hidden bg-muted">
|
<div className="aspect-square relative overflow-hidden bg-muted">
|
||||||
<img
|
<img
|
||||||
src={photo.thumbnail || "/placeholder.svg"}
|
src={photo.thumbnail || "/placeholder-photo.svg"}
|
||||||
alt={photo.title}
|
alt={photo.title}
|
||||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
onError={(e) => {
|
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" />
|
<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 */}
|
{/* Photo Viewer Modal */}
|
||||||
{selectedPhoto && isViewerOpen && typeof window !== 'undefined' && createPortal(
|
{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="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">
|
<div className="absolute top-4 right-4 z-10">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
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
|
<img
|
||||||
src={selectedPhoto.thumbnail}
|
src={`/api/photos/${selectedPhoto.id}`}
|
||||||
alt={selectedPhoto.title}
|
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 */}
|
{/* Photo Info Bar */}
|
||||||
|
|
|
||||||
|
|
@ -130,11 +130,11 @@ const VideosPage = () => {
|
||||||
>
|
>
|
||||||
<div className="aspect-video relative overflow-hidden bg-muted">
|
<div className="aspect-video relative overflow-hidden bg-muted">
|
||||||
<img
|
<img
|
||||||
src={video.thumbnail || "/placeholder.svg"}
|
src={video.thumbnail || "/placeholder-video.svg"}
|
||||||
alt={video.title}
|
alt={video.title}
|
||||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
onError={(e) => {
|
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" />
|
<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