nextav/src/components/virtualized-media-grid.tsx

363 lines
12 KiB
TypeScript

'use client';
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 { Button } from '@/components/ui/button';
interface MediaItem {
id: number;
title: string;
path: string;
size: number;
thumbnail: string;
type: string;
bookmark_count: number;
avg_rating: number;
star_count: number;
}
interface PaginationInfo {
total: number;
limit: number;
offset: number;
hasMore: boolean;
}
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>;
}
export default function VirtualizedMediaGrid({
type,
onItemClick,
onBookmark,
onUnbookmark,
onRate
}: VirtualizedMediaGridProps) {
const [items, setItems] = useState<MediaItem[]>([]);
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 observerTarget = useRef<HTMLDivElement>(null);
const loadingRef = useRef(false);
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];
};
const formatFilePath = (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 fetchItems = useCallback(async (offset: number, search?: string) => {
if (loadingRef.current) return;
loadingRef.current = true;
setIsLoadingMore(offset > 0);
try {
const params = new URLSearchParams({
limit: '50',
offset: offset.toString(),
...(search && { search })
});
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`;
if (offset === 0) {
setItems(data[itemsKey] || []);
} else {
setItems(prev => [...prev, ...(data[itemsKey] || [])]);
}
setPagination(data.pagination);
} catch (error) {
console.error(`Error fetching ${type}s:`, error);
} 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 loadMoreItems = useCallback(() => {
if (pagination.hasMore && !loadingRef.current) {
fetchItems(pagination.offset + pagination.limit, searchTerm);
}
}, [pagination, searchTerm, fetchItems]);
useEffect(() => {
setLoading(true);
fetchItems(0, searchTerm);
}, [type]);
useEffect(() => {
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting && pagination.hasMore && !loadingRef.current) {
loadMoreItems();
}
},
{ threshold: 0.1 }
);
if (observerTarget.current) {
observer.observe(observerTarget.current);
}
return () => observer.disconnect();
}, [loadMoreItems, pagination.hasMore]);
const Cell = ({ columnIndex, rowIndex, style }: any) => {
const columnCount = 6;
const index = rowIndex * columnCount + columnIndex;
const item = items[index];
if (!item) return null;
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 getColumnCount = () => {
if (typeof window === 'undefined') return 6;
const width = window.innerWidth;
if (width < 640) return 2;
if (width < 768) return 3;
if (width < 1024) return 4;
if (width < 1280) return 5;
if (width < 1536) return 6;
return 7;
};
const columnCount = getColumnCount();
const rowCount = Math.ceil(items.length / columnCount);
if (loading && items.length === 0) {
return (
<div className="min-h-screen p-6">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-center min-h-[60vh]">
<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>
</div>
);
}
return (
<div className="min-h-screen p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-6">
<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>
</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>
</div>
{/* Media Grid */}
{items.length > 0 ? (
<div className="w-full">
<FixedSizeGrid
columnCount={columnCount}
columnWidth={window.innerWidth / columnCount - 16}
height={Math.min(window.innerHeight - 200, rowCount * 300)}
rowCount={rowCount}
rowHeight={300}
width={window.innerWidth - 48}
itemData={items}
>
{Cell}
</FixedSizeGrid>
{pagination.hasMore && (
<div ref={observerTarget} className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
)}
</div>
) : (
<div className="text-center py-20">
<div className="max-w-sm mx-auto">
<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>
);
}