diff --git a/gameyfin/src/main/frontend/App.tsx b/gameyfin/src/main/frontend/App.tsx index b7f8248..349cd56 100644 --- a/gameyfin/src/main/frontend/App.tsx +++ b/gameyfin/src/main/frontend/App.tsx @@ -10,6 +10,7 @@ import {IconContext, X} from "@phosphor-icons/react"; import client from "Frontend/generated/connect-client.default"; import {ErrorHandlingMiddleware} from "Frontend/util/middleware"; import {initializeLibraryState} from "Frontend/state/LibraryState"; +import {initializeGameState} from "Frontend/state/GameState"; export default function App() { const navigate = useNavigate(); @@ -17,6 +18,7 @@ export default function App() { client.middlewares = [ErrorHandlingMiddleware]; initializeLibraryState(); + initializeGameState(); return ( diff --git a/gameyfin/src/main/frontend/components/administration/LibraryManagement.tsx b/gameyfin/src/main/frontend/components/administration/LibraryManagement.tsx index 1ba0499..bf3f184 100644 --- a/gameyfin/src/main/frontend/components/administration/LibraryManagement.tsx +++ b/gameyfin/src/main/frontend/components/administration/LibraryManagement.tsx @@ -31,7 +31,7 @@ function LibraryManagementLayout({getConfig, formik}: any) { } async function removeLibrary(library: LibraryDto) { - await LibraryEndpoint.removeLibrary(library.id); + await LibraryEndpoint.deleteLibrary(library.id); addToast({ title: "Library removed", description: `Library ${library.name} has been removed.`, @@ -47,6 +47,12 @@ function LibraryManagementLayout({getConfig, formik}: any) {
+
+ +

+ Minimum required Levenshtein ratio to consider two titles the same. +

+
diff --git a/gameyfin/src/main/frontend/components/general/IconBackgroundPattern.tsx b/gameyfin/src/main/frontend/components/general/IconBackgroundPattern.tsx new file mode 100644 index 0000000..91bceb9 --- /dev/null +++ b/gameyfin/src/main/frontend/components/general/IconBackgroundPattern.tsx @@ -0,0 +1,32 @@ +import { + Alien, + CastleTurret, + GameController, + Ghost, + Joystick, + Lego, + Skull, + SoccerBall, + Strategy, + Sword, + TreasureChest, + Trophy +} from "@phosphor-icons/react"; +import React from "react"; + +export default function IconBackgroundPattern() { + return
+ + + + + + + + + + + + +
+} \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/general/cards/LibraryOverviewCard.tsx b/gameyfin/src/main/frontend/components/general/cards/LibraryOverviewCard.tsx index ba88cc8..8dde4c2 100644 --- a/gameyfin/src/main/frontend/components/general/cards/LibraryOverviewCard.tsx +++ b/gameyfin/src/main/frontend/components/general/cards/LibraryOverviewCard.tsx @@ -1,28 +1,15 @@ import {Button, Card, Chip, Tooltip} from "@heroui/react"; import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto"; import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto"; -import React, {useEffect, useState} from "react"; +import React from "react"; import {LibraryEndpoint} from "Frontend/generated/endpoints"; import {GameCover} from "Frontend/components/general/covers/GameCover"; -import { - Alien, - CastleTurret, - GameController, - Ghost, - Joystick, - Lego, - MagnifyingGlass, - Skull, - SlidersHorizontal, - SoccerBall, - Strategy, - Sword, - TreasureChest, - Trophy -} from "@phosphor-icons/react"; +import {MagnifyingGlass, SlidersHorizontal} from "@phosphor-icons/react"; import ScanType from "Frontend/generated/de/grimsi/gameyfin/libraries/enums/ScanType"; -import {randomGamesFromLibrary} from "Frontend/util/utils"; import {useNavigate} from "react-router"; +import {useSnapshot} from "valtio/react"; +import {gameState} from "Frontend/state/GameState"; +import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern"; interface LibraryOverviewCardProps { library: LibraryDto; @@ -31,75 +18,59 @@ interface LibraryOverviewCardProps { export function LibraryOverviewCard({library}: LibraryOverviewCardProps) { const MAX_COVER_COUNT = 5; const navigate = useNavigate(); - const [randomGames, setRandomGames] = useState([]); + const state = useSnapshot(gameState); + const randomGames = getRandomGames(); - useEffect(() => { - randomGamesFromLibrary(library, MAX_COVER_COUNT).then((games) => { - setRandomGames(games); - }) - }, []); + function getRandomGames() { + const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[]; + if (!games) return []; + return games.slice(0, MAX_COVER_COUNT); + } async function triggerScan() { await LibraryEndpoint.triggerScan(ScanType.QUICK, [library]); } return ( - <> - -
-
-
- - - - - - - - - - - - + +
+
+ + {randomGames.length > 0 && +
+ {randomGames.map((game) => ( + + ))}
- {randomGames.length > 0 && -
- {randomGames.map((game) => ( - - ))} -
- } -
- -

{library.name}

- -
- - - - - - -
+ }
- {library.stats && -
-

Games

-

Downloads

-

Platforms

-

{library.stats.gamesCount}

-

{library.stats.downloadedGamesCount}

- PC -
- } -
- +

{library.name}

+ +
+ + + + + + +
+
+ + {library.stats && +
+

Games

+

Downloads

+

Platforms

+

{library.stats.gamesCount}

+

{library.stats.downloadedGamesCount}

+ PC +
+ } + ); } \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/general/covers/LibraryHeader.tsx b/gameyfin/src/main/frontend/components/general/covers/LibraryHeader.tsx index a760002..e2c603b 100644 --- a/gameyfin/src/main/frontend/components/general/covers/LibraryHeader.tsx +++ b/gameyfin/src/main/frontend/components/general/covers/LibraryHeader.tsx @@ -1,7 +1,10 @@ import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto"; -import React, {useEffect, useState} from "react"; +import React from "react"; import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto"; -import {randomGamesFromLibrary} from "Frontend/util/utils"; +import {useSnapshot} from "valtio/react"; +import {gameState} from "Frontend/state/GameState"; +import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern"; +import {Card} from "@heroui/react"; interface LibraryHeaderProps { library: LibraryDto; @@ -9,39 +12,39 @@ interface LibraryHeaderProps { } export default function LibraryHeader({library, className}: LibraryHeaderProps) { - const [randomGames, setRandomGames] = useState([]); - const maxCoverCount = 5; + const MAX_COVER_COUNT = 5; + const state = useSnapshot(gameState); + const randomGames = getRandomGames(); - useEffect(() => { - randomGamesFromLibrary(library, maxCoverCount).then((games) => { - setRandomGames(games); - }); - }, [library, maxCoverCount]); + function getRandomGames() { + const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[]; + if (!games) return []; + return games.slice(0, MAX_COVER_COUNT); + } return ( -
+ +
- {randomGames - .map((game, idx) => ( -
- {`Image -
- )) - .slice(0, maxCoverCount)} + {randomGames.map((game, idx) => ( +
+ {`Image +
+ ))}

{library.name}

-
+ ); } \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/general/library/LibraryManagementDetails.tsx b/gameyfin/src/main/frontend/components/general/library/LibraryManagementDetails.tsx index c4b7e75..03e11bf 100644 --- a/gameyfin/src/main/frontend/components/general/library/LibraryManagementDetails.tsx +++ b/gameyfin/src/main/frontend/components/general/library/LibraryManagementDetails.tsx @@ -33,7 +33,7 @@ export default function LibraryManagementDetails({library}: LibraryManagementDet async function handleDelete(): Promise { try { - await LibraryEndpoint.removeLibrary(library.id); + await LibraryEndpoint.deleteLibrary(library.id); addToast({ title: "Library deleted", diff --git a/gameyfin/src/main/frontend/components/general/library/LibraryManagementGames.tsx b/gameyfin/src/main/frontend/components/general/library/LibraryManagementGames.tsx index 1a90020..cd208e1 100644 --- a/gameyfin/src/main/frontend/components/general/library/LibraryManagementGames.tsx +++ b/gameyfin/src/main/frontend/components/general/library/LibraryManagementGames.tsx @@ -1,22 +1,17 @@ import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto"; -import {useEffect, useState} from "react"; -import {LibraryEndpoint} from "Frontend/generated/endpoints"; import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto"; import {Button, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow} from "@heroui/react"; import {CheckCircle, Pencil, Trash} from "@phosphor-icons/react"; +import {useSnapshot} from "valtio/react"; +import {gameState} from "Frontend/state/GameState"; interface LibraryManagementGamesProps { library: LibraryDto; } export default function LibraryManagementGames({library}: LibraryManagementGamesProps) { - const [games, setGames] = useState([]); - - useEffect(() => { - LibraryEndpoint.getGamesInLibrary(library.id).then((games) => { - setGames(games); - }) - }, []); + const state = useSnapshot(gameState); + const games = state.gamesByLibraryId[library.id] ? state.gamesByLibraryId[library.id] as GameDto[] : undefined; return

Manage games in library

diff --git a/gameyfin/src/main/frontend/state/GameState.ts b/gameyfin/src/main/frontend/state/GameState.ts new file mode 100644 index 0000000..4eade5b --- /dev/null +++ b/gameyfin/src/main/frontend/state/GameState.ts @@ -0,0 +1,80 @@ +import {Subscription} from "@vaadin/hilla-frontend"; +import {proxy} from "valtio/index"; +import {GameEndpoint} from "Frontend/generated/endpoints"; +import GameEvent from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryEvent"; +import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto"; +import Rand from "rand-seed"; + +type GameState = { + subscription?: Subscription; + isLoaded: boolean; + state: Record; + games: GameDto[]; + gamesByLibraryId: Record; + sortedByMostRecentlyAdded: GameDto[]; + sortedByMostRecentlyUpdated: GameDto[]; + randomlyOrderedGamesByLibraryId: Record; +}; + +export const gameState = proxy({ + get isLoaded() { + return this.subscription != null; + }, + state: {}, + get games() { + return Object.values(this.state); + }, + get gamesByLibraryId() { + return this.games.reduce((acc: Record, game: GameDto) => { + (acc[game.libraryId] ||= []).push(game); + return acc; + }, {}); + }, + get sortedByMostRecentlyAdded() { + return this.games + .sort((a: GameDto, b: GameDto) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + }, + get sortedByMostRecentlyUpdated() { + return this.games + .sort((a: GameDto, b: GameDto) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); + }, + get randomlyOrderedGamesByLibraryId() { + const result: Record = {}; + for (const libraryId in this.gamesByLibraryId) { + const rand = new Rand(libraryId.toString()); + result[libraryId] = this.gamesByLibraryId[libraryId] + .filter((g: GameDto) => g.coverId && g.imageIds && g.imageIds.length > 0) + .sort((a: GameDto, b: GameDto) => a.id - b.id) + .sort(() => rand.next() - 0.5); + } + return result; + } +}); + +/** Subscribe to and process state updates from backend **/ +export async function initializeGameState() { + if (gameState.isLoaded) return gameState; + + // Fetch initial library list + const initialEntries = await GameEndpoint.getAll(); + initialEntries.forEach((game: GameDto) => { + gameState.state[game.id] = game; + }); + + // Subscribe to real-time updates + gameState.subscription = GameEndpoint.subscribe().onNext((gameEvent) => { + switch (gameEvent.type) { + case "created": + case "updated": + //@ts-ignore + gameState.state[gameEvent.game.id] = gameEvent.game; + break; + case "deleted": + //@ts-ignore + delete gameState.state[gameEvent.gameId]; + break; + } + }); + + return gameState; +} \ No newline at end of file diff --git a/gameyfin/src/main/frontend/state/LibraryState.ts b/gameyfin/src/main/frontend/state/LibraryState.ts index ba91f78..668a568 100644 --- a/gameyfin/src/main/frontend/state/LibraryState.ts +++ b/gameyfin/src/main/frontend/state/LibraryState.ts @@ -7,7 +7,7 @@ import LibraryEvent from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/Li type LibraryState = { subscription?: Subscription; isLoaded: boolean; - state: Record; + state: Record; libraries: LibraryDto[]; sorted: LibraryDto[]; }; diff --git a/gameyfin/src/main/frontend/util/utils.ts b/gameyfin/src/main/frontend/util/utils.ts index 2190312..a056083 100644 --- a/gameyfin/src/main/frontend/util/utils.ts +++ b/gameyfin/src/main/frontend/util/utils.ts @@ -1,23 +1,5 @@ import {getCsrfToken} from "Frontend/util/auth"; import moment from 'moment-timezone'; -import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto"; -import Rand from "rand-seed"; -import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto"; -import {LibraryEndpoint} from "Frontend/generated/endpoints"; - -export function cssVar(variable: string) { - return getComputedStyle(document.documentElement).getPropertyValue(`--${variable}`); -} - -export function hsl(hsl: string) { - return `hsl(${hsl}`; -} - -export function rand(min: number, max: number) { - const minCeiled = Math.ceil(min); - const maxFloored = Math.floor(max); - return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled); -} export function roleToRoleName(role: string) { role = role.replace("ROLE_", "").toLowerCase(); @@ -125,23 +107,6 @@ export function humanFileSize(bytes: number, si: boolean = false, dp: number = 1 return bytes.toFixed(dp) + ' ' + units[u]; } -/** - * Select a random number of games from the library based on the library ID. - * @param library - * @param count - * @returns {GameDto[]} - */ -export async function randomGamesFromLibrary(library: LibraryDto, count?: number): Promise { - const rand = new Rand(library.id.toString()); - const games = await LibraryEndpoint.getGamesInLibrary(library.id); - return games - .sort((a: GameDto, b: GameDto) => a.id - b.id) - .sort(() => rand.next() - 0.5) - .filter(g => g.imageIds && g.imageIds.length > 0) - .slice(0, count ?? games.length); -} - - /** * Return an object with the changed fields between two objects. * The returned object will only contain the changed fields with values from the current object. @@ -149,8 +114,6 @@ export async function randomGamesFromLibrary(library: LibraryDto, count?: number * @param current */ export function deepDiff(initial: T, current: T): Partial { - const diff: Partial = {}; - function compareObjects(obj1: any, obj2: any): any { if (typeof obj1 !== 'object' || typeof obj2 !== 'object' || obj1 === null || obj2 === null) { if (obj1 !== obj2) { diff --git a/gameyfin/src/main/frontend/views/GameView.tsx b/gameyfin/src/main/frontend/views/GameView.tsx index 2ef38bf..9340656 100644 --- a/gameyfin/src/main/frontend/views/GameView.tsx +++ b/gameyfin/src/main/frontend/views/GameView.tsx @@ -1,18 +1,22 @@ import {useEffect, useState} from "react"; -import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto"; -import {DownloadProviderEndpoint, GameEndpoint} from "Frontend/generated/endpoints"; -import {useParams} from "react-router"; +import {DownloadProviderEndpoint} from "Frontend/generated/endpoints"; +import {useNavigate, useParams} from "react-router"; import {GameCover} from "Frontend/components/general/covers/GameCover"; import ComboButton, {ComboButtonOption} from "Frontend/components/general/input/ComboButton"; import ImageCarousel from "Frontend/components/general/covers/ImageCarousel"; import {Chip} from "@heroui/react"; import {humanFileSize, toTitleCase} from "Frontend/util/utils"; import {DownloadEndpoint} from "Frontend/endpoints/endpoints"; +import {gameState, initializeGameState} from "Frontend/state/GameState"; +import {useSnapshot} from "valtio/react"; +import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto"; export default function GameView() { const {gameId} = useParams(); + const navigate = useNavigate(); + const state = useSnapshot(gameState); + const game = gameId ? state.state[parseInt(gameId)] as GameDto : undefined; - const [game, setGame] = useState(); const [downloadOptions, setDownloadOptions] = useState>({}); useEffect(() => { @@ -32,15 +36,17 @@ export default function GameView() { }, []); useEffect(() => { - if (gameId) { - GameEndpoint.getGame(parseInt(gameId)).then((game) => setGame(game)); - } + initializeGameState().then((state) => { + if (!gameId || !state.state[parseInt(gameId)]) { + navigate("/"); + } + }); }, [gameId]); - return (game && ( + return game && (
- {(game.imageIds !== undefined && game.imageIds.length > 0) ? + {(game.imageIds && game.imageIds.length > 0) ? Game screenshot {Object.entries({ - "Developed by": game.developers?.sort().join(" / ") || "unknown", - "Published by": game.publishers?.sort().join(" / ") || "unknown", - "Genres": game.genres?.sort().map(p => {toTitleCase(p)}), - "Themes": game.themes?.sort().map(p => {toTitleCase(p)}), - "Features": game.features?.sort().map(p => {toTitleCase(p)}), + "Developed by": game.developers ? [...game.developers].sort().join(" / ") : "unknown", + "Published by": game.publishers ? [...game.publishers].sort().join(" / ") : "unknown", + "Genres": game.genres ? [...game.genres].sort().map(p => + {toTitleCase(p)}) : undefined, + "Themes": game.themes ? [...game.themes].sort().map(p => + {toTitleCase(p)}) : undefined, + "Features": game.features ? [...game.features].sort().map(p => + {toTitleCase(p)}) : undefined, }).map(([key, value]) => ( {key} @@ -108,5 +114,5 @@ export default function GameView() {
- )); + ); } \ No newline at end of file diff --git a/gameyfin/src/main/frontend/views/HomeView.tsx b/gameyfin/src/main/frontend/views/HomeView.tsx index a40ae8e..45e947f 100644 --- a/gameyfin/src/main/frontend/views/HomeView.tsx +++ b/gameyfin/src/main/frontend/views/HomeView.tsx @@ -1,44 +1,23 @@ -import {useEffect, useState} from "react"; -import {GameEndpoint} from "Frontend/generated/endpoints"; import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto"; -import {randomGamesFromLibrary} from "Frontend/util/utils"; import {CoverRow} from "Frontend/components/general/CoverRow"; import {useSnapshot} from "valtio/react"; import {libraryState} from "Frontend/state/LibraryState"; +import {gameState} from "Frontend/state/GameState"; export default function HomeView() { - const [recentlyAddedGames, setRecentlyAddedGames] = useState([]); - const [libraryIdToGames, setLibraryIdToGames] = useState>(new Map()); - const state = useSnapshot(libraryState); - - useEffect(() => { - const gamePromises = state.libraries.map((library) => - //@ts-ignore - randomGamesFromLibrary(library).then((games) => [library.id, games] as [number, GameDto[]]) - ); - - Promise.all(gamePromises).then((results) => { - const libraryGamesMap = new Map(); - results.forEach(([libraryId, games]) => { - libraryGamesMap.set(libraryId, games); - }); - setLibraryIdToGames(libraryGamesMap); - }); - - // TODO: see https://github.com/vaadin/hilla/issues/3470 - GameEndpoint.getMostRecentlyAddedGames(undefined).then(games => { - setRecentlyAddedGames(games); - }); - }, []); + const librariesState = useSnapshot(libraryState); + const gamesState = useSnapshot(gameState); + const recentlyAddedGames = gamesState.sortedByMostRecentlyAdded as GameDto[]; + const gamesByLibrary = gamesState.gamesByLibraryId as Record; return (
alert("show more of 'Recently added'")}/> - {state.libraries.map((library) => ( + {librariesState.libraries.map((library) => ( alert(`show more of library '${library.name}'`)} /> ))} diff --git a/gameyfin/src/main/frontend/views/LibraryManagementView.tsx b/gameyfin/src/main/frontend/views/LibraryManagementView.tsx index 68ece27..136d367 100644 --- a/gameyfin/src/main/frontend/views/LibraryManagementView.tsx +++ b/gameyfin/src/main/frontend/views/LibraryManagementView.tsx @@ -16,13 +16,13 @@ export default function LibraryManagementView() { useEffect(() => { initializeLibraryState().then((state) => { - if (!libraryId || !state.state[libraryId]) { + if (!libraryId || !state.state[parseInt(libraryId)]) { navigate("/administration/libraries"); } }); - }, []); + }, [libraryId]); - return libraryId && state.state[libraryId] &&
+ return libraryId && state.state[parseInt(libraryId)] &&