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:
tigeren 2025-08-28 18:56:59 +00:00
parent 6ce4e5a877
commit 44aedcbee6
5 changed files with 579 additions and 99 deletions

View File

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

View File

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

View File

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

View File

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

View File

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