mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-15 16:20:03 +00:00
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:
@@ -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
|
||||
<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>);
|
||||
}
|
||||
Reference in New Issue
Block a user