diff --git a/app/src/main/frontend/components/general/covers/CoverGrid.tsx b/app/src/main/frontend/components/general/covers/CoverGrid.tsx index a77a9ad..1424382 100644 --- a/app/src/main/frontend/components/general/covers/CoverGrid.tsx +++ b/app/src/main/frontend/components/general/covers/CoverGrid.tsx @@ -1,13 +1,21 @@ import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto"; import {GameCover} from "Frontend/components/general/covers/GameCover"; -import type {CellComponentProps} from "react-window"; import {Grid} from "react-window"; -import {useEffect, useRef, useState} from "react"; +import React, {useCallback, useEffect, useRef, useState} from "react"; interface CoverGridProps { games: GameDto[]; } +interface GridCellProps { + columnIndex: number; + rowIndex: number; + style: React.CSSProperties; + games: GameDto[]; + columnCount: number; + coverHeight: number; +} + // Constants for grid layout const MIN_COLUMN_WIDTH = 180; // Minimum width per item (minmax value from original) const MAX_COLUMN_WIDTH = 212; // Maximum width per item (minmax value from original) @@ -52,20 +60,24 @@ export default function CoverGrid({games}: CoverGridProps) { // Calculate row count const rowCount = Math.ceil(games.length / columnCount); + // Cell renderer for react-window Grid - const Cell = ({ - columnIndex, - rowIndex, - style - }: CellComponentProps<{}>) => { - const gameIndex = rowIndex * columnCount + columnIndex; + const Cell = useCallback(({ + columnIndex, + rowIndex, + style, + games: gamesData, + columnCount: colCount, + coverHeight: height + }: GridCellProps) => { + const gameIndex = rowIndex * colCount + columnIndex; // Return empty cell if we're past the end of the games array - if (gameIndex >= games.length) { + if (gameIndex >= gamesData.length) { return
; } - const game = games[gameIndex]; + const game = gamesData[gameIndex]; return (
- +
); - }; + }, []); // Column width function to handle the last column differently const getColumnWidth = (index: number) => { @@ -94,14 +106,14 @@ export default function CoverGrid({games}: CoverGridProps) { return (
{containerWidth > 0 && ( - + columnCount={columnCount} columnWidth={getColumnWidth} rowCount={rowCount} rowHeight={coverHeight + GAP} defaultWidth={containerWidth} cellComponent={Cell} - cellProps={{}} + cellProps={{games, columnCount, coverHeight}} style={{overflowX: 'hidden'}} /> )} diff --git a/app/src/main/frontend/components/general/covers/CoverRow.tsx b/app/src/main/frontend/components/general/covers/CoverRow.tsx index 3216566..c28a462 100644 --- a/app/src/main/frontend/components/general/covers/CoverRow.tsx +++ b/app/src/main/frontend/components/general/covers/CoverRow.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useRef, useState} from "react"; +import React, {useCallback, useEffect, useRef, useState} from "react"; import {GameCover} from "Frontend/components/general/covers/GameCover"; import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto"; import {CaretLeftIcon, CaretRightIcon} from "@phosphor-icons/react"; @@ -100,20 +100,23 @@ export function CoverRow({games, title, link}: CoverRowProps) { const canScrollLeft = scrollPosition > 1; // Allow small margin for floating point issues const canScrollRight = scrollPosition < maxScroll - 1 && maxScroll > 0; - // Cell renderer for react-window Grid - const Cell = ({columnIndex, style}: { - ariaAttributes: { "aria-colindex": number; role: "gridcell" }; + // Define interface for Cell props + interface RowCellProps { columnIndex: number; rowIndex: number; style: React.CSSProperties; - }) => { - const game = games[columnIndex]; + games: GameDto[]; + } + + // Cell renderer for react-window Grid + const Cell = useCallback(({columnIndex, style, games: gamesData}: RowCellProps) => { + const game = gamesData[columnIndex]; return (
- +
); - }; + }, []); return (
@@ -148,7 +151,7 @@ export function CoverRow({games, title, link}: CoverRowProps) {
{containerWidth > 0 && ( - + gridRef={gridRef} columnCount={games.length} columnWidth={defaultImageWidth + gap} @@ -157,7 +160,7 @@ export function CoverRow({games, title, link}: CoverRowProps) { defaultHeight={defaultImageHeight} defaultWidth={containerWidth} cellComponent={Cell} - cellProps={{}} + cellProps={{games}} className="scrollbar-hide" style={{overflow: 'auto'}} /> diff --git a/app/src/main/frontend/components/general/covers/GameCover.tsx b/app/src/main/frontend/components/general/covers/GameCover.tsx index e17e3f4..58567e0 100644 --- a/app/src/main/frontend/components/general/covers/GameCover.tsx +++ b/app/src/main/frontend/components/general/covers/GameCover.tsx @@ -1,7 +1,7 @@ import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto"; import {Image} from "@heroui/react"; import {GameCoverFallback} from "Frontend/components/general/covers/GameCoverFallback"; -import {useEffect, useRef, useState} from "react"; +import {memo, useEffect, useRef, useState} from "react"; import {decode} from "blurhash"; // Cache to track which images have been loaded across component remounts @@ -15,18 +15,17 @@ interface GameCoverProps { lazy?: boolean; } -export function GameCover({game, size = 300, radius = "sm", interactive = false, lazy = false}: GameCoverProps) { +const GameCoverComponent = ({game, size = 300, radius = "sm", interactive = false, lazy = false}: GameCoverProps) => { const [shouldLoad, setShouldLoad] = useState(!lazy); // Check cache to see if this image has already been loaded - const [isImageLoaded, setIsImageLoaded] = useState( - game.cover ? loadedImagesCache.has(game.cover.id) : false - ); + const isCached = game.cover ? loadedImagesCache.has(game.cover.id) : false; + const [isImageLoaded, setIsImageLoaded] = useState(isCached); const [blurhashUrl, setBlurhashUrl] = useState(undefined); const containerRef = useRef(null); // Generate blurhash placeholder image useEffect(() => { - if (game.cover?.blurhash) { + if (game.cover?.blurhash && !blurhashUrl) { try { // Decode blurhash to pixel data const pixels = decode(game.cover.blurhash, 32, 45); // Small size for placeholder @@ -49,7 +48,7 @@ export function GameCover({game, size = 300, radius = "sm", interactive = false, console.error('Failed to decode blurhash:', e); } } - }, [game.cover?.blurhash]); + }, [game.cover?.blurhash, blurhashUrl]); useEffect(() => { if (!lazy || shouldLoad) return; @@ -99,7 +98,7 @@ export function GameCover({game, size = 300, radius = "sm", interactive = false, {game.title}} @@ -114,4 +113,14 @@ export function GameCover({game, size = 300, radius = "sm", interactive = false, {coverContent} ) : coverContent; -} \ No newline at end of file +}; + +// Memoize the component to prevent unnecessary re-renders +// Only re-render if the game ID, size, radius, interactive, or lazy props change +export const GameCover = memo(GameCoverComponent, (prevProps, nextProps) => { + return prevProps.game.id === nextProps.game.id && + prevProps.size === nextProps.size && + prevProps.radius === nextProps.radius && + prevProps.interactive === nextProps.interactive && + prevProps.lazy === nextProps.lazy; +}); diff --git a/app/src/main/frontend/components/general/modals/CollectionGamesTable.tsx b/app/src/main/frontend/components/general/modals/CollectionGamesTable.tsx index bb9f4f5..3a51fa0 100644 --- a/app/src/main/frontend/components/general/modals/CollectionGamesTable.tsx +++ b/app/src/main/frontend/components/general/modals/CollectionGamesTable.tsx @@ -80,6 +80,9 @@ export default function CollectionGamesTable({collectionId}: CollectionGamesTabl case "library": cmp = (libraryName(a)).localeCompare(libraryName(b)); break; + case "dateAdded": + cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + break; default: cmp = 0; } @@ -130,6 +133,7 @@ export default function CollectionGamesTable({collectionId}: CollectionGamesTabl Title Library + Date added Actions + + {new Date(game.createdAt).toLocaleString()} +
diff --git a/app/src/main/frontend/views/HomeView.tsx b/app/src/main/frontend/views/HomeView.tsx index cad656f..daad9a9 100644 --- a/app/src/main/frontend/views/HomeView.tsx +++ b/app/src/main/frontend/views/HomeView.tsx @@ -39,6 +39,32 @@ export default function HomeView() { }, [librariesState.sorted, collectionsState.sorted, gamesByLibrary, gamesByCollection]); + // Sort games by date added (newest first) for libraries + const getSortedLibraryGames = (libraryId: number) => { + const games = gamesByLibrary[libraryId] || []; + return [...games].sort((a, b) => { + const dateA = new Date(a.createdAt).getTime(); + const dateB = new Date(b.createdAt).getTime(); + return dateB - dateA; // Descending order (newest first) + }); + }; + + // Sort games by date added (newest first) for collections + const getSortedCollectionGames = (collection: CollectionDto) => { + const games = gamesByCollection[collection.id] || []; + const gamesAddedAt = collection.metadata?.gamesAddedAt || {}; + + return [...games].sort((a, b) => { + const dateA = gamesAddedAt[a.id.toString()] + ? new Date(gamesAddedAt[a.id.toString()]).getTime() + : 0; + const dateB = gamesAddedAt[b.id.toString()] + ? new Date(gamesAddedAt[b.id.toString()]).getTime() + : 0; + return dateB - dateA; // Descending order (newest first) + }); + }; + return (
@@ -65,13 +91,13 @@ export default function HomeView() { } {filteredAndSortedLibraries.map((library) => ( ))} {filteredAndSortedCollections.map((collection) => ( ))} diff --git a/build.gradle.kts b/build.gradle.kts index de73800..e4009f6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile import java.nio.file.Files group = "org.gameyfin" -version = "2.3.0" +version = "2.3.1-preview" allprojects { repositories {