feat: implement infinite scrolling and replace virtualized media grid
- Replaced the existing VirtualizedMediaGrid component with a new InfiniteVirtualGrid component to support infinite scrolling for bookmarks, photos, and videos. - Enhanced media item fetching with batch loading and search functionality, improving user experience and performance. - Removed unused state variables and functions related to photo indexing and file size formatting, streamlining the codebase.
This commit is contained in:
parent
6ce4e5a877
commit
44aedcbee6
60
CLAUDE.md
60
CLAUDE.md
|
|
@ -28,60 +28,6 @@ UI:
|
|||
8. the video card can be clicked, once clicked, a poped up video player will be displayed. it can be closed, fast forward, expand to full screen, etc.
|
||||
9. can bookmark/un-bookmark the video, can star the video
|
||||
|
||||
Performance Optimization Plan
|
||||
|
||||
Phase 1: Critical API Pagination (Immediate Fix)
|
||||
Implement database pagination across all list APIs
|
||||
Add limit/offset parameters to /api/videos, /api/photos, /api/bookmarks
|
||||
Add server-side filtering and sorting
|
||||
Implement cursor-based pagination for better performance
|
||||
Add database indexes for pagination queries
|
||||
Create compound indexes for (type, created_at) queries
|
||||
Add path-based indexes for folder-viewer queries
|
||||
Optimize bookmark/star count queries
|
||||
|
||||
Phase 2: Frontend Memory Optimization
|
||||
Implement virtual scrolling for large lists
|
||||
Use react-window or react-virtualized for video/photo grids
|
||||
Add infinite scroll with intersection observer
|
||||
Implement client-side caching with LRU eviction
|
||||
Add progressive loading strategies
|
||||
Lazy load thumbnails as they come into view
|
||||
Implement skeleton loaders during data fetching
|
||||
Add debounced search with server-side filtering
|
||||
|
||||
Phase 3: File System Scanning Optimization
|
||||
Parallel processing implementation
|
||||
Use worker threads for thumbnail generation
|
||||
Implement batch processing for database inserts
|
||||
Add progress reporting with WebSocket/SSE
|
||||
Smart scanning strategies
|
||||
Implement incremental scanning (only new/changed files)
|
||||
Add file watching for real-time updates
|
||||
Use streaming file discovery instead of loading all paths
|
||||
|
||||
Phase 4: Database Performance
|
||||
Connection pooling and optimization
|
||||
Implement better-sqlite3 connection pooling
|
||||
Add prepared statement caching
|
||||
Implement batch operations for inserts/updates
|
||||
Advanced indexing strategy
|
||||
Full-text search indexes for title/path searching
|
||||
Composite indexes for common query patterns
|
||||
Materialized views for aggregated data (ratings, bookmarks)
|
||||
|
||||
Phase 5: Caching & CDN Strategy
|
||||
Multi-level caching
|
||||
Redis for API response caching
|
||||
Browser caching with ETags
|
||||
CDN for thumbnail delivery
|
||||
Thumbnail optimization
|
||||
WebP format with fallbacks
|
||||
Multiple thumbnail sizes for different viewports
|
||||
Lazy generation with background processing
|
||||
Implementation Priority
|
||||
P0 (Critical) : API pagination + frontend virtual scrolling
|
||||
P1 (High) : Database indexes + connection optimization
|
||||
P2 (Medium) : File scanning improvements
|
||||
P3 (Low) : Advanced caching + CDN
|
||||
This plan addresses all identified bottlenecks systematically, starting with the most critical issues that would cause immediate system failure with large datasets.
|
||||
Development Rules:
|
||||
1. Everytime after making all the changes, run 'pnpm build' to verify the changes are compiling correct.
|
||||
2. Once added debug logs, don't delete it until told so.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import VirtualizedMediaGrid from '@/components/virtualized-media-grid';
|
||||
import InfiniteVirtualGrid from '@/components/infinite-virtual-grid';
|
||||
import VideoViewer from '@/components/video-viewer';
|
||||
import PhotoViewer from '@/components/photo-viewer';
|
||||
|
||||
|
|
@ -19,7 +19,6 @@ interface MediaItem {
|
|||
|
||||
export default function BookmarksPage() {
|
||||
const [selectedItem, setSelectedItem] = useState<MediaItem | null>(null);
|
||||
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
||||
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
||||
const [isVideoPlayerOpen, setIsVideoPlayerOpen] = useState(false);
|
||||
|
||||
|
|
@ -71,17 +70,9 @@ export default function BookmarksPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
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];
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<VirtualizedMediaGrid
|
||||
<InfiniteVirtualGrid
|
||||
type="bookmark"
|
||||
onItemClick={handleItemClick}
|
||||
onBookmark={handleBookmark}
|
||||
|
|
@ -97,7 +88,6 @@ export default function BookmarksPage() {
|
|||
onClose={handleCloseVideoPlayer}
|
||||
showBookmarks={true}
|
||||
showRatings={true}
|
||||
formatFileSize={formatFileSize}
|
||||
onBookmark={handleBookmark}
|
||||
onUnbookmark={handleUnbookmark}
|
||||
onRate={handleRate}
|
||||
|
|
@ -112,7 +102,6 @@ export default function BookmarksPage() {
|
|||
onClose={handleClosePhotoViewer}
|
||||
showBookmarks={true}
|
||||
showRatings={true}
|
||||
formatFileSize={formatFileSize}
|
||||
onBookmark={handleBookmark}
|
||||
onUnbookmark={handleUnbookmark}
|
||||
onRate={handleRate}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import VirtualizedMediaGrid from '@/components/virtualized-media-grid';
|
||||
import InfiniteVirtualGrid from '@/components/infinite-virtual-grid';
|
||||
import PhotoViewer from '@/components/photo-viewer';
|
||||
|
||||
interface Photo {
|
||||
|
|
@ -18,12 +18,10 @@ interface Photo {
|
|||
|
||||
export default function PhotosPage() {
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(null);
|
||||
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
||||
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
||||
|
||||
const handlePhotoClick = (photo: Photo, index: number = 0) => {
|
||||
const handlePhotoClick = (photo: Photo) => {
|
||||
setSelectedPhoto(photo);
|
||||
setCurrentPhotoIndex(index);
|
||||
setIsViewerOpen(true);
|
||||
};
|
||||
|
||||
|
|
@ -32,16 +30,6 @@ export default function PhotosPage() {
|
|||
setSelectedPhoto(null);
|
||||
};
|
||||
|
||||
const handleNextPhoto = () => {
|
||||
// This would need to be implemented with the virtualized grid
|
||||
// For now, we'll keep the current simple behavior
|
||||
};
|
||||
|
||||
const handlePrevPhoto = () => {
|
||||
// This would need to be implemented with the virtualized grid
|
||||
// For now, we'll keep the current simple behavior
|
||||
};
|
||||
|
||||
const handleBookmark = async (photoId: number) => {
|
||||
try {
|
||||
await fetch(`/api/bookmarks/${photoId}`, { method: 'POST' });
|
||||
|
|
@ -70,17 +58,9 @@ export default function PhotosPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
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];
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<VirtualizedMediaGrid
|
||||
<InfiniteVirtualGrid
|
||||
type="photo"
|
||||
onItemClick={handlePhotoClick}
|
||||
onBookmark={handleBookmark}
|
||||
|
|
@ -93,12 +73,8 @@ export default function PhotosPage() {
|
|||
photo={selectedPhoto!}
|
||||
isOpen={isViewerOpen}
|
||||
onClose={handleCloseViewer}
|
||||
onNext={handleNextPhoto}
|
||||
onPrev={handlePrevPhoto}
|
||||
showNavigation={false}
|
||||
showBookmarks={true}
|
||||
showRatings={true}
|
||||
formatFileSize={formatFileSize}
|
||||
onBookmark={handleBookmark}
|
||||
onUnbookmark={handleUnbookmark}
|
||||
onRate={handleRate}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import VirtualizedMediaGrid from "@/components/virtualized-media-grid";
|
||||
import InfiniteVirtualGrid from "@/components/infinite-virtual-grid";
|
||||
import VideoViewer from "@/components/video-viewer";
|
||||
|
||||
interface Video {
|
||||
|
|
@ -68,7 +68,7 @@ const VideosPage = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<VirtualizedMediaGrid
|
||||
<InfiniteVirtualGrid
|
||||
type="video"
|
||||
onItemClick={handleVideoClick}
|
||||
onBookmark={handleBookmark}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,569 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } 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';
|
||||
|
||||
interface MediaItem {
|
||||
id: number;
|
||||
title: string;
|
||||
path: string;
|
||||
size: number;
|
||||
thumbnail: string;
|
||||
type: string;
|
||||
bookmark_count: number;
|
||||
avg_rating: number;
|
||||
star_count: number;
|
||||
}
|
||||
|
||||
interface InfiniteVirtualGridProps {
|
||||
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>;
|
||||
}
|
||||
|
||||
const ITEM_HEIGHT = 300;
|
||||
const ITEMS_PER_BATCH = 50;
|
||||
const BUFFER_SIZE = 200;
|
||||
|
||||
export default function InfiniteVirtualGrid({
|
||||
type,
|
||||
onItemClick,
|
||||
onBookmark,
|
||||
onUnbookmark,
|
||||
onRate
|
||||
}: InfiniteVirtualGridProps) {
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [isLoadingInitial, setIsLoadingInitial] = useState(true);
|
||||
|
||||
const loadingRef = useRef<Set<number>>(new Set());
|
||||
const dataCacheRef = useRef<Map<number, MediaItem[]>>(new Map());
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const gridRef = useRef<any>(null);
|
||||
|
||||
const formatFileSize = useCallback((bytes: number) => {
|
||||
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 formatFilePath = useCallback((path: string) => {
|
||||
if (!path) return '';
|
||||
|
||||
const lastSlashIndex = path.lastIndexOf('/');
|
||||
const lastBackslashIndex = path.lastIndexOf('\\');
|
||||
const lastSeparatorIndex = Math.max(lastSlashIndex, lastBackslashIndex);
|
||||
|
||||
if (lastSeparatorIndex === -1) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const directory = path.substring(0, lastSeparatorIndex);
|
||||
const filename = path.substring(lastSeparatorIndex + 1);
|
||||
|
||||
if (directory.length <= 30) {
|
||||
return `${directory}/${filename}`;
|
||||
}
|
||||
|
||||
const maxDirLength = 25;
|
||||
const startLength = Math.floor(maxDirLength / 2);
|
||||
const endLength = maxDirLength - startLength - 3;
|
||||
|
||||
const truncatedDir = directory.length > maxDirLength
|
||||
? `${directory.substring(0, startLength)}...${directory.substring(directory.length - endLength)}`
|
||||
: directory;
|
||||
|
||||
return `${truncatedDir}/${filename}`;
|
||||
}, []);
|
||||
|
||||
const getColumnCount = useCallback(() => {
|
||||
if (containerWidth === 0) return 6;
|
||||
if (containerWidth < 640) return 2;
|
||||
if (containerWidth < 768) return 3;
|
||||
if (containerWidth < 1024) return 4;
|
||||
if (containerWidth < 1280) return 5;
|
||||
if (containerWidth < 1536) return 6;
|
||||
return 7;
|
||||
}, [containerWidth]);
|
||||
|
||||
const getColumnWidth = useCallback(() => {
|
||||
const cols = getColumnCount();
|
||||
const availableWidth = containerWidth - 32;
|
||||
const gapWidth = (cols - 1) * 16;
|
||||
return Math.floor((availableWidth - gapWidth) / cols);
|
||||
}, [containerWidth, getColumnCount]);
|
||||
|
||||
const rowCount = useMemo(() => {
|
||||
return Math.ceil(totalItems / getColumnCount());
|
||||
}, [totalItems, getColumnCount]);
|
||||
|
||||
const fetchItems = useCallback(async (startIndex: number, endIndex: number) => {
|
||||
const batchStart = Math.floor(startIndex / ITEMS_PER_BATCH) * ITEMS_PER_BATCH;
|
||||
const batchKey = Math.floor(startIndex / ITEMS_PER_BATCH);
|
||||
|
||||
if (loadingRef.current.has(batchKey) || dataCacheRef.current.has(batchKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadingRef.current.add(batchKey);
|
||||
|
||||
try {
|
||||
const limit = Math.min(ITEMS_PER_BATCH, totalItems - batchStart);
|
||||
if (limit <= 0) return;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
limit: limit.toString(),
|
||||
offset: batchStart.toString(),
|
||||
...(searchTerm && { search: searchTerm })
|
||||
});
|
||||
|
||||
const endpoint = type === 'bookmark' ? 'bookmarks' : `${type}s`;
|
||||
const response = await fetch(`/api/${endpoint}?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
const itemsKey = type === 'bookmark' ? 'bookmarks' : `${type}s`;
|
||||
const items = data[itemsKey] || [];
|
||||
|
||||
dataCacheRef.current.set(batchKey, items);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${type}s:`, error);
|
||||
} finally {
|
||||
loadingRef.current.delete(batchKey);
|
||||
}
|
||||
}, [type, searchTerm, totalItems]);
|
||||
|
||||
const fetchTotalCount = useCallback(async () => {
|
||||
try {
|
||||
const endpoint = type === 'bookmark' ? 'bookmarks' : `${type}s`;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
limit: '50',
|
||||
offset: '0',
|
||||
...(searchTerm && { search: searchTerm })
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/${endpoint}?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
const itemsKey = type === 'bookmark' ? 'bookmarks' : `${type}s`;
|
||||
const items = data[itemsKey] || [];
|
||||
|
||||
if (items.length > 0) {
|
||||
dataCacheRef.current.set(0, items);
|
||||
}
|
||||
|
||||
setTotalItems(data.pagination.total || 0);
|
||||
setIsLoadingInitial(false);
|
||||
} catch (error) {
|
||||
console.error(`Error fetching total count for ${type}:`, error);
|
||||
setTotalItems(0);
|
||||
setIsLoadingInitial(false);
|
||||
}
|
||||
}, [type, searchTerm]);
|
||||
|
||||
useEffect(() => {
|
||||
dataCacheRef.current.clear();
|
||||
loadingRef.current.clear();
|
||||
setTotalItems(0);
|
||||
setIsLoadingInitial(true);
|
||||
fetchTotalCount();
|
||||
}, [type, searchTerm, fetchTotalCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateWidth = () => {
|
||||
if (containerRef.current) {
|
||||
const width = containerRef.current.offsetWidth;
|
||||
setContainerWidth(width);
|
||||
}
|
||||
};
|
||||
|
||||
// Use ResizeObserver for more reliable width detection
|
||||
const resizeObserver = new ResizeObserver(entries => {
|
||||
if (entries[0]) {
|
||||
setContainerWidth(entries[0].contentRect.width);
|
||||
}
|
||||
});
|
||||
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
updateWidth(); // Initial measurement
|
||||
}
|
||||
|
||||
// Window resize fallback
|
||||
const handleResize = () => {
|
||||
updateWidth();
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Fallback to manual measurement if ResizeObserver not available
|
||||
const fallbackUpdate = () => {
|
||||
setTimeout(updateWidth, 100);
|
||||
};
|
||||
fallbackUpdate();
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Aggressive width detection fallback
|
||||
useEffect(() => {
|
||||
const aggressiveWidthCheck = () => {
|
||||
// Try to find any suitable container
|
||||
const containers = [
|
||||
containerRef.current,
|
||||
document.querySelector('.flex-1') as HTMLElement,
|
||||
document.querySelector('.max-w-7xl') as HTMLElement,
|
||||
document.querySelector('.w-full') as HTMLElement
|
||||
].filter(Boolean);
|
||||
|
||||
for (const container of containers) {
|
||||
if (container) {
|
||||
const width = (container as HTMLElement).offsetWidth || (container as HTMLElement).clientWidth;
|
||||
if (width > 0) {
|
||||
setContainerWidth(width);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// As a last resort, use window width minus some padding
|
||||
const fallbackWidth = Math.max(window.innerWidth - 64, 320);
|
||||
setContainerWidth(fallbackWidth);
|
||||
};
|
||||
|
||||
// Try multiple times with increasing delays
|
||||
aggressiveWidthCheck();
|
||||
const timeouts = [
|
||||
setTimeout(aggressiveWidthCheck, 50),
|
||||
setTimeout(aggressiveWidthCheck, 100),
|
||||
setTimeout(aggressiveWidthCheck, 500)
|
||||
];
|
||||
|
||||
return () => {
|
||||
timeouts.forEach(timeout => clearTimeout(timeout));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getItemData = useCallback((index: number): MediaItem | null => {
|
||||
if (index >= totalItems) return null;
|
||||
|
||||
const batchKey = Math.floor(index / ITEMS_PER_BATCH);
|
||||
const batchItems = dataCacheRef.current.get(batchKey);
|
||||
|
||||
if (!batchItems) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const itemIndex = index % ITEMS_PER_BATCH;
|
||||
return batchItems[itemIndex] || null;
|
||||
}, [totalItems]);
|
||||
|
||||
const handleItemsRendered = useCallback((params: any) => {
|
||||
const { visibleRowStartIndex, visibleRowStopIndex } = params;
|
||||
|
||||
console.log('onItemsRendered triggered:', { visibleRowStartIndex, visibleRowStopIndex, totalItems, columnCount: getColumnCount() });
|
||||
|
||||
if (typeof visibleRowStartIndex !== 'number' || typeof visibleRowStopIndex !== 'number') {
|
||||
console.log('Invalid indices received');
|
||||
return;
|
||||
}
|
||||
|
||||
if (totalItems === 0) {
|
||||
console.log('No total items');
|
||||
return;
|
||||
}
|
||||
|
||||
const columnCount = getColumnCount();
|
||||
|
||||
// Convert row indices to absolute item indices
|
||||
const startIndex = visibleRowStartIndex * columnCount;
|
||||
const endIndex = Math.min((visibleRowStopIndex + 1) * columnCount - 1, totalItems - 1);
|
||||
|
||||
console.log('Calculated indices:', { startIndex, endIndex, columnCount });
|
||||
|
||||
// Check what batches we need to load
|
||||
const startBatch = Math.floor(startIndex / ITEMS_PER_BATCH);
|
||||
const endBatch = Math.floor(endIndex / ITEMS_PER_BATCH);
|
||||
|
||||
console.log('Batch range needed:', { startBatch, endBatch });
|
||||
|
||||
// Load missing batches
|
||||
let loadedAny = false;
|
||||
for (let batch = startBatch; batch <= endBatch; batch++) {
|
||||
const batchStart = batch * ITEMS_PER_BATCH;
|
||||
const batchEnd = Math.min((batch + 1) * ITEMS_PER_BATCH, totalItems);
|
||||
|
||||
if (!dataCacheRef.current.has(batch) && !loadingRef.current.has(batch)) {
|
||||
console.log(`Loading batch ${batch}: ${batchStart}-${batchEnd}`);
|
||||
fetchItems(batchStart, batchEnd);
|
||||
loadedAny = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check if we need to load more for infinite scroll
|
||||
if (!loadedAny) {
|
||||
const lastLoadedBatch = Math.max(...Array.from(dataCacheRef.current.keys()), -1);
|
||||
const nextBatch = lastLoadedBatch + 1;
|
||||
const nextBatchStart = nextBatch * ITEMS_PER_BATCH;
|
||||
|
||||
if (nextBatchStart < totalItems && endIndex + BUFFER_SIZE >= nextBatchStart) {
|
||||
const batchEnd = Math.min(nextBatchStart + ITEMS_PER_BATCH, totalItems);
|
||||
console.log(`Loading next batch for infinite scroll ${nextBatch}: ${nextBatchStart}-${batchEnd}`);
|
||||
fetchItems(nextBatchStart, batchEnd);
|
||||
}
|
||||
}
|
||||
}, [totalItems, fetchItems, getColumnCount]);
|
||||
|
||||
const Cell = ({ columnIndex, rowIndex, style }: any) => {
|
||||
const columnCount = getColumnCount();
|
||||
const index = rowIndex * columnCount + columnIndex;
|
||||
|
||||
if (index >= totalItems) return null;
|
||||
|
||||
const item = getItemData(index);
|
||||
|
||||
if (!item) {
|
||||
return (
|
||||
<div style={style} className="p-2">
|
||||
<Card className="h-full animate-pulse bg-muted/50">
|
||||
<div className={`${type === 'video' ? 'aspect-video' : 'aspect-square'} bg-muted`} />
|
||||
<CardContent className="p-3">
|
||||
<div className="h-4 bg-muted rounded mb-2" />
|
||||
<div className="h-3 bg-muted rounded mb-1" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="p-3">
|
||||
<h3 className="font-medium text-foreground text-sm line-clamp-2 mb-2 group-hover:text-primary transition-colors">
|
||||
{item.title || item.path.split('/').pop()}
|
||||
</h3>
|
||||
|
||||
{(item.avg_rating > 0 || item.star_count > 0) && (
|
||||
<div className="mb-2">
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getAvailableHeight = useCallback(() => {
|
||||
if (typeof window === 'undefined') return 600;
|
||||
const headerHeight = 180;
|
||||
const bottomPadding = 20;
|
||||
return Math.max(window.innerHeight - headerHeight - bottomPadding, 400);
|
||||
}, []);
|
||||
|
||||
if (isLoadingInitial) {
|
||||
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" />
|
||||
}
|
||||
</div>
|
||||
<p className="text-muted-foreground font-medium">Loading {type}s...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (totalItems === 0) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden">
|
||||
<div ref={containerRef} className="flex-1 flex flex-col max-w-7xl mx-auto w-full overflow-hidden">
|
||||
<div className="flex-shrink-0 p-6 pb-4">
|
||||
<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">
|
||||
0 {type === 'bookmark' ? 'item' : type}{type === 'bookmark' || type === 'photo' ? 's' : ''} in your library
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 bg-background border-border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden">
|
||||
<div ref={containerRef} className="flex-1 flex flex-col max-w-7xl mx-auto w-full overflow-hidden">
|
||||
<div className="flex-shrink-0 p-6 pb-4">
|
||||
<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">
|
||||
{totalItems.toLocaleString()} {type === 'bookmark' ? 'item' : type}{totalItems === 1 ? '' : 's'} in your library
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 bg-background border-border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={containerRef} className="flex-1 relative overflow-hidden">
|
||||
{containerWidth > 0 && (
|
||||
<FixedSizeGrid
|
||||
ref={gridRef}
|
||||
columnCount={getColumnCount()}
|
||||
columnWidth={getColumnWidth()}
|
||||
height={getAvailableHeight()}
|
||||
rowCount={rowCount}
|
||||
rowHeight={ITEM_HEIGHT}
|
||||
width={containerWidth}
|
||||
itemData={null}
|
||||
onItemsRendered={handleItemsRendered}
|
||||
overscanRowCount={5}
|
||||
>
|
||||
{Cell}
|
||||
</FixedSizeGrid>
|
||||
)}
|
||||
{containerWidth === 0 && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue