'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, index?: number) => void; onBookmark: (id: number) => Promise; onUnbookmark: (id: number) => Promise; onRate: (id: number, rating: number) => Promise; onDataUpdate?: (items: MediaItem[]) => void; } const ITEM_HEIGHT = 220; const ITEMS_PER_BATCH = 50; const BUFFER_SIZE = 200; export default function InfiniteVirtualGrid({ type, onItemClick, onBookmark, onUnbookmark, onRate, onDataUpdate }: InfiniteVirtualGridProps) { const [totalItems, setTotalItems] = useState(0); const [searchTerm, setSearchTerm] = useState(''); const [containerWidth, setContainerWidth] = useState(0); const [isLoadingInitial, setIsLoadingInitial] = useState(true); const loadingRef = useRef>(new Set()); const dataCacheRef = useRef>(new Map()); const containerRef = useRef(null); const gridRef = useRef(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 <= 40) { return `${directory}/${filename}`; } const maxDirLength = 35; 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); } } // Collect all available items and notify parent if (onDataUpdate) { const allItems: MediaItem[] = []; const sortedKeys = Array.from(dataCacheRef.current.keys()).sort((a, b) => a - b); for (const batchKey of sortedKeys) { const items = dataCacheRef.current.get(batchKey); if (items) { allItems.push(...items); } } if (allItems.length > 0) { onDataUpdate(allItems); } } }, [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 (
); } return (
onItemClick(item, index)} >
{item.title} { (e.target as HTMLImageElement).src = type === 'video' ? "/placeholder-video.svg" : "/placeholder-photo.svg"; }} />
{type === 'video' ? : }
{type === 'video' ? : }

{item.title || item.path.split('/').pop()}

{(item.avg_rating > 0 || item.star_count > 0) && (
)}
{type === 'video' && item.bookmark_count > 0 && (
)}
{formatFileSize(item.size)}
{type === 'video' && item.bookmark_count > 0 && ( {item.bookmark_count} )}

{formatFilePath(item.path)}

); }; 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 (
{type === 'video' ? : }

Loading {type}s...

); } if (totalItems === 0) { return (
{type === 'video' ? : type === 'photo' ? : }

{type === 'bookmark' ? 'Bookmarked Items' : `${type}s`}

0 {type === 'bookmark' ? 'item' : type}{type === 'bookmark' || type === 'photo' ? 's' : ''} in your library

setSearchTerm(e.target.value)} className="pl-10 bg-background border-border" />

No {type === 'bookmark' ? 'bookmarked items' : `${type}s`} found

{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'}

); } return (
{type === 'video' ? : type === 'photo' ? : }

{type === 'bookmark' ? 'Bookmarked Items' : `${type}s`}

{totalItems.toLocaleString()} {type === 'bookmark' ? 'item' : type}{totalItems === 1 ? '' : 's'} in your library

setSearchTerm(e.target.value)} className="pl-10 bg-background border-border" />
{containerWidth > 0 && ( {Cell} )} {containerWidth === 0 && (
)}
); }