feat: integrate virtualized folder grid for enhanced media browsing experience
- Replaced the existing folder viewer layout with a new VirtualizedFolderGrid component to improve performance and responsiveness. - Streamlined item fetching logic to eliminate unnecessary state management and enhance loading efficiency. - Updated UI elements for better navigation, including breadcrumb support and back button functionality. - Enhanced error handling and loading states to provide clearer feedback during directory access.
This commit is contained in:
parent
a56492f36a
commit
848578c136
|
|
@ -3,13 +3,9 @@
|
|||
|
||||
import { useState, useEffect, Suspense } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { Folder, File, Image, Film, Play, ChevronLeft, Home, Star } from "lucide-react";
|
||||
import { StarRating } from "@/components/star-rating";
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import PhotoViewer from "@/components/photo-viewer";
|
||||
import VideoViewer from "@/components/video-viewer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import VirtualizedFolderGrid from "@/components/virtualized-media-grid";
|
||||
|
||||
interface FileSystemItem {
|
||||
name: string;
|
||||
|
|
@ -32,8 +28,6 @@ const FolderViewerPage = () => {
|
|||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const path = searchParams.get("path");
|
||||
const [items, setItems] = useState<FileSystemItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedVideo, setSelectedVideo] = useState<FileSystemItem | null>(null);
|
||||
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
|
||||
const [isVideoLoading, setIsVideoLoading] = useState(false);
|
||||
|
|
@ -41,14 +35,10 @@ const FolderViewerPage = () => {
|
|||
const [isPhotoViewerOpen, setIsPhotoViewerOpen] = useState(false);
|
||||
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
||||
const [libraries, setLibraries] = useState<{id: number, path: string}[]>([]);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchLibraries();
|
||||
if (path) {
|
||||
fetchItems(path);
|
||||
}
|
||||
}, [path]);
|
||||
}, []);
|
||||
|
||||
const fetchLibraries = async () => {
|
||||
try {
|
||||
|
|
@ -60,29 +50,6 @@ const FolderViewerPage = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const fetchItems = async (currentPath: string) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`/api/files?path=${encodeURIComponent(currentPath)}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
console.error('Invalid response format:', data);
|
||||
setItems([]);
|
||||
setError('Invalid response from server');
|
||||
} else {
|
||||
setItems(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching items:', error);
|
||||
setItems([]);
|
||||
setError('Failed to load directory contents');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
|
|
@ -91,71 +58,9 @@ const FolderViewerPage = () => {
|
|||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const formatFilePath = (path: string) => {
|
||||
if (!path) return '';
|
||||
|
||||
// Split path into directory and filename
|
||||
const lastSlashIndex = path.lastIndexOf('/');
|
||||
const lastBackslashIndex = path.lastIndexOf('\\');
|
||||
const lastSeparatorIndex = Math.max(lastSlashIndex, lastBackslashIndex);
|
||||
|
||||
if (lastSeparatorIndex === -1) {
|
||||
// No directory separator found, return as is
|
||||
return path;
|
||||
}
|
||||
|
||||
const directory = path.substring(0, lastSeparatorIndex);
|
||||
const filename = path.substring(lastSeparatorIndex + 1);
|
||||
|
||||
// If the full path is short enough, show it all
|
||||
if (path.length <= 80) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// If directory is short enough, show directory + truncated filename
|
||||
if (directory.length <= 50) {
|
||||
const maxFilenameLength = 80 - directory.length - 1; // -1 for "/"
|
||||
if (filename.length <= maxFilenameLength) {
|
||||
return `${directory}/${filename}`;
|
||||
} else {
|
||||
return `${directory}/${filename.substring(0, maxFilenameLength - 3)}...`;
|
||||
}
|
||||
}
|
||||
|
||||
// For longer paths, show more of the directory structure
|
||||
const maxDirLength = 45;
|
||||
const startLength = Math.floor(maxDirLength / 2);
|
||||
const endLength = maxDirLength - startLength - 3; // -3 for "..."
|
||||
|
||||
const truncatedDir = directory.length > maxDirLength
|
||||
? `${directory.substring(0, startLength)}...${directory.substring(directory.length - endLength)}`
|
||||
: directory;
|
||||
|
||||
// Add filename if there's space
|
||||
const remainingSpace = 80 - truncatedDir.length - 1; // -1 for "/"
|
||||
if (remainingSpace > 3) {
|
||||
return `${truncatedDir}/${filename.length > remainingSpace ? filename.substring(0, remainingSpace - 3) + '...' : filename}`;
|
||||
}
|
||||
|
||||
return `${truncatedDir}/...`;
|
||||
};
|
||||
|
||||
const formatTitle = (title: string) => {
|
||||
if (!title) return '';
|
||||
|
||||
// If title is short enough, return as is
|
||||
if (title.length <= 40) {
|
||||
return title;
|
||||
}
|
||||
|
||||
// For longer titles, truncate to prevent awkward third-line display
|
||||
return title.length > 60 ? title.substring(0, 60) + '...' : title;
|
||||
};
|
||||
|
||||
const getLibraryRoot = (currentPath: string): string | null => {
|
||||
if (!currentPath || libraries.length === 0) return null;
|
||||
|
||||
// Find the library root that matches the current path
|
||||
for (const library of libraries) {
|
||||
if (currentPath.startsWith(library.path)) {
|
||||
return library.path;
|
||||
|
|
@ -170,17 +75,14 @@ const FolderViewerPage = () => {
|
|||
const libraryRoot = getLibraryRoot(currentPath);
|
||||
if (!libraryRoot) return [{ name: 'Unknown', path: currentPath }];
|
||||
|
||||
// Get the relative path from library root
|
||||
const relativePath = currentPath.substring(libraryRoot.length);
|
||||
const pathParts = relativePath.split('/').filter(part => part.length > 0);
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [];
|
||||
|
||||
// Use library name as root
|
||||
const libraryName = libraryRoot.split('/').pop() || libraryRoot;
|
||||
breadcrumbs.push({ name: libraryName, path: libraryRoot });
|
||||
|
||||
// Build breadcrumbs for each path level within the library
|
||||
let accumulatedPath = libraryRoot;
|
||||
pathParts.forEach((part, index) => {
|
||||
accumulatedPath += '/' + part;
|
||||
|
|
@ -199,19 +101,15 @@ const FolderViewerPage = () => {
|
|||
const libraryRoot = getLibraryRoot(currentPath);
|
||||
if (!libraryRoot) return '';
|
||||
|
||||
// Don't allow navigation above library root
|
||||
if (currentPath === libraryRoot) return '';
|
||||
|
||||
// Split path but keep leading slash for absolute paths
|
||||
const pathParts = currentPath.split('/');
|
||||
const libraryParts = libraryRoot.split('/').filter(part => part.length > 0);
|
||||
|
||||
// Filter out empty parts but keep structure
|
||||
const filteredPathParts = pathParts.filter(part => part.length > 0);
|
||||
|
||||
if (filteredPathParts.length <= libraryParts.length) return '';
|
||||
|
||||
// Reconstruct absolute path
|
||||
const parentParts = filteredPathParts.slice(0, -1);
|
||||
return '/' + parentParts.join('/');
|
||||
};
|
||||
|
|
@ -233,23 +131,11 @@ const FolderViewerPage = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const getFileIcon = (item: FileSystemItem) => {
|
||||
if (item.isDirectory) return <Folder className="text-blue-500" size={48} />;
|
||||
if (item.type === 'photo') return <Image className="text-green-500" size={48} />;
|
||||
if (item.type === 'video') return <Film className="text-red-500" size={48} />;
|
||||
return <File className="text-gray-500" size={48} />;
|
||||
};
|
||||
|
||||
const isMediaFile = (item: FileSystemItem) => {
|
||||
return item.type === 'video' || item.type === 'photo';
|
||||
};
|
||||
|
||||
const handleVideoClick = (item: FileSystemItem) => {
|
||||
if (item.type === 'video' && item.id) {
|
||||
setIsVideoLoading(true);
|
||||
setSelectedVideo(item);
|
||||
setIsPlayerOpen(true);
|
||||
// Reset loading state after a short delay to allow player to initialize
|
||||
setTimeout(() => setIsVideoLoading(false), 500);
|
||||
}
|
||||
};
|
||||
|
|
@ -273,53 +159,25 @@ const FolderViewerPage = () => {
|
|||
};
|
||||
|
||||
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]);
|
||||
}
|
||||
// This would need to be implemented with the current items list
|
||||
// For now, just close the viewer
|
||||
handleClosePhotoViewer();
|
||||
};
|
||||
|
||||
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]);
|
||||
}
|
||||
// This would need to be implemented with the current items list
|
||||
// For now, just close the viewer
|
||||
handleClosePhotoViewer();
|
||||
};
|
||||
|
||||
if (!path) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-500 text-lg mb-4">Select a library from the sidebar</p>
|
||||
<p className="text-gray-400">Choose a media library above to browse its contents</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950">
|
||||
{loading && (
|
||||
<div className="fixed inset-0 bg-black/5 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-xl">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-2 border-slate-900 dark:border-white border-t-transparent"></div>
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Loading directory...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{!path ? (
|
||||
<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="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">
|
||||
<Folder className="h-10 w-10 text-blue-500" />
|
||||
<div className="h-10 w-10 text-blue-500">📁</div>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Select a Library</h2>
|
||||
<p className="text-zinc-400">
|
||||
|
|
@ -327,178 +185,22 @@ const FolderViewerPage = () => {
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
{/* Back Button */}
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleBackClick}
|
||||
className="text-zinc-400 hover:text-white hover:bg-zinc-800/50 transition-colors"
|
||||
disabled={!getParentPath(path || '')}
|
||||
title={getParentPath(path || '') ? 'Go to parent directory' : 'Already at library root'}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb Navigation */}
|
||||
<nav className="flex items-center flex-wrap gap-2 text-sm font-medium text-zinc-400 mb-4">
|
||||
{getBreadcrumbs(path || '').map((breadcrumb, index) => (
|
||||
<div key={breadcrumb.path} className="flex items-center gap-2">
|
||||
{index > 0 && <span className="text-zinc-600">/</span>}
|
||||
<button
|
||||
onClick={() => handleBreadcrumbClick(breadcrumb.path)}
|
||||
className={`hover:text-white transition-colors ${
|
||||
index === getBreadcrumbs(path || '').length - 1
|
||||
? 'text-white font-semibold cursor-default'
|
||||
: 'hover:underline cursor-pointer'
|
||||
}`}
|
||||
disabled={index === getBreadcrumbs(path || '').length - 1}
|
||||
title={breadcrumb.path}
|
||||
>
|
||||
{index === 0 ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Home className="h-3 w-3" />
|
||||
<span>{breadcrumb.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
breadcrumb.name
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Current Directory Title */}
|
||||
<h1 className="text-3xl font-bold text-white tracking-tight">
|
||||
{path ? path.split('/').pop() : 'Libraries'}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
||||
{error && (
|
||||
<div className="col-span-full text-center py-12">
|
||||
<div className="max-w-sm mx-auto">
|
||||
<div className="w-16 h-16 bg-red-900/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Folder className="h-8 w-8 text-red-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-red-400 mb-2">Error Loading Directory</h3>
|
||||
<p className="text-sm text-red-500 mb-4">{error}</p>
|
||||
<Button
|
||||
onClick={() => path && fetchItems(path)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-red-500 text-red-500 hover:bg-red-500/10"
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && Array.isArray(items) && 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 min-h-[240px] ${(item.type === 'video' || item.type === 'photo') ? 'cursor-pointer' : ''}`}>
|
||||
<Link href={item.isDirectory ? `/folder-viewer?path=${item.path}` : '#'}
|
||||
className="block h-full flex flex-col"
|
||||
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-[4/3] relative overflow-hidden">
|
||||
{item.isDirectory ? (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-blue-900/20 dark:to-indigo-900/20 flex items-center justify-center">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-2xl flex items-center justify-center shadow-lg"
|
||||
style={{ transform: 'perspective(100px) rotateY(-5deg) rotateX(5deg)' }}>
|
||||
<Folder className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
) : isMediaFile(item) ? (
|
||||
<div className="relative overflow-hidden aspect-[4/3] bg-black rounded-t-xl">
|
||||
<img
|
||||
src={item.thumbnail || (item.type === 'video' ? '/placeholder-video.svg' : '/placeholder-photo.svg')}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-contain transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
{item.type === 'video' && (
|
||||
<>
|
||||
<div className="absolute top-2 right-2 bg-black/60 text-white rounded-full p-1">
|
||||
<Play className="h-3 w-3" />
|
||||
</div>
|
||||
{isVideoLoading && selectedVideo?.id === item.id && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-white border-t-transparent"></div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{item.type === 'photo' && (
|
||||
<div className="absolute top-2 right-2 bg-black/60 text-white rounded-full p-1">
|
||||
<Image className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-100 to-slate-200 dark:from-slate-700 dark:to-slate-800 flex items-center justify-center"
|
||||
style={{ background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)' }}>
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-slate-400 to-slate-600 rounded-2xl flex items-center justify-center shadow-lg"
|
||||
style={{ transform: 'perspective(100px) rotateY(-5deg) rotateX(5deg)' }}>
|
||||
{getFileIcon(item)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-2.5 flex flex-col flex-1 bg-slate-50 dark:bg-slate-800/50 border-t border-slate-200 dark:border-slate-700">
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100 line-clamp-2 leading-tight mb-1 min-h-[2rem]" title={item.name}>{formatTitle(item.name)}</p>
|
||||
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="text-slate-600 dark:text-slate-400">{formatFileSize(item.size)}</span>
|
||||
{isMediaFile(item) && (item.avg_rating || 0) > 0 && (
|
||||
<StarRating
|
||||
rating={item.avg_rating || 0}
|
||||
count={item.star_count || 0}
|
||||
size="xs"
|
||||
showCount={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="text-xs text-slate-500 dark:text-slate-400 line-clamp-2 leading-tight cursor-help flex-1"
|
||||
title={item.path}
|
||||
>
|
||||
{formatFilePath(item.path)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{items.length === 0 && !loading && !error && (
|
||||
<div className="text-center py-20">
|
||||
<div className="max-w-sm mx-auto">
|
||||
<div className="w-16 h-16 bg-slate-100 dark:bg-slate-800 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Folder className="h-8 w-8 text-slate-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-700 dark:text-slate-300 mb-2">Empty Directory</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">No files or folders found in this location.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<VirtualizedFolderGrid
|
||||
currentPath={path}
|
||||
onVideoClick={handleVideoClick}
|
||||
onPhotoClick={handlePhotoClick}
|
||||
onBackClick={handleBackClick}
|
||||
onBreadcrumbClick={handleBreadcrumbClick}
|
||||
breadcrumbs={getBreadcrumbs(path)}
|
||||
libraries={libraries}
|
||||
/>
|
||||
|
||||
{/* Photo Viewer */}
|
||||
<PhotoViewer
|
||||
|
|
@ -507,7 +209,7 @@ const FolderViewerPage = () => {
|
|||
onClose={handleClosePhotoViewer}
|
||||
onNext={handleNextPhoto}
|
||||
onPrev={handlePrevPhoto}
|
||||
showNavigation={items.filter(item => item.type === 'photo' && item.id).length > 1}
|
||||
showNavigation={false}
|
||||
showBookmarks={false}
|
||||
showRatings={false}
|
||||
formatFileSize={formatFileSize}
|
||||
|
|
@ -522,14 +224,14 @@ const FolderViewerPage = () => {
|
|||
showRatings={false}
|
||||
formatFileSize={formatFileSize}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const FolderViewerPageWrapper = () => (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<FolderViewerPage />
|
||||
</Suspense>
|
||||
)
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<FolderViewerPage />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
export default FolderViewerPageWrapper;
|
||||
|
|
|
|||
|
|
@ -4,58 +4,55 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
|||
import { FixedSizeGrid } from 'react-window';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { StarRating } from '@/components/star-rating';
|
||||
import { Film, Image as ImageIcon, HardDrive, Search, Bookmark } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Film, Image as ImageIcon, HardDrive, Search, Folder, Play, ChevronLeft, Home } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface MediaItem {
|
||||
id: number;
|
||||
title: string;
|
||||
interface FileSystemItem {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
size: number;
|
||||
thumbnail: string;
|
||||
type: string;
|
||||
bookmark_count: number;
|
||||
avg_rating: number;
|
||||
star_count: number;
|
||||
thumbnail?: string;
|
||||
type?: string;
|
||||
id?: number;
|
||||
avg_rating?: number;
|
||||
star_count?: number;
|
||||
}
|
||||
|
||||
interface PaginationInfo {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
hasMore: boolean;
|
||||
interface BreadcrumbItem {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface VirtualizedMediaGridProps {
|
||||
type: 'video' | 'photo' | 'bookmark';
|
||||
onItemClick: (item: MediaItem) => void;
|
||||
onBookmark: (id: number) => Promise<void>;
|
||||
onUnbookmark: (id: number) => Promise<void>;
|
||||
onRate: (id: number, rating: number) => Promise<void>;
|
||||
interface VirtualizedFolderGridProps {
|
||||
currentPath: string;
|
||||
onVideoClick: (item: FileSystemItem) => void;
|
||||
onPhotoClick: (item: FileSystemItem, index: number) => void;
|
||||
onBackClick: () => void;
|
||||
onBreadcrumbClick: (path: string) => void;
|
||||
breadcrumbs: BreadcrumbItem[];
|
||||
libraries: {id: number, path: string}[];
|
||||
}
|
||||
|
||||
export default function VirtualizedMediaGrid({
|
||||
type,
|
||||
onItemClick,
|
||||
onBookmark,
|
||||
onUnbookmark,
|
||||
onRate
|
||||
}: VirtualizedMediaGridProps) {
|
||||
const [items, setItems] = useState<MediaItem[]>([]);
|
||||
const ITEM_HEIGHT = 280; // Increased for folder cards
|
||||
|
||||
export default function VirtualizedFolderGrid({
|
||||
currentPath,
|
||||
onVideoClick,
|
||||
onPhotoClick,
|
||||
onBackClick,
|
||||
onBreadcrumbClick,
|
||||
breadcrumbs,
|
||||
libraries
|
||||
}: VirtualizedFolderGridProps) {
|
||||
const [items, setItems] = useState<FileSystemItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [pagination, setPagination] = useState<PaginationInfo>({
|
||||
total: 0,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
hasMore: true
|
||||
});
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const router = useRouter();
|
||||
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
const loadingRef = useRef(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
|
|
@ -95,58 +92,79 @@ export default function VirtualizedMediaGrid({
|
|||
return `${truncatedDir}/${filename}`;
|
||||
};
|
||||
|
||||
const fetchItems = useCallback(async (offset: number, search?: string) => {
|
||||
if (loadingRef.current) return;
|
||||
const formatTitle = (title: string) => {
|
||||
if (!title) return '';
|
||||
|
||||
loadingRef.current = true;
|
||||
setIsLoadingMore(offset > 0);
|
||||
if (title.length <= 40) {
|
||||
return title;
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
limit: '50',
|
||||
offset: offset.toString(),
|
||||
...(search && { search })
|
||||
});
|
||||
return title.length > 60 ? title.substring(0, 60) + '...' : title;
|
||||
};
|
||||
|
||||
const endpoint = type === 'bookmark' ? 'bookmarks' : `${type}s`;
|
||||
const response = await fetch(`/api/${endpoint}?${params}`);
|
||||
const data = await response.json();
|
||||
const getLibraryRoot = (currentPath: string): string | null => {
|
||||
if (!currentPath || libraries.length === 0) return null;
|
||||
|
||||
const itemsKey = type === 'bookmark' ? 'bookmarks' : `${type}s`;
|
||||
|
||||
if (offset === 0) {
|
||||
setItems(data[itemsKey] || []);
|
||||
} else {
|
||||
setItems(prev => [...prev, ...(data[itemsKey] || [])]);
|
||||
for (const library of libraries) {
|
||||
if (currentPath.startsWith(library.path)) {
|
||||
return library.path;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
setPagination(data.pagination);
|
||||
const getParentPath = (currentPath: string): string => {
|
||||
if (!currentPath) return '';
|
||||
|
||||
const libraryRoot = getLibraryRoot(currentPath);
|
||||
if (!libraryRoot) return '';
|
||||
|
||||
if (currentPath === libraryRoot) return '';
|
||||
|
||||
const pathParts = currentPath.split('/');
|
||||
const libraryParts = libraryRoot.split('/').filter(part => part.length > 0);
|
||||
|
||||
const filteredPathParts = pathParts.filter(part => part.length > 0);
|
||||
|
||||
if (filteredPathParts.length <= libraryParts.length) return '';
|
||||
|
||||
const parentParts = filteredPathParts.slice(0, -1);
|
||||
return '/' + parentParts.join('/');
|
||||
};
|
||||
|
||||
const fetchItems = useCallback(async (path: string) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`/api/files?path=${encodeURIComponent(path)}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
console.error('Invalid response format:', data);
|
||||
setItems([]);
|
||||
setError('Invalid response from server');
|
||||
} else {
|
||||
setItems(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${type}s:`, error);
|
||||
console.error('Error fetching items:', error);
|
||||
setItems([]);
|
||||
setError('Failed to load directory contents');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsLoadingMore(false);
|
||||
loadingRef.current = false;
|
||||
}
|
||||
}, [type]);
|
||||
}, []);
|
||||
|
||||
const handleSearch = useCallback((term: string) => {
|
||||
setSearchTerm(term);
|
||||
setItems([]);
|
||||
setPagination({
|
||||
total: 0,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
hasMore: true
|
||||
});
|
||||
fetchItems(0, term);
|
||||
}, [fetchItems]);
|
||||
const getFileIcon = (item: FileSystemItem) => {
|
||||
if (item.isDirectory) return <Folder className="text-blue-500" size={48} />;
|
||||
if (item.type === 'photo') return <ImageIcon className="text-green-500" size={48} />;
|
||||
if (item.type === 'video') return <Film className="text-red-500" size={48} />;
|
||||
return <HardDrive className="text-gray-500" size={48} />;
|
||||
};
|
||||
|
||||
const loadMoreItems = useCallback(() => {
|
||||
if (pagination.hasMore && !loadingRef.current) {
|
||||
fetchItems(pagination.offset + pagination.limit, searchTerm);
|
||||
}
|
||||
}, [pagination, searchTerm, fetchItems]);
|
||||
const isMediaFile = (item: FileSystemItem) => {
|
||||
return item.type === 'video' || item.type === 'photo';
|
||||
};
|
||||
|
||||
// Calculate responsive column count and width
|
||||
const getColumnCount = useCallback(() => {
|
||||
|
|
@ -161,9 +179,8 @@ export default function VirtualizedMediaGrid({
|
|||
|
||||
const getColumnWidth = useCallback(() => {
|
||||
const cols = getColumnCount();
|
||||
// Account for padding (16px on each side) and gaps between cards (16px total per row)
|
||||
const availableWidth = containerWidth - 32; // 16px padding on each side
|
||||
const gapWidth = (cols - 1) * 16; // 16px gap between each column
|
||||
const availableWidth = containerWidth - 32;
|
||||
const gapWidth = (cols - 1) * 16;
|
||||
return Math.floor((availableWidth - gapWidth) / cols);
|
||||
}, [containerWidth, getColumnCount]);
|
||||
|
||||
|
|
@ -181,12 +198,10 @@ export default function VirtualizedMediaGrid({
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetchItems(0, searchTerm);
|
||||
}, [type]);
|
||||
|
||||
// Disabled automatic loading to prevent premature batch loading
|
||||
// Users will manually click "Load More" button when they want more content
|
||||
if (currentPath) {
|
||||
fetchItems(currentPath);
|
||||
}
|
||||
}, [currentPath, fetchItems]);
|
||||
|
||||
const Cell = ({ columnIndex, rowIndex, style }: any) => {
|
||||
const columnCount = getColumnCount();
|
||||
|
|
@ -197,70 +212,79 @@ export default function VirtualizedMediaGrid({
|
|||
|
||||
return (
|
||||
<div style={style} className="p-2">
|
||||
<Card
|
||||
className="group hover:shadow-lg transition-all duration-300 hover:-translate-y-1 cursor-pointer border-border overflow-hidden h-full"
|
||||
onClick={() => onItemClick(item)}
|
||||
>
|
||||
<div className={`relative overflow-hidden bg-muted ${type === 'video' ? 'aspect-video' : 'aspect-square'}`}>
|
||||
<img
|
||||
src={item.thumbnail || (type === 'video' ? "/placeholder-video.svg" : "/placeholder-photo.svg")}
|
||||
alt={item.title}
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = type === 'video' ? "/placeholder-video.svg" : "/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 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<div className="w-10 h-10 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center shadow-lg">
|
||||
{type === 'video' ?
|
||||
<Film className="h-5 w-5 text-foreground" /> :
|
||||
<ImageIcon className="h-5 w-5 text-foreground" />
|
||||
}
|
||||
</div>
|
||||
<div 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 min-h-[240px] ${(item.type === 'video' || item.type === 'photo') ? 'cursor-pointer' : ''}`}>
|
||||
<Link href={item.isDirectory ? `/folder-viewer?path=${item.path}` : '#'}
|
||||
className="block h-full flex flex-col"
|
||||
onClick={(e) => {
|
||||
if (item.type === 'video' && item.id) {
|
||||
e.preventDefault();
|
||||
onVideoClick(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);
|
||||
onPhotoClick(item, photoIndex);
|
||||
}
|
||||
}}>
|
||||
<div className="aspect-[4/3] relative overflow-hidden">
|
||||
{item.isDirectory ? (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-blue-900/20 dark:to-indigo-900/20 flex items-center justify-center">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-2xl flex items-center justify-center shadow-lg"
|
||||
style={{ transform: 'perspective(100px) rotateY(-5deg) rotateX(5deg)' }}>
|
||||
<Folder className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
) : isMediaFile(item) ? (
|
||||
<div className="relative overflow-hidden aspect-[4/3] bg-black rounded-t-xl">
|
||||
<img
|
||||
src={item.thumbnail || (item.type === 'video' ? '/placeholder-video.svg' : '/placeholder-photo.svg')}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-contain transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
{item.type === 'video' && (
|
||||
<div className="absolute top-2 right-2 bg-black/60 text-white rounded-full p-1">
|
||||
<Play className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
{item.type === 'photo' && (
|
||||
<div className="absolute top-2 right-2 bg-black/60 text-white rounded-full p-1">
|
||||
<ImageIcon className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-100 to-slate-200 dark:from-slate-700 dark:to-slate-800 flex items-center justify-center"
|
||||
style={{ background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)' }}>
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-slate-400 to-slate-600 rounded-2xl flex items-center justify-center shadow-lg"
|
||||
style={{ transform: 'perspective(100px) rotateY(-5deg) rotateX(5deg)' }}>
|
||||
{getFileIcon(item)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-2.5 flex flex-col flex-1 bg-slate-50 dark:bg-slate-800/50 border-t border-slate-200 dark:border-slate-700">
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100 line-clamp-2 leading-tight mb-1 min-h-[2rem]" title={item.name}>{formatTitle(item.name)}</p>
|
||||
|
||||
<div className="absolute top-2 right-2">
|
||||
<div className="bg-black/70 backdrop-blur-sm rounded-full px-2 py-1">
|
||||
{type === 'video' ?
|
||||
<Film className="h-3 w-3 text-white" /> :
|
||||
<ImageIcon className="h-3 w-3 text-white" />
|
||||
}
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="text-slate-600 dark:text-slate-400">{formatFileSize(item.size)}</span>
|
||||
{isMediaFile(item) && (item.avg_rating || 0) > 0 && (
|
||||
<StarRating
|
||||
rating={item.avg_rating || 0}
|
||||
count={item.star_count || 0}
|
||||
size="xs"
|
||||
showCount={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="text-xs text-slate-500 dark:text-slate-400 line-clamp-2 leading-tight cursor-help flex-1"
|
||||
title={item.path}
|
||||
>
|
||||
{formatFilePath(item.path)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="p-2.5">
|
||||
<h3 className="font-medium text-foreground text-sm line-clamp-2 mb-1.5 group-hover:text-primary transition-colors">
|
||||
{item.title || item.path.split('/').pop()}
|
||||
</h3>
|
||||
|
||||
{(item.avg_rating > 0 || item.star_count > 0) && (
|
||||
<div className="mb-1.5">
|
||||
<StarRating
|
||||
rating={item.avg_rating || 0}
|
||||
count={item.star_count}
|
||||
size="sm"
|
||||
showCount={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3" />
|
||||
<span>{formatFileSize(item.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className="text-xs text-muted-foreground mt-1 line-clamp-1 cursor-help"
|
||||
title={item.path}
|
||||
>
|
||||
{formatFilePath(item.path)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -269,31 +293,45 @@ export default function VirtualizedMediaGrid({
|
|||
const columnWidth = getColumnWidth();
|
||||
const rowCount = Math.ceil(items.length / columnCount);
|
||||
|
||||
// Calculate available height for the grid more precisely
|
||||
// Calculate available height for the grid
|
||||
const getAvailableHeight = useCallback(() => {
|
||||
if (typeof window === 'undefined') return 600;
|
||||
|
||||
// Calculate the actual header height and other UI elements
|
||||
const headerHeight = 180; // Title, description, search bar
|
||||
const bottomPadding = 120; // Load more button area
|
||||
const headerHeight = 200; // Title, breadcrumbs, back button
|
||||
const bottomPadding = 20;
|
||||
const availableHeight = window.innerHeight - headerHeight - bottomPadding;
|
||||
|
||||
// Ensure minimum height and maximum height
|
||||
return Math.max(Math.min(availableHeight, window.innerHeight - 100), 400);
|
||||
}, []);
|
||||
|
||||
if (!currentPath) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden">
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<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">
|
||||
<Folder className="h-10 w-10 text-blue-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Select a Library</h2>
|
||||
<p className="text-zinc-400">
|
||||
Choose a media library from the sidebar to browse your files
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && items.length === 0) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden">
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<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">
|
||||
{type === 'video' ?
|
||||
<Film className="h-8 w-8 text-primary-foreground" /> :
|
||||
<ImageIcon className="h-8 w-8 text-primary-foreground" />
|
||||
}
|
||||
<Folder className="h-8 w-8 text-primary-foreground" />
|
||||
</div>
|
||||
<p className="text-muted-foreground font-medium">Loading {type}s...</p>
|
||||
<p className="text-muted-foreground font-medium">Loading directory...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -305,51 +343,83 @@ export default function VirtualizedMediaGrid({
|
|||
<div ref={containerRef} className="flex-1 flex flex-col max-w-7xl mx-auto w-full overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 p-6 pb-4">
|
||||
{/* Back Button */}
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className={`w-12 h-12 bg-gradient-to-br rounded-xl flex items-center justify-center shadow-lg ${
|
||||
type === 'video' ? 'from-red-500 to-red-600' :
|
||||
type === 'photo' ? 'from-green-500 to-green-600' :
|
||||
'from-blue-500 to-blue-600'
|
||||
}`}>
|
||||
{type === 'video' ?
|
||||
<Film className="h-6 w-6 text-white" /> :
|
||||
type === 'photo' ?
|
||||
<ImageIcon className="h-6 w-6 text-white" /> :
|
||||
<Bookmark className="h-6 w-6 text-white" />
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground tracking-tight capitalize">
|
||||
{type === 'bookmark' ? 'Bookmarked Items' : `${type}s`}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{pagination.total.toLocaleString()} {type === 'bookmark' ? 'item' : type}{pagination.total === 1 ? '' : 's'} in your library
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onBackClick}
|
||||
className="text-zinc-400 hover:text-white hover:bg-zinc-800/50 transition-colors"
|
||||
disabled={!getParentPath(currentPath)}
|
||||
title={getParentPath(currentPath) ? 'Go to parent directory' : 'Already at library root'}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={`Search ${type}s...`}
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="pl-10 bg-background border-border"
|
||||
/>
|
||||
</div>
|
||||
{/* Breadcrumb Navigation */}
|
||||
<nav className="flex items-center flex-wrap gap-2 text-sm font-medium text-zinc-400 mb-4">
|
||||
{breadcrumbs.map((breadcrumb, index) => (
|
||||
<div key={breadcrumb.path} className="flex items-center gap-2">
|
||||
{index > 0 && <span className="text-zinc-600">/</span>}
|
||||
<button
|
||||
onClick={() => onBreadcrumbClick(breadcrumb.path)}
|
||||
className={`hover:text-white transition-colors ${
|
||||
index === breadcrumbs.length - 1
|
||||
? 'text-white font-semibold cursor-default'
|
||||
: 'hover:underline cursor-pointer'
|
||||
}`}
|
||||
disabled={index === breadcrumbs.length - 1}
|
||||
title={breadcrumb.path}
|
||||
>
|
||||
{index === 0 ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Home className="h-3 w-3" />
|
||||
<span>{breadcrumb.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
breadcrumb.name
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Current Directory Title */}
|
||||
<h1 className="text-3xl font-bold text-white tracking-tight">
|
||||
{currentPath ? currentPath.split('/').pop() : 'Libraries'}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Media Grid Container */}
|
||||
{/* Folder Grid Container */}
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
{items.length > 0 && containerWidth > 0 ? (
|
||||
{error ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center max-w-sm">
|
||||
<div className="w-16 h-16 bg-red-900/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Folder className="h-8 w-8 text-red-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-red-400 mb-2">Error Loading Directory</h3>
|
||||
<p className="text-sm text-red-500 mb-4">{error}</p>
|
||||
<Button
|
||||
onClick={() => currentPath && fetchItems(currentPath)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-red-500 text-red-500 hover:bg-red-500/10"
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : items.length > 0 && containerWidth > 0 ? (
|
||||
<div className="h-full relative overflow-hidden">
|
||||
<FixedSizeGrid
|
||||
columnCount={columnCount}
|
||||
columnWidth={columnWidth}
|
||||
height={getAvailableHeight()}
|
||||
rowCount={rowCount}
|
||||
rowHeight={300}
|
||||
rowHeight={ITEM_HEIGHT}
|
||||
width={containerWidth}
|
||||
itemData={items}
|
||||
className="custom-scrollbar"
|
||||
|
|
@ -361,40 +431,15 @@ export default function VirtualizedMediaGrid({
|
|||
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 w-2 h-32 bg-gradient-to-b from-primary/20 via-primary/40 to-primary/20 rounded-full opacity-0 hover:opacity-100 transition-opacity duration-300 pointer-events-none">
|
||||
<div className="w-full h-full bg-gradient-to-b from-primary/60 via-primary to-primary/60 rounded-full scroll-indicator"></div>
|
||||
</div>
|
||||
|
||||
{pagination.hasMore && (
|
||||
<div className="flex justify-center py-8">
|
||||
{isLoadingMore ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
||||
<span className="text-sm text-muted-foreground">Loading more {type === 'bookmark' ? 'items' : `${type}s`}...</span>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={loadMoreItems}
|
||||
disabled={loadingRef.current}
|
||||
className="px-6 py-2"
|
||||
>
|
||||
Load More {type === 'bookmark' ? 'Items' : `${type}s`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center max-w-sm">
|
||||
<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 className="w-16 h-16 bg-slate-100 dark:bg-slate-800 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Folder className="h-8 w-8 text-slate-400" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-2">
|
||||
No {type === 'bookmark' ? 'bookmarked items' : `${type}s`} found
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{searchTerm ? 'Try adjusting your search terms' :
|
||||
type === 'bookmark' ? 'Start bookmarking videos and photos to see them here' :
|
||||
'Add media libraries and scan for content to get started'}
|
||||
</p>
|
||||
<h3 className="text-lg font-semibold text-slate-700 dark:text-slate-300 mb-2">Empty Directory</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">No files or folders found in this location.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
Loading…
Reference in New Issue