Release 2.3.0 (#804)

* chore: bump version to v2.3.0-preview

* Customize start page (#803)

* Update ConfigService to support complex Objects
Implemented tests for ConfigService

* Added DB migration for config table

* Fixed version in banner.txt not being displayed

* Implement Library ordering
Implement "Show recently added games on homepage"

* Fix build.gradle.kts

* FIx bug when creating libraries

* Fix TypeScript errors
Fix library sorting

* Bump actions/checkout from 5 to 6 (#811)

Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Added automatic scanning using file system watchers (#813)

* Implement collections (#814)

* Backend implementation for collections

* Fix database schema and migration script

* Refactor some config values
Fix ArrayInput not being deactivatable

* Remove "AutoRegisterNewUsers" config option

* Fix bug when removing ignored paths

* Add UI for collections (WIP)

* Fix table actions not synced with state
Fix tests

* Finish implementation of collection feature

* Fix tests

* Bump actions/checkout from 5 to 6 (#815)

Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Fix "allow guests to create game requests" not being enabled when guest access is activated

* Fix: Disable loading of EditGameMetadataModal and MatchGameModal in GameView when user is not admin

* Bump actions/checkout from 5 to 6 (#819)

Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Overhaul startpage (#823)

* WIP: Update start page layout

* Performance improvements (lazy loading and virtualized grids/lists)
Fix various smaller issues

* Implement use of blurhash for all images in backend and covers in frontend

* Fix bugs and test

* Fix code analysis issues

* Remove "UI settings" since they have been made obsolete

* Remove length limit from "image.originalUrl" (#824)

* Remove alpine based image (#825)

* Fix bug when games from library are still in a collection, thus prevention deletion of said library

* Delete image files in background

* Fix layout

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
Simon
2025-12-10 12:58:14 +01:00
committed by GitHub
parent 608a0b5ac1
commit 09953a3f78
142 changed files with 5249 additions and 937 deletions
@@ -11,16 +11,15 @@ import {
} from "@heroui/react";
import {useSnapshot} from "valtio/react";
import {scanState} from "Frontend/state/ScanState";
import LibraryScanProgress from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanProgress";
import {libraryState} from "Frontend/state/LibraryState";
import { TargetIcon, WarningIcon } from "@phosphor-icons/react";
import {TargetIcon, WarningIcon} from "@phosphor-icons/react";
import {timeBetween, timeUntil, toTitleCase} from "Frontend/util/utils";
import LibraryScanStatus from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanStatus";
import {useEffect, useState} from "react";
export default function ScanProgressPopover() {
const libraries = useSnapshot(libraryState).state;
const scans = useSnapshot(scanState).sortedByStartTime as LibraryScanProgress[];
const scans = useSnapshot(scanState).sortedByStartTime;
const scanInProgress = useSnapshot(scanState).isScanning;
// Add state to track current time and force re-renders
@@ -50,7 +49,7 @@ export default function ScanProgressPopover() {
</Button>
</PopoverTrigger>
<PopoverContent>
<div className="flex flex-col gap-2 m-2 min-w-96 w-fit">
<div className="flex flex-col gap-2 m-2 min-w-md">
{scans.length === 0 ?
<p className="flex h-12 items-center justify-center text-sm text-default-500">
No scans in progress or in history.
@@ -59,12 +58,12 @@ export default function ScanProgressPopover() {
{scans.map((scan, index) =>
<div className="flex flex-col" key={scan.scanId}>
<div
className="flex flex-row justify-between items-center text-default-500 mb-1">
className="flex flex-row gap-4 justify-between items-center text-default-500 mb-1">
<p>{toTitleCase(scan.type)} scan for library&nbsp;
<Link underline="always"
color="foreground"
size="sm"
href={`/administration/libraries/library/${scan.libraryId}`}>
href={`/administration/games/library/${scan.libraryId}`}>
{libraries[scan.libraryId].name}
</Link>
</p>
@@ -1,8 +1,7 @@
import {Autocomplete, AutocompleteItem} from "@heroui/react";
import { CaretRightIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
import {CaretRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {useNavigate} from "react-router";
import {GameCover} from "Frontend/components/general/covers/GameCover";
@@ -10,7 +9,7 @@ export default function SearchBar() {
const navigate = useNavigate();
const state = useSnapshot(gameState);
const games = state.games as GameDto[];
const games = state.games;
return <Autocomplete
aria-label="Search for games"
@@ -0,0 +1,75 @@
import {Button, Card, Tooltip} from "@heroui/react";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import React, {useEffect, useState} from "react";
import {GameCover} from "Frontend/components/general/covers/GameCover";
import CollectionAdminDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionAdminDto";
import {SlidersHorizontalIcon} from "@phosphor-icons/react";
import {useNavigate} from "react-router";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
import ChipList from "Frontend/components/general/ChipList";
interface CollectionOverviewCardProps {
collection: CollectionAdminDto;
}
export function CollectionOverviewCard({collection}: CollectionOverviewCardProps) {
const MAX_COVER_COUNT = 5;
const navigate = useNavigate();
const state = useSnapshot(gameState);
const [randomGames, setRandomGames] = useState<GameDto[]>([]);
useEffect(() => {
if (!state.randomlyOrderedGamesByCollectionId) return;
setRandomGames(getRandomGames());
}, [state]);
function getRandomGames() {
if (!state.randomlyOrderedGamesByCollectionId[collection.id]) return [];
const games = state.randomlyOrderedGamesByCollectionId[collection.id]
.filter(game => game.cover?.id != null);
if (!games) return [];
return games.slice(0, MAX_COVER_COUNT);
}
return (
<Card className="flex flex-col justify-between w-[353px]">
<div className="flex flex-1 justify-center items-center">
<div className="flex flex-1 opacity-10 min-h-[100px]">
<IconBackgroundPattern/>
{randomGames.length > 0 &&
<div className="absolute flex flex-row">
{randomGames.map((game) => (
<GameCover game={game} size={100} radius="none" key={game.cover?.id}/>
))}
</div>
}
</div>
<p className="absolute text-2xl font-bold">{collection.name}</p>
<div className="absolute right-0 top-0 flex flex-row">
<Tooltip content="Configuration" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={() => navigate('collection/' + collection.id)}>
<SlidersHorizontalIcon/>
</Button>
</Tooltip>
</div>
</div>
{collection.stats &&
<div className="grid grid-rows-2 grid-cols-3 justify-items-center items-center p-2 pt-4">
<p>Games</p>
<p>Downloads</p>
<p>Platforms</p>
<p className="font-bold">{collection.stats.gamesCount}</p>
<p className="font-bold">{collection.stats.downloadCount}</p>
<ChipList items={collection.stats.gamePlatforms} maxVisible={0}
defaultContent={collection.stats.gamesCount > 0 ? "All" : "None"}/>
</div>
}
</Card>
);
}
@@ -1,5 +1,4 @@
import {Button, Card, Tooltip} from "@heroui/react";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import React from "react";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {GameCover} from "Frontend/components/general/covers/GameCover";
@@ -23,7 +22,9 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
const randomGames = getRandomGames();
function getRandomGames() {
const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[];
if (!state.randomlyOrderedGamesByLibraryId[library.id]) return [];
const games = state.randomlyOrderedGamesByLibraryId[library.id]
.filter(game => game.cover?.id != null);
if (!games) return [];
return games.slice(0, MAX_COVER_COUNT);
}
@@ -40,7 +41,7 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
{randomGames.length > 0 &&
<div className="absolute flex flex-row">
{randomGames.map((game) => (
<GameCover game={game} size={100} radius="none" key={game.coverId}/>
<GameCover game={game} size={100} radius="none" key={game.cover?.id}/>
))}
</div>
}
@@ -1,5 +1,20 @@
import {Button, Card, Chip, Tooltip, useDisclosure} from "@heroui/react";
import { CheckCircleIcon, IconContext, PauseCircleIcon, PlayCircleIcon, PowerIcon, QuestionIcon, QuestionMarkIcon, SealCheckIcon, SealQuestionIcon, SealWarningIcon, SlidersHorizontalIcon, StopCircleIcon, WarningCircleIcon, XCircleIcon } from "@phosphor-icons/react";
import {
CheckCircleIcon,
IconContext,
PauseCircleIcon,
PlayCircleIcon,
PowerIcon,
QuestionIcon,
QuestionMarkIcon,
SealCheckIcon,
SealQuestionIcon,
SealWarningIcon,
SlidersHorizontalIcon,
StopCircleIcon,
WarningCircleIcon,
XCircleIcon
} from "@phosphor-icons/react";
import PluginState from "Frontend/generated/org/pf4j/PluginState";
import React, {ReactNode} from "react";
import PluginDetailsModal from "Frontend/components/general/modals/PluginDetailsModal";
@@ -105,11 +120,11 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
return state === PluginState.DISABLED;
}
function togglePluginEnabled() {
async function togglePluginEnabled() {
if (isDisabled(plugin.state)) {
PluginEndpoint.enablePlugin(plugin.id);
await PluginEndpoint.enablePlugin(plugin.id);
} else {
PluginEndpoint.disablePlugin(plugin.id);
await PluginEndpoint.disablePlugin(plugin.id);
}
}
@@ -0,0 +1,84 @@
import {Card, Chip, Image} from "@heroui/react";
import React, {useMemo} from "react";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import CollectionDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionDto";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import Rand from "rand-seed";
import {useNavigate} from "react-router";
interface StartPageDisplayCardProps {
item: LibraryDto | CollectionDto;
}
export function StartPageDisplayCard({item}: StartPageDisplayCardProps) {
const navigate = useNavigate();
const isCollection = (libraryOrCollection: LibraryDto | CollectionDto): libraryOrCollection is CollectionDto => {
return 'description' in libraryOrCollection;
};
const isLibrary = (libraryOrCollection: LibraryDto | CollectionDto): libraryOrCollection is LibraryDto => {
return !('description' in libraryOrCollection);
};
const gamesState = useSnapshot(gameState);
const randomImageId = useMemo<number | null>(() => getRandomImageId(), [item]);
const link = useMemo<string>(() => getLink(), [item]);
const type = isCollection(item) ? 'Collection' : 'Library';
/**
* Gets a random cover ID from the games in the specified library or collection.
* Since the Random class is seeded with the game ID, the same game and image will always be selected for a given library/collection (unless the games inside change).
* @return {number | null} The random cover ID or null if none found.
*/
function getRandomImageId(): number | null {
let games: GameDto[] = [];
if (isCollection(item)) {
games = gamesState.randomlyOrderedGamesByCollectionId[item.id] as GameDto[];
} else if (isLibrary(item)) {
games = gamesState.randomlyOrderedGamesByLibraryId[item.id] as GameDto[];
}
if (!games || games.length == 0) return null;
// Find the first game that has at least one screenshot available
let game: GameDto | undefined = games.find(game => game.images && game.images.length > 0);
if (!game) return null;
const random = new Rand(`${item.id}-${game.id}`);
const randomImageIndex = Math.floor(random.next() * game.images!.length);
return game.images![randomImageIndex].id;
}
function getLink(): string {
if (isCollection(item)) {
return `/collection/${item.id}`;
} else if (isLibrary(item)) {
return `/library/${item.id}`;
}
return '#';
}
return randomImageId && (
<Card isPressable={true}
onPress={() => navigate(link)}
className="h-48 w-96 relative overflow-hidden scale-95 hover:scale-100 shine transition-all select-none">
<Image
src={`images/cover/${randomImageId}`}
className="absolute inset-0 w-full h-full object-cover brightness-40 z-0"
removeWrapper
/>
<div className="flex flex-col gap-1 relative z-10 items-center justify-center h-full">
<h2 className="text-white text-2xl font-bold text-center px-4">
{item.name}
</h2>
<Chip size="sm" radius="sm">{type}</Chip>
</div>
</Card>
);
}
@@ -0,0 +1,57 @@
import CollectionAdminDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionAdminDto";
import React, {useEffect, useState} from "react";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
import {Card} from "@heroui/react";
interface CollectionHeaderProps {
collection: CollectionAdminDto;
className?: string;
}
export default function CollectionHeader({collection, className}: CollectionHeaderProps) {
const MAX_COVER_COUNT = 5;
const state = useSnapshot(gameState);
const [randomGames, setRandomGames] = useState<GameDto[]>([]);
useEffect(() => {
if (!state.randomlyOrderedGamesByCollectionId) return;
setRandomGames(getRandomGames());
}, [state]);
function getRandomGames() {
if (!state.randomlyOrderedGamesByCollectionId[collection.id]) return [];
const games = state.randomlyOrderedGamesByCollectionId[collection.id]
.filter(game => game.images && game.images.length > 0);
if (!games) return [];
return games.slice(0, MAX_COVER_COUNT);
}
return (
<Card className={`overflow-hidden rounded-lg relative pointer-events-none select-none ${className}`}>
<IconBackgroundPattern/>
<div className="flex flex-row items-center w-full h-full brightness-50">
{randomGames.map((game, idx) => (
<div
key={idx}
className="flex-none overflow-hidden -ml-[10%]"
style={{
width: `calc(100% / ${MAX_COVER_COUNT - 2})`,
clipPath: 'polygon(15% 0, 100% 0, 85% 100%, 0% 100%)',
}}
>
<img
src={`/images/screenshot/${game.images![0].id}`}
alt={`Image ${idx}`}
/>
</div>
))}
</div>
<div className="absolute inset-0 flex items-center justify-center">
<h2 className="text-white text-3xl font-bold">{collection.name}</h2>
</div>
</Card>
);
}
@@ -1,16 +1,110 @@
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";
interface CoverGridProps {
games: GameDto[];
}
// 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)
const GAP = 16; // gap-4 = 1rem = 16px
const ASPECT_RATIO = 12 / 17; // Game cover aspect ratio (width/height)
export default function CoverGrid({games}: CoverGridProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0);
// Update container width on resize
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current) {
setContainerWidth(containerRef.current.offsetWidth);
}
};
const resizeObserver = new ResizeObserver(updateDimensions);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
updateDimensions();
return () => resizeObserver.disconnect();
}, []);
// Calculate how many columns can fit
const columnCount = Math.max(1, Math.floor((containerWidth + GAP) / (MIN_COLUMN_WIDTH + GAP)));
// Calculate actual column width to distribute space evenly (up to MAX_COLUMN_WIDTH)
const actualColumnWidth = Math.min(
MAX_COLUMN_WIDTH,
Math.floor((containerWidth - (columnCount - 1) * GAP) / columnCount)
);
// Calculate cover height based on width and aspect ratio
// GameCover's size prop is the height, so we need to calculate height from width
const coverHeight = Math.floor(actualColumnWidth / ASPECT_RATIO);
// 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;
// Return empty cell if we're past the end of the games array
if (gameIndex >= games.length) {
return <div style={style}/>;
}
const game = games[gameIndex];
return (
<div
style={{
...style,
paddingBottom: GAP,
display: 'flex',
justifyContent: 'center',
boxSizing: 'border-box'
}}
>
<GameCover game={game} interactive={true} size={coverHeight} lazy={true}/>
</div>
);
};
// Column width function to handle the last column differently
const getColumnWidth = (index: number) => {
// Last column doesn't need gap after it
if (index === columnCount - 1) {
return actualColumnWidth;
}
return actualColumnWidth + GAP;
};
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,212px))] gap-4 justify-center">
{games.map((game) => (
<GameCover key={game.id} game={game} interactive={true}/>
))}
<div ref={containerRef} className="w-full">
{containerWidth > 0 && (
<Grid<{}>
columnCount={columnCount}
columnWidth={getColumnWidth}
rowCount={rowCount}
rowHeight={coverHeight + GAP}
defaultWidth={containerWidth}
cellComponent={Cell}
cellProps={{}}
style={{overflowX: 'hidden'}}
/>
)}
</div>
);
}
@@ -1,66 +1,166 @@
import React, {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 {ArrowRightIcon} from "@phosphor-icons/react";
import {CaretLeftIcon, CaretRightIcon} from "@phosphor-icons/react";
import {Button, Link} from "@heroui/react";
import {Grid, GridImperativeAPI} from "react-window";
interface CoverRowProps {
games: GameDto[];
title: string;
onPressShowMore: () => void;
link: string;
}
const aspectRatio = 12 / 17; // aspect ratio of the game cover
const defaultImageHeight = 300; // default height for the image
const defaultImageWidth = aspectRatio * defaultImageHeight; // default width for the image
const gap = 8; // gap between items in pixels (gap-2 = 0.5rem = 8px)
export function CoverRow({games, title, onPressShowMore}: CoverRowProps) {
export function CoverRow({games, title, link}: CoverRowProps) {
const gridRef = useRef<GridImperativeAPI | null>(null);
const [scrollPosition, setScrollPosition] = useState(0);
const [containerWidth, setContainerWidth] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(games.length);
// Update container width on resize
useEffect(() => {
const calculateVisible = () => {
const updateWidth = () => {
if (containerRef.current) {
const containerWidth = containerRef.current.offsetWidth;
const maxFit = Math.floor((containerWidth - defaultImageWidth) / defaultImageWidth) + 1;
setVisibleCount(maxFit < games.length ? maxFit : games.length);
setContainerWidth(containerRef.current.offsetWidth);
}
};
const resizeObserver = new ResizeObserver(calculateVisible);
const resizeObserver = new ResizeObserver(updateWidth);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
calculateVisible(); // initial calculation
updateWidth();
return () => resizeObserver.disconnect();
}, [games.length]);
}, []);
const showMore = visibleCount < games.length;
// Handle scroll updates - track scroll position from the grid element
useEffect(() => {
let gridElement: HTMLDivElement | null = null;
const handleScroll = () => {
if (gridElement) {
setScrollPosition(gridElement.scrollLeft);
}
};
// Small delay to ensure grid is mounted
const timer = setTimeout(() => {
gridElement = gridRef.current?.element ?? null;
if (gridElement) {
gridElement.addEventListener('scroll', handleScroll);
// Initial scroll position
setScrollPosition(gridElement.scrollLeft);
}
}, 100);
return () => {
clearTimeout(timer);
if (gridElement) {
gridElement.removeEventListener('scroll', handleScroll);
}
};
}, [containerWidth, games.length]);
const totalWidth = games.length * (defaultImageWidth + gap);
const maxScroll = Math.max(0, totalWidth - containerWidth);
const scrollLeft = () => {
const gridElement = gridRef.current?.element;
if (gridElement) {
const itemWidth = defaultImageWidth + gap;
const scrollAmount = itemWidth * 3; // Scroll exactly 3 items
const newPosition = Math.max(0, scrollPosition - scrollAmount);
gridElement.scrollTo({
left: newPosition,
behavior: "smooth"
});
}
};
const scrollRight = () => {
const gridElement = gridRef.current?.element;
if (gridElement) {
const itemWidth = defaultImageWidth + gap;
const scrollAmount = itemWidth * 3; // Scroll exactly 3 items
const newPosition = Math.min(maxScroll, scrollPosition + scrollAmount);
gridElement.scrollTo({
left: newPosition,
behavior: "smooth"
});
}
};
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" };
columnIndex: number;
rowIndex: number;
style: React.CSSProperties;
}) => {
const game = games[columnIndex];
return (
<div style={{...style, paddingRight: gap}}>
<GameCover game={game} radius="sm" interactive={true}/>
</div>
);
};
return (
<div className="flex flex-col mb-4">
<p className="text-2xl font-bold mb-4">{title}</p>
<div className="w-full relative">
<div ref={containerRef} className="flex flex-row gap-2 rounded-md bg-transparent">
{games.slice(0, visibleCount).map((game, index) => (
<GameCover key={index} game={game} radius="sm" interactive={true}/>
))}
<div className="flex flex-row justify-between items-baseline mb-4">
<Link href={link} className="flex flex-row gap-1 w-fit items-baseline" color="foreground"
underline="hover">
<p className="text-2xl font-bold">{title}</p>
<CaretRightIcon weight="bold" size={16}/>
</Link>
<div className="flex flex-row gap-2">
<Button
isIconOnly
size="sm"
variant="flat"
onPress={scrollLeft}
isDisabled={!canScrollLeft}
aria-label="Scroll left"
>
<CaretLeftIcon weight="bold" size={20}/>
</Button>
<Button
isIconOnly
size="sm"
variant="flat"
onPress={scrollRight}
isDisabled={!canScrollRight}
aria-label="Scroll right"
>
<CaretRightIcon weight="bold" size={20}/>
</Button>
</div>
{showMore && (
<div className="flex flex-row items-center justify-end cursor-pointer"
onClick={onPressShowMore}>
<div className="absolute h-full w-1/4 right-0 bottom-0
bg-linear-to-r from-transparent to-background
transition-all duration-300 ease-in-out hover:opacity-80"/>
<div
className="absolute h-full right-0 bottom-0 flex flex-row items-center gap-2 pointer-events-none">
<p className="text-xl font-semibold">Show more</p>
<ArrowRightIcon weight="bold"/>
</div>
</div>
</div>
<div ref={containerRef} className="w-full relative overflow-hidden">
{containerWidth > 0 && (
<Grid<{}>
gridRef={gridRef}
columnCount={games.length}
columnWidth={defaultImageWidth + gap}
rowCount={1}
rowHeight={defaultImageHeight}
defaultHeight={defaultImageHeight}
defaultWidth={containerWidth}
cellComponent={Cell}
cellProps={{}}
className="scrollbar-hide"
style={{overflow: 'auto'}}
/>
)}
</div>
</div>
@@ -1,21 +1,105 @@
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 {decode} from "blurhash";
// Cache to track which images have been loaded across component remounts
const loadedImagesCache = new Set<number>();
interface GameCoverProps {
game: GameDto;
size?: number;
radius?: "none" | "sm" | "md" | "lg";
interactive?: boolean;
lazy?: boolean;
}
export function GameCover({game, size = 300, radius = "sm", interactive = false}: GameCoverProps) {
const coverContent = Number.isInteger(game.coverId) ? (
<div className={`${interactive ? "rounded-md scale-95 hover:scale-100 shine transition-all" : ""}`}>
export function GameCover({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 [blurhashUrl, setBlurhashUrl] = useState<string | undefined>(undefined);
const containerRef = useRef<HTMLDivElement>(null);
// Generate blurhash placeholder image
useEffect(() => {
if (game.cover?.blurhash) {
try {
// Decode blurhash to pixel data
const pixels = decode(game.cover.blurhash, 32, 45); // Small size for placeholder
// Create canvas and draw pixels
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 45;
const ctx = canvas.getContext('2d');
if (ctx) {
const imageData = ctx.createImageData(32, 45);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
// Convert canvas to data URL
setBlurhashUrl(canvas.toDataURL());
}
} catch (e) {
console.error('Failed to decode blurhash:', e);
}
}
}, [game.cover?.blurhash]);
useEffect(() => {
if (!lazy || shouldLoad) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setShouldLoad(true);
observer.disconnect();
}
});
},
{
rootMargin: '200px', // Start loading 200px before the element enters viewport
}
);
if (containerRef.current) {
observer.observe(containerRef.current);
}
return () => observer.disconnect();
}, [lazy, shouldLoad]);
// Preload the real image when shouldLoad becomes true
useEffect(() => {
if (!shouldLoad || !game.cover || isImageLoaded) return;
const img = document.createElement('img');
img.src = `images/cover/${game.cover.id}`;
img.onload = () => {
loadedImagesCache.add(game.cover!.id);
setIsImageLoaded(true);
};
img.onerror = () => {
// If image fails to load, we'll just show the fallback
setIsImageLoaded(true);
};
}, [shouldLoad, game.cover, isImageLoaded]);
const coverContent = game.cover ? (
<div
ref={containerRef}
className={`${interactive ? "rounded-md scale-95 hover:scale-100 shine transition-all" : ""}`}
>
<Image
alt={game.title}
className="z-0 object-cover aspect-12/17"
src={`images/cover/${game.coverId}`}
src={shouldLoad && isImageLoaded ? `images/cover/${game.cover.id}` : blurhashUrl}
radius={radius}
height={size}
fallbackSrc={<GameCoverFallback title={game.title} size={size} radius={radius}/>}
@@ -1,6 +1,5 @@
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import React from "react";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
@@ -17,7 +16,9 @@ export default function LibraryHeader({library, className}: LibraryHeaderProps)
const randomGames = getRandomGames();
function getRandomGames() {
const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[];
if (!state.randomlyOrderedGamesByLibraryId[library.id]) return [];
const games = state.randomlyOrderedGamesByLibraryId[library.id]
.filter(game => game.images && game.images.length > 0);
if (!games) return [];
return games.slice(0, MAX_COVER_COUNT);
}
@@ -36,7 +37,7 @@ export default function LibraryHeader({library, className}: LibraryHeaderProps)
}}
>
<img
src={`/images/screenshot/${game.imageIds![0]}`}
src={`/images/screenshot/${game.images![0].id}`}
alt={`Image ${idx}`}
/>
</div>
@@ -1,7 +1,7 @@
import {FieldArray, useField} from "formik";
import {Button, Chip, Input, Popover, PopoverContent, PopoverTrigger} from "@heroui/react";
import {KeyboardEvent, useState} from "react";
import { PlusIcon } from "@phosphor-icons/react";
import {PlusIcon} from "@phosphor-icons/react";
// @ts-ignore
const ArrayInput = ({label, ...props}) => {
@@ -35,13 +35,23 @@ const ArrayInput = ({label, ...props}) => {
<div className="flex flex-row flex-wrap gap-2 items-center">
{field.value.map((element: any, index: number) => (
<Chip key={index} onClose={() => arrayHelpers.remove(index)}>
<Chip key={index}
onClose={() => arrayHelpers.remove(index)}
isDisabled={props.isDisabled}
>
{element}
</Chip>
))}
<Popover placement="bottom" showArrow={true}>
<PopoverTrigger>
<Button isIconOnly size="sm" variant="light" radius="full"><PlusIcon/></Button>
<Button isIconOnly
size="sm"
variant="light"
radius="full"
isDisabled={props.isDisabled}
>
<PlusIcon/>
</Button>
</PopoverTrigger>
<PopoverContent>
<Input
@@ -77,7 +77,7 @@ export default function FileTreeView({onPathChange}: { onPathChange: (file: stri
if (subDirectories === undefined) return;
const newNodes = fileDtosToNodes(subDirectories as FileDto[]);
const updatedTree = updateTreeWithNewNodes(fileTree!!, element.id, newNodes);
const updatedTree = updateTreeWithNewNodes(fileTree!, element.id, newNodes);
setFileTree(updatedTree);
setFlattenedFileTree(flattenTree(updatedTree));
@@ -2,7 +2,7 @@ import {Image, useDisclosure} from "@heroui/react";
import React from "react";
import {useField} from "formik";
import {GameCoverPickerModal} from "Frontend/components/general/modals/GameCoverPickerModal";
import { ImageBrokenIcon, PencilIcon } from "@phosphor-icons/react";
import {ImageBrokenIcon, PencilIcon} from "@phosphor-icons/react";
// @ts-ignore
@@ -16,12 +16,12 @@ export default function GameCoverPicker({game, showErrorUntouched = false, ...pr
return (<>
<div className="relative group aspect-12/17 cursor-pointer bg-background/50"
onClick={gameCoverPickerModal.onOpenChange}>
{field.value || game.coverId ?
{field.value || game.cover?.id ?
<div className="size-full overflow-hidden">
<Image
alt={game.title}
className="z-0 object-cover group-hover:brightness-25"
src={field.value ? field.value : `images/cover/${game.coverId}`}
src={field.value ? field.value : `images/cover/${game.cover?.id}`}
{...props}
{...field}
radius="none"
@@ -16,12 +16,12 @@ export default function GameHeaderPicker({game, showErrorUntouched = false, ...p
return (<>
<div className="relative group size-full cursor-pointer bg-background/50"
onClick={gameHeaderPickerModal.onOpenChange}>
{field.value || game.headerId ?
{field.value || game.header?.id ?
<div className="size-full overflow-hidden">
<Image
alt={game.title}
className="z-0 object-cover group-hover:brightness-25"
src={field.value ? field.value : `images/cover/${game.headerId}`}
src={field.value ? field.value : `images/cover/${game.header?.id}`}
{...props}
{...field}
radius="none"
@@ -14,6 +14,7 @@ import * as Yup from "yup";
import ArrayInputAutocomplete from "Frontend/components/general/input/ArrayInputAutocomplete";
import {useSnapshot} from "valtio/react";
import {platformState} from "Frontend/state/PlatformState";
import CheckboxInput from "Frontend/components/general/input/CheckboxInput";
interface LibraryManagementDetailsProps {
library: LibraryDto;
@@ -45,7 +46,7 @@ export default function LibraryManagementDetails({library}: LibraryManagementDet
color: "success"
});
navigate("/administration/libraries");
navigate("/administration/games");
} catch (e) {
addToast({
title: "Error deleting library",
@@ -84,6 +85,8 @@ export default function LibraryManagementDetails({library}: LibraryManagementDet
<Input label="Library name" name="name"/>
<CheckboxInput label="Display on homepage" name="metadata.displayOnHomepage" className="mb-4"/>
<ArrayInputAutocomplete options={Array.from(availablePlatforms)} name="platforms" label="Platforms"/>
<DirectoryMappingInput name="directories"/>
@@ -38,12 +38,12 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
const rowsPerPage = 25;
const state = useSnapshot(gameState);
const games = state.gamesByLibraryId[library.id] ? state.gamesByLibraryId[library.id] as GameAdminDto[] : [];
const games = state.gamesByLibraryId[library.id] ? state.gamesByLibraryId[library.id] : [];
const [searchTerm, setSearchTerm] = useState("");
const [filter, setFilter] = useState<"all" | "confirmed" | "nonConfirmed">("all");
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "title", direction: "ascending"});
const [selectedGame, setSelectedGame] = useState<GameAdminDto>(games[0]);
const [selectedGame, setSelectedGame] = useState<GameAdminDto>(games[0] as GameAdminDto);
const editGameModal = useDisclosure();
const matchGameModal = useDisclosure();
@@ -94,7 +94,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
function getFilteredGames() {
let filteredGames = (games as GameAdminDto[]).filter((game) =>
game.metadata.path!!.toLowerCase().includes(searchTerm.toLowerCase()) ||
game.metadata.path!.toLowerCase().includes(searchTerm.toLowerCase()) ||
game.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
game.publishers?.some(publisher => publisher.toLowerCase().includes(searchTerm.toLowerCase())) ||
game.developers?.some(developer => developer.toLowerCase().includes(searchTerm.toLowerCase()))
@@ -102,10 +102,10 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
if (filter === "confirmed") {
return filteredGames.filter(g => g.metadata.matchConfirmed);
}
if (filter === "nonConfirmed") {
} else if (filter === "nonConfirmed") {
return filteredGames.filter(g => !g.metadata.matchConfirmed);
}
return filteredGames;
}
@@ -178,7 +178,8 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
<Link href={`/game/${item.id}`}
color="foreground"
className="text-sm"
underline="hover">{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
underline="hover">
{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
</Link>
</TableCell>
<TableCell>
@@ -238,7 +239,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
<EditGameMetadataModal game={selectedGame}
isOpen={editGameModal.isOpen}
onOpenChange={editGameModal.onOpenChange}/>
<MatchGameModal path={selectedGame.metadata.path!!}
<MatchGameModal path={selectedGame.metadata.path!}
libraryId={library.id}
replaceGameId={selectedGame.id}
initialSearchTerm={selectedGame.title}
@@ -85,7 +85,7 @@ export default function LibraryManagementIgnoredPaths({library}: LibraryManageme
}
function getFilteredPaths() {
return library.ignoredPaths!!.filter((path) =>
return library.ignoredPaths!.filter((path) =>
path.path.toLowerCase().includes(searchTerm.toLowerCase())
)
}
@@ -165,7 +165,10 @@ export default function LibraryManagementIgnoredPaths({library}: LibraryManageme
</Tooltip>
<Tooltip content="Remove entry from list">
<Button isIconOnly size="sm" color="danger"
onPress={() => deleteIgnoredPath(item.path)}><TrashIcon/>
onPress={() => deleteIgnoredPath(item.path)}
isDisabled={item.path.sourceType !== IgnoredPathSourceTypeDto.USER}
>
<TrashIcon/>
</Button>
</Tooltip>
</div>
@@ -0,0 +1,90 @@
import React from "react";
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {Form, Formik} from "formik";
import Input from "Frontend/components/general/input/Input";
import {CollectionEndpoint} from "Frontend/generated/endpoints";
import CollectionCreateDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionCreateDto";
import * as Yup from "yup";
import TextAreaInput from "Frontend/components/general/input/TextAreaInput";
interface CollectionCreationModalProps {
isOpen: boolean;
onOpenChange: () => void;
}
export default function CollectionCreationModal({
isOpen,
onOpenChange
}: CollectionCreationModalProps) {
async function createCollection(collection: CollectionCreateDto) {
await CollectionEndpoint.createCollection(collection);
addToast({
title: "New collection created",
description: `Collection ${collection.name} created!`,
color: "success"
});
}
return (<>
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="xl">
<ModalContent>
{(onClose) => (
<Formik
initialValues={{
name: "",
description: ""
}}
validationSchema={Yup.object({
name: Yup.string()
.required("Collection name is required")
.max(255, "Collection name must be 255 characters or less")
})}
isInitialValid={false}
onSubmit={async (values: any) => {
await createCollection(values);
onClose();
}}
>
{(formik) =>
<Form>
<ModalHeader className="flex flex-col gap-1">Create a new collection</ModalHeader>
<ModalBody>
<div className="flex flex-col gap-2">
<Input
name="name"
label="Collection Name"
placeholder="Enter collection name"
value={formik.values.name}
required
/>
<TextAreaInput
name="description"
label="Collection Description"
placeholder="Enter collection description"
value={formik.values.description}
/>
</div>
</ModalBody>
<ModalFooter className="flex flex-row justify-end">
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting}
type="submit"
>
{formik.isSubmitting ? "" : "Add"}
</Button>
</ModalFooter>
</Form>
}
</Formik>
)}
</ModalContent>
</Modal>
</>
);
}
@@ -0,0 +1,181 @@
import {useSnapshot} from "valtio/react";
import {
Button,
Input,
Link,
Select,
SelectItem,
SortDescriptor,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
Tooltip
} from "@heroui/react";
import React, {useMemo, useState} from "react";
import {GameAdminDto} from "Frontend/dtos/GameDtos";
import {CollectionEndpoint} from "Frontend/generated/endpoints";
import {MinusIcon, PlusIcon} from "@phosphor-icons/react";
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
import {libraryState} from "Frontend/state/LibraryState";
import {gameState} from "Frontend/state/GameState";
import {collectionState} from "Frontend/state/CollectionState";
interface CollectionGamesTableProps {
collectionId: number;
}
export default function CollectionGamesTable({collectionId}: CollectionGamesTableProps) {
const gamesState = useSnapshot(gameState);
const games = gamesState.games as GameAdminDto[];
const librariesState = useSnapshot(libraryState);
const libraries = librariesState.state as Record<number, LibraryAdminDto>;
const collectionsState = useSnapshot(collectionState);
const collection = collectionsState.state[collectionId];
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "path", direction: "ascending"});
const [searchTerm, setSearchTerm] = useState("");
const [filter, setFilter] = useState<"all" | "inCollection" | "notInCollection">("all");
function libraryName(game: GameAdminDto) {
return libraries[game.libraryId]?.name || "Unknown";
}
const gameInCollectionMap = useMemo(() => {
const map = new Map<number, boolean>();
games.forEach(game => {
map.set(game.id, collection.gameIds!.includes(game.id));
});
return map;
}, [games, collection.gameIds]);
function isGameInCollection(game: GameAdminDto) {
return gameInCollectionMap.get(game.id) ?? false;
}
const filteredGames = useMemo(() => {
return games
.filter((game) => game.title.toLowerCase().includes(searchTerm.toLowerCase()))
.filter(game => {
if (filter === "inCollection") {
return isGameInCollection(game);
} else if (filter === "notInCollection") {
return !isGameInCollection(game);
}
return true;
});
}, [games, searchTerm, filter, gameInCollectionMap]);
const sortedGames = useMemo(() => {
return filteredGames
.slice()
.sort((a, b) => {
let cmp: number;
switch (sortDescriptor.column) {
case "title":
cmp = a.title.localeCompare(b.title);
break;
case "library":
cmp = (libraryName(a)).localeCompare(libraryName(b));
break;
default:
cmp = 0;
}
if (sortDescriptor.direction === "descending") {
cmp *= -1;
}
return cmp;
})
.map(game => ({...game, _inCollection: isGameInCollection(game)}));
}, [filteredGames, sortDescriptor, libraries, gameInCollectionMap]);
async function addGameToCollection(game: GameAdminDto) {
await CollectionEndpoint.addGameToCollection(collectionId, game.id);
}
async function removeGameFromCollection(game: GameAdminDto) {
await CollectionEndpoint.removeGameFromCollection(collectionId, game.id);
}
return (
<div className="flex flex-col gap-2">
<div className="flex flex-row gap-2 justify-between">
<Input
className="w-96"
isClearable
placeholder="Search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onClear={() => setSearchTerm("")}
/>
<Select
selectedKeys={[filter]}
disallowEmptySelection
onSelectionChange={keys => setFilter(Array.from(keys)[0] as any)}
className="w-64"
>
<SelectItem key="all">Show all games</SelectItem>
<SelectItem key="inCollection">Show only games in collection</SelectItem>
<SelectItem key="notInCollection">Show only games not in collection</SelectItem>
</Select>
</div>
<Table isStriped isHeaderSticky
sortDescriptor={sortDescriptor}
onSortChange={setSortDescriptor}
classNames={{
base: "h-96"
}}>
<TableHeader>
<TableColumn key="title" allowsSorting>Title</TableColumn>
<TableColumn key="library" allowsSorting>Library</TableColumn>
<TableColumn width={1}>Actions</TableColumn>
</TableHeader>
<TableBody
emptyContent="Your filters did not match any games."
items={sortedGames}>
{(game) => (
// Key includes _inCollection to force re-render when that value changes
<TableRow key={`${game.id}-${game._inCollection}`}>
<TableCell>
<Link href={`/game/${game.id}`}
color="foreground"
className="text-sm"
underline="hover">
{game.title} ({game.release ? new Date(game.release).getFullYear() : "unknown"})
</Link>
</TableCell>
<TableCell>
<Link href={`/administration/games/library/${game.libraryId}`}
color="foreground"
className="text-sm"
underline="hover">
{libraryName(game)}
</Link>
</TableCell>
<TableCell>
<div className="flex flex-row gap-2">
<Tooltip content="Add game to collection">
<Button isIconOnly size="sm"
onPress={() => addGameToCollection(game)}
isDisabled={game._inCollection}>
<PlusIcon/>
</Button>
</Tooltip>
<Tooltip content="Remove game from collection">
<Button isIconOnly size="sm"
onPress={() => removeGameFromCollection(game)}
isDisabled={!game._inCollection}>
<MinusIcon/>
</Button>
</Tooltip>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}
@@ -0,0 +1,42 @@
import React from "react";
import {CollectionEndpoint} from "Frontend/generated/endpoints";
import {useSnapshot} from "valtio/react";
import {collectionState} from "Frontend/state/CollectionState";
import CollectionDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionDto";
import CollectionUpdateDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionUpdateDto";
import PrioritiesModal from "./PrioritiesModal";
interface CollectionPrioritiesModalProps {
isOpen: boolean;
onOpenChange: () => void;
}
export default function CollectionPrioritiesModal({isOpen, onOpenChange}: CollectionPrioritiesModalProps) {
const collections = useSnapshot(collectionState).sorted;
const updateCollections = async (reorderedCollections: any[]) => {
const updateDtos: CollectionUpdateDto[] = reorderedCollections.map((collection, index): CollectionUpdateDto => {
return {
id: collection.id,
metadata: {
displayOnHomepage: collection.metadata!.displayOnHomepage,
displayOrder: index
}
};
});
await CollectionEndpoint.updateCollections(updateDtos);
};
return (
<PrioritiesModal
title="Edit collection order"
subtitle="Collections higher on the list are displayed at the start"
items={collections as CollectionDto[]}
updateItems={updateCollections}
isOpen={isOpen}
onOpenChange={onOpenChange}
/>
);
}
@@ -7,7 +7,6 @@ import {ArrowRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react";
import PluginIcon from "Frontend/components/general/plugin/PluginIcon";
import {useSnapshot} from "valtio/react";
import {pluginState} from "Frontend/state/PluginState";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
interface GameCoverPickerModalProps {
game: GameDto;
@@ -110,7 +109,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
/>
<div
className="absolute inset-0 flex flex-col gap-4 items-center justify-center opacity-0 group-hover:opacity-100">
<PluginIcon plugin={state[cover.source] as PluginDto} size={32}
<PluginIcon plugin={state[cover.source]} size={32}
blurred={false} showTooltip={false}/>
<p className="text-s text-center">{cover.title}</p>
<ArrowRightIcon/>
@@ -7,7 +7,6 @@ import {ArrowRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react";
import PluginIcon from "Frontend/components/general/plugin/PluginIcon";
import {useSnapshot} from "valtio/react";
import {pluginState} from "Frontend/state/PluginState";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
interface GameHeaderPickerModalProps {
game: GameDto;
@@ -109,7 +108,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
/>
<div
className="absolute inset-0 flex flex-col gap-4 items-center justify-center opacity-0 group-hover:opacity-100">
<PluginIcon plugin={state[header.source] as PluginDto} size={32}
<PluginIcon plugin={state[header.source]} size={32}
blurred={false} showTooltip={false}/>
<p className="text-s text-center">{header.title}</p>
<ArrowRightIcon/>
@@ -1,15 +1,14 @@
import React, {useState} from "react";
import {addToast, Button, Checkbox, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {Form, Formik} from "formik";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import Input from "Frontend/components/general/input/Input";
import * as Yup from "yup";
import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMappingInput";
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
import ArrayInputAutocomplete from "Frontend/components/general/input/ArrayInputAutocomplete";
import {useSnapshot} from "valtio/react";
import {platformState} from "Frontend/state/PlatformState";
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
interface LibraryCreationModalProps {
isOpen: boolean;
@@ -24,8 +23,8 @@ export default function LibraryCreationModal({
const [scanAfterCreation, setScanAfterCreation] = useState<boolean>(true);
const availablePlatforms = useSnapshot(platformState).available;
async function createLibrary(library: LibraryDto) {
await LibraryEndpoint.createLibrary(library as LibraryAdminDto, scanAfterCreation);
async function createLibrary(library: LibraryAdminDto) {
await LibraryEndpoint.createLibrary(library, scanAfterCreation);
addToast({
title: "New library created",
@@ -39,20 +38,25 @@ export default function LibraryCreationModal({
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="xl">
<ModalContent>
{(onClose) => (
<Formik initialValues={{name: "", directories: [], platforms: []}}
validationSchema={Yup.object({
name: Yup.string()
.required("Library name is required")
.max(255, "Library name must be 255 characters or less"),
directories: Yup.array()
.of(Yup.object())
.min(1, "At least one directory is required")
})}
isInitialValid={false}
onSubmit={async (values: any) => {
await createLibrary(values);
onClose();
}}
<Formik
initialValues={{
name: "",
directories: [],
platforms: []
}}
validationSchema={Yup.object({
name: Yup.string()
.required("Library name is required")
.max(255, "Library name must be 255 characters or less"),
directories: Yup.array()
.of(Yup.object())
.min(1, "At least one directory is required")
})}
isInitialValid={false}
onSubmit={async (values: any) => {
await createLibrary(values);
onClose();
}}
>
{(formik) =>
<Form>
@@ -0,0 +1,41 @@
import React from "react";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {useSnapshot} from "valtio/react";
import {libraryState} from "Frontend/state/LibraryState";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto";
import PrioritiesModal from "./PrioritiesModal";
interface LibraryPrioritiesModalProps {
isOpen: boolean;
onOpenChange: () => void;
}
export default function LibraryPrioritiesModal({isOpen, onOpenChange}: LibraryPrioritiesModalProps) {
const libraries = useSnapshot(libraryState).sorted;
const updateLibraries = async (reorderedLibraries: LibraryDto[]) => {
const updateDtos: LibraryUpdateDto[] = reorderedLibraries.map((library, index): LibraryUpdateDto => {
return {
id: library.id,
metadata: {
displayOnHomepage: library.metadata!.displayOnHomepage,
displayOrder: index
}
};
});
await LibraryEndpoint.updateLibraries(updateDtos);
};
return (
<PrioritiesModal
title="Edit library order"
subtitle="Libraries higher on the list are displayed at the start"
items={libraries}
updateItems={updateLibraries}
isOpen={isOpen}
onOpenChange={onOpenChange}
/>
);
}
@@ -19,7 +19,6 @@ import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/G
import PluginIcon from "../plugin/PluginIcon";
import {useSnapshot} from "valtio/react";
import {pluginState} from "Frontend/state/PluginState";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
import {libraryState} from "Frontend/state/LibraryState";
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
@@ -129,7 +128,7 @@ export default function MatchGameModal({
<div className="flex flex-row gap-2">
{Object.values(item.originalIds).map(
originalId => <PluginIcon
plugin={state[originalId.pluginId] as PluginDto}/>
plugin={state[originalId.pluginId]}/>
)}
</div>
</TableCell>
@@ -1,113 +1,39 @@
import React from "react";
import {addToast, Button, Chip, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {ListBox, ListBoxItem, useDragAndDrop} from "react-aria-components";
import { CaretUpDownIcon } from "@phosphor-icons/react";
import {useListData} from "@react-stately/data";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
import {PluginEndpoint} from "Frontend/generated/endpoints";
import PrioritiesModal from "./PrioritiesModal";
import {useSnapshot} from "valtio/react";
import {pluginState} from "Frontend/state/PluginState";
interface PluginPrioritiesModalProps {
plugins: PluginDto[];
isOpen: boolean;
onOpenChange: () => void;
type: string;
}
export default function PluginPrioritiesModal({plugins, isOpen, onOpenChange}: PluginPrioritiesModalProps) {
export default function PluginPrioritiesModal({isOpen, onOpenChange, type}: PluginPrioritiesModalProps) {
const plugins = useSnapshot(pluginState).sortedByType[type];
const sortedPlugins = useListData({
initialItems: plugins, // Already sorted in parent
getKey: (plugin) => plugin.id
});
const updatePlugins = async (reorderedPlugins: PluginDto[]) => {
const prioritiesMap: Record<string, number> = {};
const totalPlugins = reorderedPlugins.length;
let {dragAndDropHooks} = useDragAndDrop({
getItems: (keys) =>
[...keys].map((key) => ({'text/plain': sortedPlugins.getItem(key)!.name})),
onReorder(e) {
if (e.keys.has(e.target.key)) return;
if (e.target.dropPosition === 'before' || e.target.dropPosition === 'on') {
sortedPlugins.moveBefore(e.target.key, e.keys);
} else if (e.target.dropPosition === 'after') {
sortedPlugins.moveAfter(e.target.key, e.keys);
}
// Recalculate priority based on new position (reversed)
sortedPlugins.items.forEach((plugin, index) => {
const reversedPriority = sortedPlugins.items.length - index;
sortedPlugins.update(plugin.id, {...plugin, priority: reversedPriority});
});
}
});
function generatePrioritiesMap(): Record<string, number> {
let map: Record<string, number> = {};
const totalPlugins = sortedPlugins.items.length;
sortedPlugins.items.forEach((plugin, index) => {
map[plugin.id] = totalPlugins - index; // Reverse order
reorderedPlugins.forEach((plugin, index) => {
// Reverse order: first item gets highest priority
prioritiesMap[plugin.id] = totalPlugins - index;
});
return map;
}
async function setPluginPriorities(onClose: () => void) {
try {
const prioritiesMap = generatePrioritiesMap();
await PluginEndpoint.setPluginPriorities(prioritiesMap);
addToast({
title: "Plugin order updated",
description: "Plugin order has been updated successfully.",
color: "success"
});
onClose();
} catch (e) {
addToast({
title: "Error",
description: "An error occurred while updating plugin order.",
color: "warning"
});
}
}
await PluginEndpoint.setPluginPriorities(prioritiesMap);
};
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<p>Edit plugin order</p>
<p className="text-small font-normal">Plugins higher on the list are preferred</p>
</ModalHeader>
<ModalBody>
<ListBox items={sortedPlugins.items}
dragAndDropHooks={dragAndDropHooks}
className="flex flex-col gap-2">
{(plugin: PluginDto) => (
<ListBoxItem
key={plugin.id}
className="flex flex-row p-2 rounded-lg justify-between items-center bg-foreground/5">
<div className="flex flex-row gap-2 items-center">
<Chip size="sm" color="primary">
{sortedPlugins.items.findIndex(p => p.id === plugin.id) + 1}
</Chip>
<p className="font-normal text-small">{plugin.name}</p>
</div>
<CaretUpDownIcon/>
</ListBoxItem>
)}
</ListBox>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary" onPress={() => setPluginPriorities(onClose)}>
Save
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
<PrioritiesModal
title="Edit plugin order"
subtitle="Plugins higher on the list are preferred"
items={plugins}
updateItems={updatePlugins}
isOpen={isOpen}
onOpenChange={onOpenChange}
/>
);
}
@@ -0,0 +1,127 @@
import React, {useEffect, useState} from "react";
import {addToast, Button, Chip, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {ListBox, ListBoxItem, useDragAndDrop} from "react-aria-components";
import {CaretUpDownIcon} from "@phosphor-icons/react";
import {useListData} from "@react-stately/data";
export interface PrioritizableItem {
id: number | string;
name: string;
}
interface PrioritiesModalProps<T extends PrioritizableItem> {
title: string;
subtitle: string;
items: T[];
updateItems: (items: T[]) => Promise<void>;
isOpen: boolean;
onOpenChange: () => void;
}
export default function PrioritiesModal<T extends PrioritizableItem>({
items,
isOpen,
onOpenChange,
title,
subtitle,
updateItems
}: PrioritiesModalProps<T>) {
const sortedItems = useListData<T>({
initialItems: items,
getKey: (item) => item.id
});
// Track order changes to trigger re-renders
const [orderVersion, setOrderVersion] = useState(0);
// Update sortedItems when items change
useEffect(() => {
sortedItems.setSelectedKeys(new Set());
sortedItems.items.forEach(item => sortedItems.remove(item.id));
items.forEach(item => sortedItems.append(item));
setOrderVersion(prev => prev + 1);
}, [items]);
let {dragAndDropHooks} = useDragAndDrop({
getItems: (keys) =>
[...keys].map((key) => ({'text/plain': sortedItems.getItem(key)!.name})),
onReorder(e) {
if (e.keys.has(e.target.key)) return;
if (e.target.dropPosition === 'before' || e.target.dropPosition === 'on') {
sortedItems.moveBefore(e.target.key, e.keys);
} else if (e.target.dropPosition === 'after') {
sortedItems.moveAfter(e.target.key, e.keys);
}
// Trigger re-render after reorder
setOrderVersion(prev => prev + 1);
}
});
async function updateItemOrder(onClose: () => void) {
try {
// Pass the reordered items directly to the update function
// The parent component will handle the actual transformation
await updateItems(sortedItems.items);
addToast({
title: "Order updated",
description: "Item order has been updated successfully.",
color: "success"
});
onClose();
} catch (e) {
addToast({
title: "Error",
description: "An error occurred while updating item order.",
color: "warning"
});
}
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<p>{title}</p>
<p className="text-small font-normal">{subtitle}</p>
</ModalHeader>
<ModalBody>
<ListBox items={sortedItems.items}
dragAndDropHooks={dragAndDropHooks}
className="flex flex-col gap-2"
key={orderVersion}>
{(item: T) => (
<ListBoxItem
key={item.id}
className="flex flex-row p-2 rounded-lg justify-between items-center bg-foreground/5">
<div className="flex flex-row gap-2 items-center">
<Chip size="sm" color="primary">
{sortedItems.items.findIndex(p => p.id === item.id) + 1}
</Chip>
<p className="font-normal text-small">{item.name}</p>
</div>
<CaretUpDownIcon/>
</ListBoxItem>
)}
</ListBox>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary" onPress={() => updateItemOrder(onClose)}>
Save
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -1,17 +1,19 @@
import {Button, Tooltip, useDisclosure} from "@heroui/react";
import { ListNumbersIcon } from "@phosphor-icons/react";
import {ListNumbersIcon} from "@phosphor-icons/react";
import {PluginManagementCard} from "Frontend/components/general/cards/PluginManagementCard";
import React from "react";
import PluginPrioritiesModal from "Frontend/components/general/modals/PluginPrioritiesModal";
import {camelCaseToTitle} from "Frontend/util/utils";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
import {useSnapshot} from "valtio/react";
import {pluginState} from "Frontend/state/PluginState";
interface PluginManagementSectionProps {
type: string;
plugins: PluginDto[];
}
export function PluginManagementSection({type, plugins = []}: PluginManagementSectionProps) {
export function PluginManagementSection({type}: PluginManagementSectionProps) {
const plugins = useSnapshot(pluginState).sortedByType[type];
const pluginPrioritiesModal = useDisclosure();
return (
@@ -40,10 +42,9 @@ export function PluginManagementSection({type, plugins = []}: PluginManagementSe
</div>}
<PluginPrioritiesModal
key={plugins.map(p => p.id + p.priority).join(',')} // force re-mount if plugin order changes
plugins={[...plugins].sort((a, b) => b.priority - a.priority)}
isOpen={pluginPrioritiesModal.isOpen}
onOpenChange={pluginPrioritiesModal.onOpenChange}
type={type}
/>
</div>);
}