diff --git a/src/renderer/components/virtual-grid/GridCard.tsx b/src/renderer/components/virtual-grid/GridCard.tsx
new file mode 100644
index 0000000..7738cb9
--- /dev/null
+++ b/src/renderer/components/virtual-grid/GridCard.tsx
@@ -0,0 +1,59 @@
+import { Card, Image } from '@mantine/core';
+import { motion } from 'framer-motion';
+import styled from 'styled-components';
+
+const CardWrapper = styled(motion.div)<{
+ itemGap: number;
+ itemHeight: number;
+ itemWidth: number;
+}>`
+ display: flex;
+ flex: ${({ itemWidth }) => `0 0 ${itemWidth}px`};
+ width: 100%;
+ height: ${({ itemHeight }) => itemHeight}px;
+ margin: ${({ itemGap }) => `0 ${itemGap / 2}px`};
+ pointer-events: auto; // https://github.com/bvaughn/react-window/issues/128#issuecomment-460166682
+`;
+
+export const GridCard = ({ data, index, style }: any) => {
+ const { itemHeight, itemWidth, columnCount, itemGap, itemCount, itemData } =
+ data;
+
+ const startIndex = index * columnCount;
+ const stopIndex = Math.min(itemCount - 1, startIndex + columnCount - 1);
+ const cards = [];
+
+ for (let i = startIndex; i <= stopIndex; i += 1) {
+ // if (itemData[i].type === ServerType.Jellyfin) {
+ // const image = getJellyfinImageUrl()
+ // }
+ cards.push(
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {cards}
+
+ );
+};
diff --git a/src/renderer/components/virtual-grid/VirtualGridWrapper.tsx b/src/renderer/components/virtual-grid/VirtualGridWrapper.tsx
new file mode 100644
index 0000000..aa7a8b6
--- /dev/null
+++ b/src/renderer/components/virtual-grid/VirtualGridWrapper.tsx
@@ -0,0 +1,48 @@
+import { Ref, useMemo } from 'react';
+import { FixedSizeList, FixedSizeListProps } from 'react-window';
+import { GridCard } from './GridCard';
+
+export const VirtualGridWrapper = ({
+ refInstance,
+ itemGap,
+ itemWidth,
+ ...rest
+}: Omit & {
+ itemGap: number;
+ itemHeight: number;
+ itemWidth: number;
+ refInstance: Ref;
+}) => {
+ const itemHeight = itemWidth + 55;
+
+ const columnCount = Math.floor(
+ (Number(rest.width) - itemGap + 3) / (itemWidth + itemGap + 2)
+ );
+
+ const rowCount = Math.ceil(rest.itemCount / columnCount);
+
+ const itemData = useMemo(
+ () => ({
+ columnCount,
+ itemCount: rest.itemCount,
+ itemData: rest.itemData,
+ itemGap,
+ itemHeight,
+ itemWidth,
+ }),
+ [columnCount, itemGap, itemHeight, itemWidth, rest.itemCount, rest.itemData]
+ );
+
+ return (
+
+ {GridCard}
+
+ );
+};
diff --git a/src/renderer/components/virtual-grid/VirtualInfiniteGrid.tsx b/src/renderer/components/virtual-grid/VirtualInfiniteGrid.tsx
new file mode 100644
index 0000000..a7afdf6
--- /dev/null
+++ b/src/renderer/components/virtual-grid/VirtualInfiniteGrid.tsx
@@ -0,0 +1,131 @@
+import { forwardRef, Ref, useState } from 'react';
+import debounce from 'lodash/debounce';
+import AutoSizer from 'react-virtualized-auto-sizer';
+import { FixedSizeListProps } from 'react-window';
+import InfiniteLoader from 'react-window-infinite-loader';
+import { VirtualGridWrapper } from './VirtualGridWrapper';
+
+interface VirtualGridProps
+ extends Omit<
+ FixedSizeListProps,
+ 'children' | 'itemSize' | 'height' | 'width'
+ > {
+ itemGap?: number;
+ itemHeight?: number;
+ itemWidth?: number;
+ minimumBatchSize?: number;
+ query: (props: any) => Promise;
+ queryParams?: Record;
+}
+
+export const VirtualInfiniteGrid = forwardRef(
+ (
+ {
+ itemCount,
+ itemGap,
+ itemWidth,
+ itemHeight,
+ minimumBatchSize,
+ query,
+ queryParams,
+ }: VirtualGridProps,
+ ref: Ref
+ ) => {
+ const [itemData, setItemData] = useState([]);
+
+ const isItemLoaded = (index: number, columnCount: number) => {
+ const itemIndex = index * columnCount;
+
+ return (
+ itemIndex < itemData.length * columnCount &&
+ itemData[itemIndex] !== undefined
+ );
+ };
+
+ const loadMoreItems = async (
+ startIndex: number,
+ stopIndex: number,
+ limit: number,
+ columnCount: number
+ ) => {
+ const currentPage = Math.ceil(startIndex / minimumBatchSize!);
+
+ const t = await query({
+ limit,
+ page: currentPage,
+ ...queryParams,
+ });
+
+ // Need to multiply by columnCount due to the grid layout
+ const start = startIndex * columnCount;
+ const end = (stopIndex + 1) * columnCount;
+
+ return new Promise((resolve) => {
+ const newData: any[] = [...itemData];
+
+ let itemIndex = 0;
+ for (let rowIndex = start; rowIndex < end; rowIndex += 1) {
+ newData[rowIndex] = t?.data[itemIndex];
+ itemIndex += 1;
+ }
+
+ setItemData(newData);
+ resolve();
+ });
+ };
+
+ const debouncedLoadMoreItems = debounce(loadMoreItems, 300);
+
+ return (
+
+ {({ height, width }) => {
+ const columnCount = Math.floor(
+ (Number(width) - itemGap! + 3) / (itemWidth! + itemGap! + 2)
+ );
+
+ const pageItemLimit = columnCount * minimumBatchSize!;
+
+ return (
+ isItemLoaded(index, columnCount)}
+ itemCount={itemCount || 0}
+ loadMoreItems={(startIndex, stopIndex) =>
+ debouncedLoadMoreItems(
+ startIndex,
+ stopIndex,
+ pageItemLimit,
+ columnCount
+ )
+ }
+ minimumBatchSize={minimumBatchSize}
+ threshold={10}
+ >
+ {({ onItemsRendered, ref: infiniteLoaderRef }) => (
+
+ )}
+
+ );
+ }}
+
+ );
+ }
+);
+
+VirtualInfiniteGrid.defaultProps = {
+ itemGap: 10,
+ itemHeight: 200,
+ itemWidth: 150,
+ minimumBatchSize: 20,
+ queryParams: {},
+};