mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
Release 2.3.1 (#828)
* chore: bump version to v2.3.1-preview * Fix flashing of game covers * Change sort order of games on the start page (most recently added first) * Add "Date added" column to table in Collection Management
This commit is contained in:
@@ -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 <div style={style}/>;
|
||||
}
|
||||
|
||||
const game = games[gameIndex];
|
||||
const game = gamesData[gameIndex];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -77,10 +89,10 @@ export default function CoverGrid({games}: CoverGridProps) {
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
>
|
||||
<GameCover game={game} interactive={true} size={coverHeight} lazy={true}/>
|
||||
<GameCover key={game.id} game={game} interactive={true} size={height} lazy={true}/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Column width function to handle the last column differently
|
||||
const getColumnWidth = (index: number) => {
|
||||
@@ -94,14 +106,14 @@ export default function CoverGrid({games}: CoverGridProps) {
|
||||
return (
|
||||
<div ref={containerRef} className="w-full">
|
||||
{containerWidth > 0 && (
|
||||
<Grid<{}>
|
||||
<Grid<{ games: GameDto[], columnCount: number, coverHeight: number }>
|
||||
columnCount={columnCount}
|
||||
columnWidth={getColumnWidth}
|
||||
rowCount={rowCount}
|
||||
rowHeight={coverHeight + GAP}
|
||||
defaultWidth={containerWidth}
|
||||
cellComponent={Cell}
|
||||
cellProps={{}}
|
||||
cellProps={{games, columnCount, coverHeight}}
|
||||
style={{overflowX: 'hidden'}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<div style={{...style, paddingRight: gap}}>
|
||||
<GameCover game={game} radius="sm" interactive={true}/>
|
||||
<GameCover key={game.id} game={game} radius="sm" interactive={true}/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col mb-4">
|
||||
@@ -148,7 +151,7 @@ export function CoverRow({games, title, link}: CoverRowProps) {
|
||||
</div>
|
||||
<div ref={containerRef} className="w-full relative overflow-hidden">
|
||||
{containerWidth > 0 && (
|
||||
<Grid<{}>
|
||||
<Grid<{ games: GameDto[] }>
|
||||
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'}}
|
||||
/>
|
||||
|
||||
@@ -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<string | undefined>(undefined);
|
||||
const containerRef = useRef<HTMLDivElement>(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,
|
||||
<Image
|
||||
alt={game.title}
|
||||
className="z-0 object-cover aspect-12/17"
|
||||
src={shouldLoad && isImageLoaded ? `images/cover/${game.cover.id}` : blurhashUrl}
|
||||
src={(shouldLoad || isCached) && isImageLoaded ? `images/cover/${game.cover.id}` : blurhashUrl}
|
||||
radius={radius}
|
||||
height={size}
|
||||
fallbackSrc={<GameCoverFallback title={game.title} size={size} radius={radius}/>}
|
||||
@@ -114,4 +113,14 @@ export function GameCover({game, size = 300, radius = "sm", interactive = false,
|
||||
{coverContent}
|
||||
</a>
|
||||
) : coverContent;
|
||||
}
|
||||
};
|
||||
|
||||
// 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;
|
||||
});
|
||||
|
||||
@@ -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
|
||||
<TableHeader>
|
||||
<TableColumn key="title" allowsSorting>Title</TableColumn>
|
||||
<TableColumn key="library" allowsSorting>Library</TableColumn>
|
||||
<TableColumn key="dateAdded" allowsSorting>Date added</TableColumn>
|
||||
<TableColumn width={1}>Actions</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody
|
||||
@@ -154,6 +158,9 @@ export default function CollectionGamesTable({collectionId}: CollectionGamesTabl
|
||||
{libraryName(game)}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(game.createdAt).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row gap-2">
|
||||
<Tooltip content="Add game to collection">
|
||||
|
||||
@@ -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 (
|
||||
<div className="w-full">
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -65,13 +91,13 @@ export default function HomeView() {
|
||||
}
|
||||
{filteredAndSortedLibraries.map((library) => (
|
||||
<CoverRow key={library.id} title={library.name}
|
||||
games={gamesByLibrary[library.id] || []}
|
||||
games={getSortedLibraryGames(library.id)}
|
||||
link={"/library/" + library.id}
|
||||
/>
|
||||
))}
|
||||
{filteredAndSortedCollections.map((collection) => (
|
||||
<CoverRow key={collection.id} title={collection.name}
|
||||
games={gamesByCollection[collection.id] || []}
|
||||
games={getSortedCollectionGames(collection)}
|
||||
link={"/collection/" + collection.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user