Compare commits

...

3 Commits

Author SHA1 Message Date
tigeren 2ac68f9a69 feat: refactor media viewers for improved functionality and UI
- Replaced inline video player with a dedicated VideoViewer component for enhanced video playback experience.
- Updated the PhotosPage and FolderViewerPage to utilize the new PhotoViewer and VideoViewer components, streamlining the media viewing process.
- Removed unnecessary loading states and modal implementations, simplifying the code structure and improving performance.
- Enhanced the PhotoViewer and VideoViewer with bookmarking and rating features for better user interaction.
2025-08-26 18:03:43 +00:00
tigeren 444f6288fe 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.
2025-08-26 17:42:52 +00:00
tigeren 0ae51402f6 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.
2025-08-26 16:18:01 +00:00
10 changed files with 988 additions and 141 deletions

BIN
media.db

Binary file not shown.

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

@ -2,6 +2,27 @@ import { NextResponse } from 'next/server';
import db from '@/db';
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);
}

View File

@ -3,9 +3,9 @@
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
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;
title: string;
path: string;
@ -19,23 +19,23 @@ interface Video {
}
export default function BookmarksPage() {
const [videos, setVideos] = useState<Video[]>([]);
const [items, setItems] = useState<MediaItem[]>([]);
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 [scrollPosition, setScrollPosition] = useState(0);
useEffect(() => {
fetchBookmarkedVideos();
fetchBookmarkedItems();
}, []);
const fetchBookmarkedVideos = async () => {
const fetchBookmarkedItems = async () => {
try {
const response = await fetch('/api/bookmarks');
const data = await response.json();
setVideos(data);
setItems(data);
} catch (error) {
console.error('Error fetching bookmarked videos:', error);
console.error('Error fetching bookmarked items:', error);
} finally {
setLoading(false);
}
@ -49,16 +49,16 @@ export default function BookmarksPage() {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const handleVideoClick = (video: Video) => {
const handleItemClick = (item: MediaItem) => {
setScrollPosition(window.scrollY);
setSelectedVideo(video);
setSelectedItem(item);
setIsPlayerOpen(true);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleClosePlayer = () => {
setIsPlayerOpen(false);
setSelectedVideo(null);
setSelectedItem(null);
// Restore scroll position
setTimeout(() => {
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="text-center">
<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>
);
@ -85,15 +85,15 @@ export default function BookmarksPage() {
<Bookmark className="h-6 w-6 text-white" />
</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">
{videos.length} {videos.length === 1 ? 'video' : 'videos'} bookmarked
{items.length} {items.length === 1 ? 'item' : 'items'} bookmarked
</p>
</div>
</div>
</div>
{videos.length === 0 ? (
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center min-h-[60vh]">
<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">
@ -101,29 +101,33 @@ export default function BookmarksPage() {
</div>
<h2 className="text-2xl font-bold text-white mb-2">No Bookmarks Yet</h2>
<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>
</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">
{videos.map((video) => (
{items.map((item) => (
<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"
onClick={() => handleVideoClick(video)}
onClick={() => handleItemClick(item)}
>
<CardHeader className="p-0">
<div className="aspect-video bg-slate-200 dark:bg-slate-700 relative overflow-hidden">
{video.thumbnail ? (
<div className={`relative overflow-hidden ${item.type === 'photo' ? 'aspect-square' : 'aspect-video'} bg-slate-200 dark:bg-slate-700`}>
{item.thumbnail ? (
<img
src={video.thumbnail}
alt={video.title}
src={item.thumbnail}
alt={item.title}
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">
<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 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>
<CardContent className="p-3">
<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>
<CardDescription className="text-xs text-slate-600 dark:text-slate-400">
{formatFileSize(video.size)}
{formatFileSize(item.size)}
</CardDescription>
{/* 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-1">
<Heart className="h-3 w-3" />
<span>{video.bookmark_count || 0}</span>
<span>{item.bookmark_count || 0}</span>
</div>
<div className="flex items-center gap-1">
<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 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>
</CardContent>
</Card>
@ -160,14 +172,14 @@ export default function BookmarksPage() {
</div>
{/* Inline Video Player */}
{selectedVideo && (
{selectedItem && selectedItem.type === 'video' && (
<InlineVideoPlayer
video={{
id: selectedVideo.id,
title: selectedVideo.title || selectedVideo.path.split('/').pop() || 'Untitled',
path: selectedVideo.path,
size: selectedVideo.size,
thumbnail: selectedVideo.thumbnail || '',
id: selectedItem.id,
title: selectedItem.title || selectedItem.path.split('/').pop() || 'Untitled',
path: selectedItem.path,
size: selectedItem.size,
thumbnail: selectedItem.thumbnail || '',
}}
isOpen={isPlayerOpen}
onClose={handleClosePlayer}

View File

@ -6,8 +6,9 @@ import { useSearchParams } from "next/navigation";
import { Folder, File, Image, Film, Play } from "lucide-react";
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 PhotoViewer from "@/components/photo-viewer";
import VideoViewer from "@/components/video-viewer";
import { Button } from "@/components/ui/button";
interface FileSystemItem {
name: string;
@ -27,6 +28,9 @@ 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 [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
useEffect(() => {
if (path) {
@ -81,6 +85,37 @@ const FolderViewerPage = () => {
setSelectedVideo(null);
};
const handlePhotoClick = (item: FileSystemItem, index: number) => {
if (item.type === 'photo' && item.id) {
setSelectedPhoto(item);
setCurrentPhotoIndex(index);
setIsPhotoViewerOpen(true);
}
};
const handleClosePhotoViewer = () => {
setIsPhotoViewerOpen(false);
setSelectedPhoto(null);
};
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]);
}
};
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]);
}
};
if (!path) {
return (
<div className="flex items-center justify-center h-full">
@ -136,13 +171,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 +195,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,21 +266,28 @@ const FolderViewerPage = () => {
)}
</div>
{/* Inline Video Player - Rendered as Portal */}
{selectedVideo && isPlayerOpen && typeof window !== 'undefined' && createPortal(
<InlineVideoPlayer
video={{
id: selectedVideo.id!,
title: selectedVideo.name,
path: selectedVideo.path,
size: selectedVideo.size,
thumbnail: selectedVideo.thumbnail || '',
}}
isOpen={isPlayerOpen}
onClose={handleClosePlayer}
/>,
document.body
)}
{/* Photo Viewer */}
<PhotoViewer
photo={selectedPhoto!}
isOpen={isPhotoViewerOpen}
onClose={handleClosePhotoViewer}
onNext={handleNextPhoto}
onPrev={handlePrevPhoto}
showNavigation={items.filter(item => item.type === 'photo' && item.id).length > 1}
showBookmarks={false}
showRatings={false}
formatFileSize={formatFileSize}
/>
{/* Video Viewer */}
<VideoViewer
video={selectedVideo!}
isOpen={isPlayerOpen}
onClose={handleClosePlayer}
showBookmarks={false}
showRatings={false}
formatFileSize={formatFileSize}
/>
</div>
);
};

View File

@ -1,10 +1,12 @@
'use client';
import { useEffect, useState } from 'react';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Image as ImageIcon, Search, Filter, Star, Bookmark, HardDrive } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Image as ImageIcon } from 'lucide-react';
import { Input } from '@/components/ui/input';
import PhotoViewer from '@/components/photo-viewer';
interface Photo {
id: number;
@ -13,11 +15,18 @@ interface Photo {
size: number;
thumbnail: string;
type: string;
bookmark_count: number;
avg_rating: number;
star_count: number;
}
export default function PhotosPage() {
const [photos, setPhotos] = useState<Photo[]>([]);
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(() => {
fetchPhotos();
@ -43,88 +52,242 @@ export default function PhotosPage() {
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) {
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
<div className="text-center">
<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">
<ImageIcon className="h-8 w-8 text-white" />
<div className="min-h-screen p-6">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-center min-h-[60vh]">
<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>
<p className="text-zinc-400 font-medium">Loading photos...</p>
</div>
</div>
);
}
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="mb-8">
<h1 className="text-5xl font-bold text-white tracking-tight mb-2">
Photos
</h1>
<p className="text-zinc-400 text-lg">
{photos.length} {photos.length === 1 ? 'photo' : 'photos'} found
</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 className="min-h-screen p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-4 mb-4">
<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">
<ImageIcon className="h-6 w-6 text-white" />
</div>
))}
</div>
) : (
<div className="text-center py-20">
<div className="max-w-sm mx-auto">
<div className="w-20 h-20 bg-zinc-900 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
<ImageIcon className="h-10 w-10 text-purple-500" />
<div>
<h1 className="text-3xl font-bold text-foreground tracking-tight">
Photos
</h1>
<p className="text-muted-foreground">
{photos.length} {photos.length === 1 ? 'photo' : 'photos'} in your library
</p>
</div>
<h3 className="text-2xl font-semibold text-white mb-2">
No Photos Found
</h3>
<p className="text-zinc-400 mb-6">
Add media libraries to scan for photos
</p>
<Link href="/settings">
<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">
Add Library
</button>
</Link>
</div>
{/* Search and Filter Bar */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search photos..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
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>
)}
{/* 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-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-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" />
{/* 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>
{/* Photo Viewer */}
<PhotoViewer
photo={selectedPhoto!}
isOpen={isViewerOpen}
onClose={handleCloseViewer}
onNext={handleNextPhoto}
onPrev={handlePrevPhoto}
showNavigation={filteredPhotos.length > 1}
showBookmarks={true}
showRatings={true}
formatFileSize={formatFileSize}
onBookmark={handleBookmark}
onUnbookmark={handleUnbookmark}
onRate={handleRate}
/>
</>
);
}

View File

@ -6,8 +6,7 @@ import { Film, Play, Clock, HardDrive, Search, Filter } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import InlineVideoPlayer from "@/components/inline-video-player";
import { createPortal } from "react-dom";
import VideoViewer from "@/components/video-viewer";
interface Video {
id: number;
@ -15,6 +14,10 @@ interface Video {
path: string;
size: number;
thumbnail: string;
type: string;
bookmark_count: number;
avg_rating: number;
star_count: number;
}
const VideosPage = () => {
@ -130,11 +133,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" />
@ -207,16 +210,15 @@ const VideosPage = () => {
</div>
</div>
{/* Inline Video Player - Rendered as Portal */}
{selectedVideo && isPlayerOpen && typeof window !== 'undefined' && createPortal(
<InlineVideoPlayer
video={selectedVideo}
isOpen={isPlayerOpen}
onClose={handleClosePlayer}
scrollPosition={scrollPosition}
/>,
document.body
)}
{/* Video Viewer */}
<VideoViewer
video={selectedVideo!}
isOpen={isPlayerOpen}
onClose={handleClosePlayer}
showBookmarks={true}
showRatings={true}
formatFileSize={formatFileSize}
/>
</>
);
};

View File

@ -0,0 +1,223 @@
'use client';
import { useState, useEffect } from 'react';
import { X, ChevronLeft, ChevronRight, Star, Bookmark } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { createPortal } from 'react-dom';
interface Photo {
id: number;
path: string;
title: string;
size: number;
thumbnail: string;
type: string;
bookmark_count: number;
avg_rating: number;
star_count: number;
}
interface FileSystemItem {
name: string;
path: string;
isDirectory: boolean;
size: number;
thumbnail?: string;
type?: string;
id?: number;
}
interface PhotoViewerProps {
photo: Photo | FileSystemItem;
isOpen: boolean;
onClose: () => void;
onNext?: () => void;
onPrev?: () => void;
showNavigation?: boolean;
showBookmarks?: boolean;
showRatings?: boolean;
formatFileSize?: (bytes: number) => string;
onBookmark?: (photoId: number) => void;
onUnbookmark?: (photoId: number) => void;
onRate?: (photoId: number, rating: number) => void;
}
export default function PhotoViewer({
photo,
isOpen,
onClose,
onNext,
onPrev,
showNavigation = false,
showBookmarks = false,
showRatings = false,
formatFileSize,
onBookmark,
onUnbookmark,
onRate
}: PhotoViewerProps) {
const [isPhotoLoading, setIsPhotoLoading] = useState(false);
useEffect(() => {
if (isOpen) {
setIsPhotoLoading(true);
}
}, [isOpen, photo]);
const handleClose = () => {
setIsPhotoLoading(false);
onClose();
};
const handleNext = () => {
if (onNext) {
setIsPhotoLoading(true);
onNext();
}
};
const handlePrev = () => {
if (onPrev) {
setIsPhotoLoading(true);
onPrev();
}
};
const handleBookmark = () => {
if (onBookmark && 'id' in photo) {
onBookmark(photo.id);
}
};
const handleUnbookmark = () => {
if (onUnbookmark && 'id' in photo) {
onUnbookmark(photo.id);
}
};
const handleRate = (rating: number) => {
if (onRate && 'id' in photo) {
onRate(photo.id, rating);
}
};
const getPhotoTitle = () => {
if ('title' in photo) return photo.title;
if ('name' in photo) return photo.name;
return 'Photo';
};
const getPhotoSize = () => {
if (formatFileSize) {
return formatFileSize(photo.size);
}
// Default format function
const bytes = photo.size;
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const getBookmarkCount = () => {
if ('bookmark_count' in photo) return photo.bookmark_count;
return 0;
};
const getAvgRating = () => {
if ('avg_rating' in photo) return photo.avg_rating;
return 0;
};
if (!isOpen || typeof window === 'undefined') return null;
return createPortal(
<div className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center" onClick={handleClose}>
<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={handleClose}
className="bg-black/50 backdrop-blur-sm hover:bg-black/70"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Navigation Arrows */}
{showNavigation && onNext && onPrev && (
<>
<Button
variant="secondary"
size="icon"
onClick={handlePrev}
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={handleNext}
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/${('id' in photo ? photo.id : photo.id) || ''}`}
alt={getPhotoTitle()}
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">{getPhotoTitle()}</h3>
<p className="text-gray-300 text-sm">{getPhotoSize()}</p>
</div>
{(showBookmarks || showRatings) && (
<div className="flex items-center gap-4">
{showBookmarks && (
<button
onClick={getBookmarkCount() > 0 ? handleUnbookmark : handleBookmark}
className="flex items-center gap-1 text-white hover:text-yellow-400 transition-colors"
>
<Bookmark className={`h-4 w-4 ${getBookmarkCount() > 0 ? 'fill-yellow-400 text-yellow-400' : ''}`} />
</button>
)}
{showRatings && (
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((rating) => (
<button
key={rating}
onClick={() => handleRate(rating)}
className="text-white hover:text-yellow-400 transition-colors"
>
<Star className={`h-4 w-4 ${rating <= Math.round(getAvgRating()) ? 'fill-yellow-400 text-yellow-400' : ''}`} />
</button>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>,
document.body
);
}

View File

@ -0,0 +1,315 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { X, Play, Pause, Volume2, VolumeX, Maximize, Star, Bookmark } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { createPortal } from 'react-dom';
interface Video {
id: number;
title: string;
path: string;
size: number;
thumbnail: string;
type: string;
bookmark_count: number;
avg_rating: number;
star_count: number;
}
interface FileSystemItem {
name: string;
path: string;
isDirectory: boolean;
size: number;
thumbnail?: string;
type?: string;
id?: number;
}
interface VideoViewerProps {
video: Video | FileSystemItem;
isOpen: boolean;
onClose: () => void;
showBookmarks?: boolean;
showRatings?: boolean;
formatFileSize?: (bytes: number) => string;
onBookmark?: (videoId: number) => void;
onUnbookmark?: (videoId: number) => void;
onRate?: (videoId: number, rating: number) => void;
}
export default function VideoViewer({
video,
isOpen,
onClose,
showBookmarks = false,
showRatings = false,
formatFileSize,
onBookmark,
onUnbookmark,
onRate
}: VideoViewerProps) {
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [volume, setVolume] = useState(1);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [showControls, setShowControls] = useState(true);
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (isOpen && videoRef.current) {
videoRef.current.src = `/api/stream/${('id' in video ? video.id : video.id) || ''}`;
videoRef.current.load();
// Auto-play when video is loaded
videoRef.current.addEventListener('loadeddata', () => {
if (videoRef.current) {
videoRef.current.play().then(() => {
setIsPlaying(true);
}).catch((error) => {
console.log('Auto-play prevented by browser:', error);
});
}
});
}
}, [isOpen, video]);
const handlePlayPause = () => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
const handleMute = () => {
if (videoRef.current) {
videoRef.current.muted = !isMuted;
setIsMuted(!isMuted);
}
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newVolume = parseFloat(e.target.value);
setVolume(newVolume);
if (videoRef.current) {
videoRef.current.volume = newVolume;
}
};
const handleTimeUpdate = () => {
if (videoRef.current) {
setCurrentTime(videoRef.current.currentTime);
}
};
const handleLoadedMetadata = () => {
if (videoRef.current) {
setDuration(videoRef.current.duration);
}
};
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = parseFloat(e.target.value);
if (videoRef.current) {
videoRef.current.currentTime = newTime;
setCurrentTime(newTime);
}
};
const handleFullscreen = () => {
if (videoRef.current) {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
videoRef.current.requestFullscreen();
}
}
};
const handleBookmark = () => {
if (onBookmark && 'id' in video) {
onBookmark(video.id);
}
};
const handleUnbookmark = () => {
if (onUnbookmark && 'id' in video) {
onUnbookmark(video.id);
}
};
const handleRate = (rating: number) => {
if (onRate && 'id' in video) {
onRate(video.id, rating);
}
};
const getVideoTitle = () => {
if ('title' in video) return video.title;
if ('name' in video) return video.name;
return 'Video';
};
const getVideoSize = () => {
if (formatFileSize) {
return formatFileSize(video.size);
}
// Default format function
const bytes = video.size;
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const getBookmarkCount = () => {
if ('bookmark_count' in video) return video.bookmark_count;
return 0;
};
const getAvgRating = () => {
if ('avg_rating' in video) return video.avg_rating;
return 0;
};
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
if (!isOpen || typeof window === 'undefined') return null;
return createPortal(
<div className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center">
<div className="relative w-full h-full max-w-7xl max-h-[90vh] mx-auto my-8">
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 z-10 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 transition-colors"
>
<X className="h-6 w-6" />
</button>
{/* Video container */}
<div
className="relative w-full h-full bg-black rounded-lg overflow-hidden"
onMouseMove={() => setShowControls(true)}
onMouseLeave={() => setShowControls(false)}
>
<video
ref={videoRef}
className="w-full h-full object-contain"
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onMouseMove={() => setShowControls(true)}
onMouseLeave={() => setShowControls(false)}
>
<source src={`/api/stream/${('id' in video ? video.id : video.id) || ''}`} type="video/mp4" />
Your browser does not support the video tag.
</video>
{/* Title overlay */}
<div className={`absolute top-0 left-0 right-0 bg-gradient-to-b from-black/60 to-transparent p-4 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'}`}>
<div className="flex items-center justify-between">
<h2 className="text-white text-lg font-semibold">{getVideoTitle()}</h2>
{(showBookmarks || showRatings) && (
<div className="flex items-center gap-4">
{showBookmarks && (
<button
onClick={getBookmarkCount() > 0 ? handleUnbookmark : handleBookmark}
className="flex items-center gap-1 text-white hover:text-yellow-400 transition-colors"
>
<Bookmark className={`h-4 w-4 ${getBookmarkCount() > 0 ? 'fill-yellow-400 text-yellow-400' : ''}`} />
</button>
)}
{showRatings && (
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((rating) => (
<button
key={rating}
onClick={() => handleRate(rating)}
className="text-white hover:text-yellow-400 transition-colors"
>
<Star className={`h-4 w-4 ${rating <= Math.round(getAvgRating()) ? 'fill-yellow-400 text-yellow-400' : ''}`} />
</button>
))}
</div>
)}
</div>
)}
</div>
</div>
{/* Controls overlay */}
<div className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-4 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'}`}>
{/* Progress bar */}
<div className="mb-4">
<input
type="range"
min="0"
max={duration || 0}
value={currentTime}
onChange={handleSeek}
className="w-full h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer slider"
/>
<div className="flex justify-between text-white text-sm mt-1">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
{/* Control buttons */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<button
onClick={handlePlayPause}
className="text-white hover:text-gray-300 transition-colors"
>
{isPlaying ? <Pause className="h-6 w-6" /> : <Play className="h-6 w-6" />}
</button>
<div className="flex items-center gap-2">
<button
onClick={handleMute}
className="text-white hover:text-gray-300 transition-colors"
>
{isMuted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</button>
<input
type="range"
min="0"
max="1"
step="0.1"
value={volume}
onChange={handleVolumeChange}
className="w-20 h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer slider"
/>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleFullscreen}
className="text-white hover:text-gray-300 transition-colors"
>
<Maximize className="h-4 w-4" />
</button>
</div>
</div>
</div>
</div>
</div>
</div>,
document.body
);
}