nextav/src/components/infinite-virtual-grid.tsx

608 lines
22 KiB
TypeScript

'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<void>;
onUnbookmark: (id: number) => Promise<void>;
onRate: (id: number, rating: number) => Promise<void>;
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<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 <= 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 (
<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, index)}
>
<div className="relative overflow-hidden bg-muted aspect-video">
<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-2.5">
<div className="flex items-start justify-between min-h-[2rem]">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground text-xs line-clamp-2 group-hover:text-primary transition-colors leading-tight">
{item.title || item.path.split('/').pop()}
</h3>
{(item.avg_rating > 0 || item.star_count > 0) && (
<div className="mt-0.5">
<StarRating
rating={item.avg_rating || 0}
count={item.star_count}
size="xs"
showCount={false}
/>
</div>
)}
</div>
<div className="flex gap-1 ml-1 flex-shrink-0">
{type === 'video' && item.bookmark_count > 0 && (
<div className="text-xs text-yellow-500">
<Bookmark className="h-2.5 w-2.5 fill-yellow-500" />
</div>
)}
</div>
</div>
<div className="mt-1 space-y-0.5">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<HardDrive className="h-2.5 w-2.5" />
<span>{formatFileSize(item.size)}</span>
</div>
{type === 'video' && item.bookmark_count > 0 && (
<span className="text-xs text-muted-foreground">
{item.bookmark_count}
</span>
)}
</div>
<p
className="text-xs text-muted-foreground line-clamp-1 cursor-help hover:text-foreground transition-colors"
title={item.path}
>
{formatFilePath(item.path)}
</p>
</div>
</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>
);
}