Overhaul startpage (#821)

* 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

* 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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
Simon
2025-12-10 00:22:58 +01:00
committed by GitHub
parent 38b95ae102
commit 8d8dca32d8
137 changed files with 5218 additions and 809 deletions
+3 -1
View File
@@ -19,6 +19,7 @@ import {initializeGameRequestState} from "Frontend/state/GameRequestState";
import {initializePlatformState} from "Frontend/state/PlatformState";
import {initializeDownloadSessionState} from "Frontend/state/DownloadSessionState";
import {initializeUserState} from "Frontend/state/UserState";
import {initializeCollectionState} from "Frontend/state/CollectionState";
export default function App() {
client.middlewares = [ErrorHandlingMiddleware];
@@ -48,10 +49,11 @@ function ViewWithAuth() {
if (auth.state.initializing || auth.state.loading) return;
initializeLibraryState();
initializeGameState();
initializeCollectionState();
initializePlatformState();
initializeGameRequestState();
initializePluginState();
initializeGameState();
if (isAdmin(auth)) {
initializeScanState();
@@ -1,5 +1,5 @@
import {useAuth} from "Frontend/util/auth";
import { GearFineIcon, QuestionIcon, SignOutIcon, UserIcon } from "@phosphor-icons/react";
import {GearFineIcon, QuestionIcon, SignOutIcon, UserIcon} from "@phosphor-icons/react";
import {Dropdown, DropdownItem, DropdownMenu, DropdownTrigger} from "@heroui/react";
import {useNavigate} from "react-router";
import Avatar from "Frontend/components/general/Avatar";
@@ -19,7 +19,7 @@ export default function ProfileMenu() {
{
label: "Administration",
icon: <GearFineIcon/>,
onClick: () => navigate("/administration/libraries"),
onClick: () => navigate("/administration/games"),
showIf: isAdmin(auth)
},
{
@@ -12,7 +12,7 @@ import {DownloadSessionCard} from "Frontend/components/general/cards/DownloadSes
import {humanFileSize} from "Frontend/util/utils";
function DownloadManagementLayout({getConfig, formik}: any) {
const sessions = useSnapshot(downloadSessionState).all as SessionStatsDto[];
const sessions = useSnapshot(downloadSessionState).all;
const [lastDaySum, setLastDaySum] = React.useState<number>(0);
React.useEffect(() => {
@@ -0,0 +1,149 @@
import React from "react";
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import Section from "Frontend/components/general/Section";
import * as Yup from 'yup';
import "Frontend/util/yup-extensions";
import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
import {ListNumbersIcon, PlusIcon} from "@phosphor-icons/react";
import {LibraryOverviewCard} from "Frontend/components/general/cards/LibraryOverviewCard";
import LibraryCreationModal from "Frontend/components/general/modals/LibraryCreationModal";
import {useSnapshot} from "valtio/react";
import {libraryState} from "Frontend/state/LibraryState";
import LibraryPrioritiesModal from "Frontend/components/general/modals/LibraryPrioritiesModal";
import {collectionState} from "Frontend/state/CollectionState";
import {CollectionOverviewCard} from "Frontend/components/general/cards/CollectionOverviewCard";
import CollectionCreationModal from "Frontend/components/general/modals/CollectionCreationModal";
import CollectionPrioritiesModal from "Frontend/components/general/modals/CollectionPrioritiesModal";
function GameManagementLayout({getConfig, formik}: any) {
const libraries = useSnapshot(libraryState);
const libraryCreationModal = useDisclosure();
const libraryOrderModal = useDisclosure();
const collections = useSnapshot(collectionState);
const collectionCreationModal = useDisclosure();
const collectionOrderModal = useDisclosure();
return (
<div className="flex flex-col">
<div className="flex flex-row items-baseline justify-between">
<h2 className="text-xl font-bold mt-8 mb-1">Libraries</h2>
<div className="flex flex-row gap-2">
<Tooltip content="Change library order">
<Button isIconOnly variant="flat" onPress={libraryOrderModal.onOpen}>
<ListNumbersIcon/>
</Button>
</Tooltip>
<Tooltip content="Add new library">
<Button isIconOnly variant="flat" onPress={libraryCreationModal.onOpen}>
<PlusIcon/>
</Button>
</Tooltip>
</div>
</div>
<Divider className="mb-4"/>
{libraries.sorted.length > 0 ?
// Aspect ratio of cover = 12/17 -> 5 covers = 60/17 -> 353px * 100px
<div id="library-cards" className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(353px,1fr))]">
{libraries.sorted.map((library) =>
// @ts-ignore
<LibraryOverviewCard library={library} key={library.name}/>
)}
</div> :
<p className="mt-4 text-center text-default-500">No libraries found</p>
}
<div className="flex flex-row items-baseline justify-between">
<h2 className="text-xl font-bold mt-8 mb-1">Collections</h2>
<div className="flex flex-row gap-2">
<Tooltip content="Change collection order">
<Button isIconOnly variant="flat" onPress={collectionOrderModal.onOpen}>
<ListNumbersIcon/>
</Button>
</Tooltip>
<Tooltip content="Create new collection">
<Button isIconOnly variant="flat" onPress={collectionCreationModal.onOpen}>
<PlusIcon/>
</Button>
</Tooltip>
</div>
</div>
<Divider className="mb-4"/>
{collections.sorted.length > 0 ?
// Aspect ratio of cover = 12/17 -> 5 covers = 60/17 -> 353px * 100px
<div id="collection-cards" className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(353px,1fr))]">
{collections.sorted.map((collection) =>
// @ts-ignore
<CollectionOverviewCard collection={collection} key={collection.name}/>
)}
</div> :
<p className="mt-4 text-center text-default-500">No collections found</p>
}
<Section title="Scanning"/>
<div className="flex flex-col gap-4">
<ConfigFormField configElement={getConfig("library.scan.enable-filesystem-watcher")}/>
<ConfigFormField configElement={getConfig("library.scan.scan-empty-directories")}/>
<div className="flex flex-row gap-4 items-baseline">
<ConfigFormField configElement={getConfig("library.scan.extract-title-using-regex")}/>
<ConfigFormField configElement={getConfig("library.scan.title-extraction-regex")}
isDisabled={!formik.values.library.scan["extract-title-using-regex"]}/>
</div>
<ConfigFormField configElement={getConfig("library.scan.title-match-min-ratio")}/>
<ConfigFormField configElement={getConfig("library.scan.game-file-extensions")}/>
</div>
<Section title="Metadata"/>
<div className="flex flex-row items-baseline">
<ConfigFormField configElement={getConfig("library.metadata.update.enabled")}/>
<ConfigFormField configElement={getConfig("library.metadata.update.schedule")}
isDisabled={!formik.values.library.metadata.update.enabled}/>
</div>
<LibraryCreationModal
isOpen={libraryCreationModal.isOpen}
onOpenChange={libraryCreationModal.onOpenChange}
/>
<LibraryPrioritiesModal
isOpen={libraryOrderModal.isOpen}
onOpenChange={libraryOrderModal.onOpenChange}
/>
<CollectionCreationModal
isOpen={collectionCreationModal.isOpen}
onOpenChange={collectionCreationModal.onOpenChange}
/>
<CollectionPrioritiesModal
isOpen={collectionOrderModal.isOpen}
onOpenChange={collectionOrderModal.onOpenChange}/>
</div>
);
}
const validationSchema = Yup.object({
library: Yup.object({
metadata: Yup.object({
update: Yup.object({
enabled: Yup.boolean(),
schedule: Yup.string().when("enabled", {
is: true,
then: (schema) => schema.cron()
}),
})
}),
scan: Yup.object({
"extract-title-using-regex": Yup.boolean(),
"title-extraction-regex": Yup.string().when("extract-title-using-regex", {
is: true,
then: (schema) => schema.trim().required("Title extraction regex is required when enabled")
}),
"title-match-min-ratio": Yup.number().min(1, "Must be between 1-100").max(100, "Must be between 1-100")
})
})
});
export const GameManagement = withConfigPage(GameManagementLayout, "Games", validationSchema);
@@ -20,7 +20,7 @@ function GameRequestManagementLayout({getConfig, formik}: any) {
<div className="flex flex-row items-center gap-4">
<ConfigFormField
configElement={getConfig("requests.games.allow-guests-to-request-games")}
isDisabled={!formik.values.library["allow-public-access"]}/>
isDisabled={!formik.values.security["allow-public-access"]}/>
<ConfigFormField configElement={getConfig("requests.games.max-open-requests-per-user")}/>
</div>
@@ -1,115 +0,0 @@
import React from "react";
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import Section from "Frontend/components/general/Section";
import * as Yup from 'yup';
import "Frontend/util/yup-extensions";
import {addToast, Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
import {PlusIcon} from "@phosphor-icons/react";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {LibraryOverviewCard} from "Frontend/components/general/cards/LibraryOverviewCard";
import LibraryCreationModal from "Frontend/components/general/modals/LibraryCreationModal";
import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import {useSnapshot} from "valtio/react";
import {libraryState} from "Frontend/state/LibraryState";
function LibraryManagementLayout({getConfig, formik}: any) {
const libraryCreationModal = useDisclosure();
const state = useSnapshot(libraryState);
async function updateLibrary(library: LibraryUpdateDto) {
await LibraryEndpoint.updateLibrary(library);
addToast({
title: "Library updated",
description: `Library ${library.name} has been updated.`,
color: "success"
})
}
async function removeLibrary(library: LibraryDto) {
await LibraryEndpoint.deleteLibrary(library.id);
addToast({
title: "Library removed",
description: `Library ${library.name} has been removed.`,
color: "success"
})
}
return (
<div className="flex flex-col">
<Section title="Permissions"/>
<ConfigFormField configElement={getConfig("library.allow-public-access")}/>
<Section title="Scanning"/>
<div className="flex flex-col gap-4">
<ConfigFormField configElement={getConfig("library.scan.enable-filesystem-watcher")} isDisabled/>
<ConfigFormField configElement={getConfig("library.scan.scan-empty-directories")}/>
<div className="flex flex-row gap-4 items-baseline">
<ConfigFormField configElement={getConfig("library.scan.extract-title-using-regex")}/>
<ConfigFormField configElement={getConfig("library.scan.title-extraction-regex")}
isDisabled={!formik.values.library.scan["extract-title-using-regex"]}/>
</div>
<ConfigFormField configElement={getConfig("library.scan.title-match-min-ratio")}/>
<ConfigFormField configElement={getConfig("library.scan.game-file-extensions")}/>
</div>
<Section title="Metadata"/>
<div className="flex flex-row items-baseline">
<ConfigFormField configElement={getConfig("library.metadata.update.enabled")}/>
<ConfigFormField configElement={getConfig("library.metadata.update.schedule")}
isDisabled={!formik.values.library.metadata.update.enabled}/>
</div>
<div className="flex flex-row items-baseline justify-between">
<h2 className="text-xl font-bold mt-8 mb-1">Libraries</h2>
<Tooltip content="Add new library">
<Button isIconOnly variant="flat" onPress={libraryCreationModal.onOpen}>
<PlusIcon/>
</Button>
</Tooltip>
</div>
<Divider className="mb-4"/>
{state.sorted.length > 0 ?
// Aspect ratio of cover = 12/17 -> 5 covers = 60/17 -> 353px * 100px
<div id="library-cards" className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(353px,1fr))]">
{state.sorted.map((library) =>
// @ts-ignore
<LibraryOverviewCard library={library} updateLibrary={updateLibrary}
removeLibrary={removeLibrary} key={library.name}/>
)}
</div> :
<p className="mt-4 text-center text-default-500">No libraries found</p>
}
<LibraryCreationModal
isOpen={libraryCreationModal.isOpen}
onOpenChange={libraryCreationModal.onOpenChange}
/>
</div>
);
}
const validationSchema = Yup.object({
library: Yup.object({
metadata: Yup.object({
update: Yup.object({
enabled: Yup.boolean(),
schedule: Yup.string().when("enabled", {
is: true,
then: (schema) => schema.cron()
}),
})
}),
scan: Yup.object({
"extract-title-using-regex": Yup.boolean(),
"title-extraction-regex": Yup.string().when("extract-title-using-regex", {
is: true,
then: (schema) => schema.trim().required("Title extraction regex is required when enabled")
}),
"title-match-min-ratio": Yup.number().min(1, "Must be between 1-100").max(100, "Must be between 1-100")
})
})
});
export const LibraryManagement = withConfigPage(LibraryManagementLayout, "Library Management", validationSchema);
@@ -121,13 +121,13 @@ function MessageManagementLayout({getConfig, formik}: any) {
<EditTemplateModal
isOpen={editorModal.isOpen}
onOpenChange={editorModal.onOpenChange}
selectedTemplate={selectedTemplate!!}
selectedTemplate={selectedTemplate!}
/>
<SendTestNotificationModal
isOpen={testNotificationModal.isOpen}
onOpenChange={testNotificationModal.onOpenChange}
selectedTemplate={selectedTemplate!!}
selectedTemplate={selectedTemplate!}
/>
</div>
);
@@ -19,7 +19,7 @@ export default function PluginManagement() {
<div className="flex flex-col gap-8">
{pluginTypes.map(type =>
// @ts-ignore
<PluginManagementSection key={type} type={type} plugins={state.pluginsByType[type]}/>
<PluginManagementSection key={type} type={type}/>
)}
</div>
</div>
@@ -3,14 +3,14 @@ import withConfigPage from "Frontend/components/administration/withConfigPage";
import * as Yup from 'yup';
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import Section from "Frontend/components/general/Section";
import {addToast, Button, Checkbox, CheckboxGroup, Tooltip} from "@heroui/react";
import { MagicWandIcon, WarningIcon } from "@phosphor-icons/react";
import {addToast, Button} from "@heroui/react";
import {MagicWandIcon} from "@phosphor-icons/react";
function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
function SecurityManagementLayout({getConfig, formik, setSaveMessage}: any) {
useEffect(() => {
if (formik.dirty) {
setSaveMessage("Gameyfin must be restarted for the changes to take effect");
setSaveMessage("Gameyfin must be restarted for changes in the SSO configuration to take effect");
} else {
setSaveMessage(null);
}
@@ -43,41 +43,26 @@ function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
return (
<div className="flex flex-col">
<div className="flex flex-row">
<Section title="Permissions"/>
<ConfigFormField configElement={getConfig("security.allow-public-access")}/>
<Section title="Single Sign-On"/>
<div className="flex flex-row items-start gap-8">
<div className="flex flex-col">
<h2 className="text-xl font-bold mb-4">General configuration</h2>
<ConfigFormField className="mb-4"
configElement={getConfig("sso.oidc.enabled")}/>
<ConfigFormField configElement={getConfig("sso.oidc.match-existing-users-by")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.roles-claim")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.oauth-scopes")}
isDisabled={!formik.values.sso.oidc.enabled}/>
</div>
<div className="flex flex-col flex-1">
<Section title="SSO configuration"/>
<ConfigFormField configElement={getConfig("sso.oidc.enabled")}/>
<Section title="SSO user handling"/>
<div className="flex flex-row items-baseline mb-4">
<CheckboxGroup className="flex flex-col flex-1 items-baseline gap-2"
value={["auto-register-new-users"]}>
<div className="flex flex-row gap-2">
<Checkbox className="items-baseline" value="auto-register-new-users" isDisabled>
Automatically create new users after registration
</Checkbox>
<Tooltip content={"Currently not configurable (always enabled)"} placement="right">
<WarningIcon weight="fill"/>
</Tooltip>
</div>
</CheckboxGroup>
{/*TODO: enable when the issues with unregistered SSO users are sorted
<ConfigFormField configElement={getConfig("sso.oidc.auto-register-new-users")} isDisabled={!formik.values.sso.oidc.enabled}/>
*/}
<ConfigFormField configElement={getConfig("sso.oidc.match-existing-users-by")}
isDisabled={!formik.values.sso.oidc.enabled ||
!formik.values.sso.oidc["auto-register-new-users"]}/>
</div>
<div className="flex flex-row items-center gap-4">
<ConfigFormField configElement={getConfig("sso.oidc.roles-claim")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.oauth-scopes")}
isDisabled={!formik.values.sso.oidc.enabled}/>
</div>
<Section title="SSO provider configuration"/>
<h2 className="text-xl font-bold mb-4">SSO Provider Configuration</h2>
<ConfigFormField configElement={getConfig("sso.oidc.client-id")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.client-secret")}
@@ -111,7 +96,6 @@ const validationSchema = Yup.object({
sso: Yup.object({
oidc: Yup.object({
enabled: Yup.boolean(),
"auto-register-new-users": Yup.boolean().required(),
"match-existing-users-by": Yup.string().required(),
"client-id": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("Client ID is required") : schema
@@ -141,4 +125,4 @@ const validationSchema = Yup.object({
})
});
export const SsoManagement = withConfigPage(SsoManagementLayout, "Single Sign-On", validationSchema);
export const SecurityManagement = withConfigPage(SecurityManagementLayout, "Security", validationSchema);
@@ -4,8 +4,7 @@ import withConfigPage from "Frontend/components/administration/withConfigPage";
import Section from "Frontend/components/general/Section";
import {UserEndpoint} from "Frontend/generated/endpoints";
import {UserManagementCard} from "Frontend/components/general/cards/UserManagementCard";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import { InfoIcon, UserPlusIcon } from "@phosphor-icons/react";
import {UserPlusIcon} from "@phosphor-icons/react";
import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
import InviteUserModal from "Frontend/components/general/modals/InviteUserModal";
import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto";
@@ -32,10 +31,6 @@ function UserManagementLayout({getConfig, formik}: any) {
<div className="flex flex-row items-baseline justify-between">
<h2 className="text-xl font-bold mt-8 mb-1">Users</h2>
{!getConfig("sso.oidc.auto-register-new-users").value &&
<SmallInfoField className="mb-4 text-warning" icon={InfoIcon}
message="Automatic user registration for SSO users is disabled"/>
}
<Tooltip content="Invite new user">
<Button isIconOnly variant="flat" onPress={inviteUserModal.onOpen}>
<UserPlusIcon/>
@@ -3,7 +3,7 @@ import {ConfigEndpoint} from "Frontend/generated/endpoints";
import ConfigEntryDto from "Frontend/generated/org/gameyfin/app/config/dto/ConfigEntryDto";
import {Form, Formik} from "formik";
import {Button, Skeleton} from "@heroui/react";
import { CheckIcon, InfoIcon } from "@phosphor-icons/react";
import {CheckIcon, InfoIcon} from "@phosphor-icons/react";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import {configState, initializeConfigState, NestedConfig} from "Frontend/state/ConfigState";
import {useSnapshot} from "valtio/react";
@@ -32,7 +32,7 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
}
function getConfig(key: string): ConfigEntryDto | undefined {
return state.state[key] as ConfigEntryDto | undefined;
return state.state[key];
}
function getChangedValues(initial: NestedConfig, current: NestedConfig): Record<string, any> {
@@ -11,16 +11,15 @@ import {
} from "@heroui/react";
import {useSnapshot} from "valtio/react";
import {scanState} from "Frontend/state/ScanState";
import LibraryScanProgress from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanProgress";
import {libraryState} from "Frontend/state/LibraryState";
import { TargetIcon, WarningIcon } from "@phosphor-icons/react";
import {TargetIcon, WarningIcon} from "@phosphor-icons/react";
import {timeBetween, timeUntil, toTitleCase} from "Frontend/util/utils";
import LibraryScanStatus from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanStatus";
import {useEffect, useState} from "react";
export default function ScanProgressPopover() {
const libraries = useSnapshot(libraryState).state;
const scans = useSnapshot(scanState).sortedByStartTime as LibraryScanProgress[];
const scans = useSnapshot(scanState).sortedByStartTime;
const scanInProgress = useSnapshot(scanState).isScanning;
// Add state to track current time and force re-renders
@@ -50,7 +49,7 @@ export default function ScanProgressPopover() {
</Button>
</PopoverTrigger>
<PopoverContent>
<div className="flex flex-col gap-2 m-2 min-w-96 w-fit">
<div className="flex flex-col gap-2 m-2 min-w-md">
{scans.length === 0 ?
<p className="flex h-12 items-center justify-center text-sm text-default-500">
No scans in progress or in history.
@@ -59,12 +58,12 @@ export default function ScanProgressPopover() {
{scans.map((scan, index) =>
<div className="flex flex-col" key={scan.scanId}>
<div
className="flex flex-row justify-between items-center text-default-500 mb-1">
className="flex flex-row gap-4 justify-between items-center text-default-500 mb-1">
<p>{toTitleCase(scan.type)} scan for library&nbsp;
<Link underline="always"
color="foreground"
size="sm"
href={`/administration/libraries/library/${scan.libraryId}`}>
href={`/administration/games/library/${scan.libraryId}`}>
{libraries[scan.libraryId].name}
</Link>
</p>
@@ -1,8 +1,7 @@
import {Autocomplete, AutocompleteItem} from "@heroui/react";
import { CaretRightIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
import {CaretRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {useNavigate} from "react-router";
import {GameCover} from "Frontend/components/general/covers/GameCover";
@@ -10,7 +9,7 @@ export default function SearchBar() {
const navigate = useNavigate();
const state = useSnapshot(gameState);
const games = state.games as GameDto[];
const games = state.games;
return <Autocomplete
aria-label="Search for games"
@@ -0,0 +1,75 @@
import {Button, Card, Tooltip} from "@heroui/react";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import React, {useEffect, useState} from "react";
import {GameCover} from "Frontend/components/general/covers/GameCover";
import CollectionAdminDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionAdminDto";
import {SlidersHorizontalIcon} from "@phosphor-icons/react";
import {useNavigate} from "react-router";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
import ChipList from "Frontend/components/general/ChipList";
interface CollectionOverviewCardProps {
collection: CollectionAdminDto;
}
export function CollectionOverviewCard({collection}: CollectionOverviewCardProps) {
const MAX_COVER_COUNT = 5;
const navigate = useNavigate();
const state = useSnapshot(gameState);
const [randomGames, setRandomGames] = useState<GameDto[]>([]);
useEffect(() => {
if (!state.randomlyOrderedGamesByCollectionId) return;
setRandomGames(getRandomGames());
}, [state]);
function getRandomGames() {
if (!state.randomlyOrderedGamesByCollectionId[collection.id]) return [];
const games = state.randomlyOrderedGamesByCollectionId[collection.id]
.filter(game => game.cover?.id != null);
if (!games) return [];
return games.slice(0, MAX_COVER_COUNT);
}
return (
<Card className="flex flex-col justify-between w-[353px]">
<div className="flex flex-1 justify-center items-center">
<div className="flex flex-1 opacity-10 min-h-[100px]">
<IconBackgroundPattern/>
{randomGames.length > 0 &&
<div className="absolute flex flex-row">
{randomGames.map((game) => (
<GameCover game={game} size={100} radius="none" key={game.cover?.id}/>
))}
</div>
}
</div>
<p className="absolute text-2xl font-bold">{collection.name}</p>
<div className="absolute right-0 top-0 flex flex-row">
<Tooltip content="Configuration" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={() => navigate('collection/' + collection.id)}>
<SlidersHorizontalIcon/>
</Button>
</Tooltip>
</div>
</div>
{collection.stats &&
<div className="grid grid-rows-2 grid-cols-3 justify-items-center items-center p-2 pt-4">
<p>Games</p>
<p>Downloads</p>
<p>Platforms</p>
<p className="font-bold">{collection.stats.gamesCount}</p>
<p className="font-bold">{collection.stats.downloadCount}</p>
<ChipList items={collection.stats.gamePlatforms} maxVisible={0}
defaultContent={collection.stats.gamesCount > 0 ? "All" : "None"}/>
</div>
}
</Card>
);
}
@@ -1,5 +1,4 @@
import {Button, Card, Tooltip} from "@heroui/react";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import React from "react";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {GameCover} from "Frontend/components/general/covers/GameCover";
@@ -23,7 +22,9 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
const randomGames = getRandomGames();
function getRandomGames() {
const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[];
if (!state.randomlyOrderedGamesByLibraryId[library.id]) return [];
const games = state.randomlyOrderedGamesByLibraryId[library.id]
.filter(game => game.cover?.id != null);
if (!games) return [];
return games.slice(0, MAX_COVER_COUNT);
}
@@ -40,7 +41,7 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
{randomGames.length > 0 &&
<div className="absolute flex flex-row">
{randomGames.map((game) => (
<GameCover game={game} size={100} radius="none" key={game.coverId}/>
<GameCover game={game} size={100} radius="none" key={game.cover?.id}/>
))}
</div>
}
@@ -1,5 +1,20 @@
import {Button, Card, Chip, Tooltip, useDisclosure} from "@heroui/react";
import { CheckCircleIcon, IconContext, PauseCircleIcon, PlayCircleIcon, PowerIcon, QuestionIcon, QuestionMarkIcon, SealCheckIcon, SealQuestionIcon, SealWarningIcon, SlidersHorizontalIcon, StopCircleIcon, WarningCircleIcon, XCircleIcon } from "@phosphor-icons/react";
import {
CheckCircleIcon,
IconContext,
PauseCircleIcon,
PlayCircleIcon,
PowerIcon,
QuestionIcon,
QuestionMarkIcon,
SealCheckIcon,
SealQuestionIcon,
SealWarningIcon,
SlidersHorizontalIcon,
StopCircleIcon,
WarningCircleIcon,
XCircleIcon
} from "@phosphor-icons/react";
import PluginState from "Frontend/generated/org/pf4j/PluginState";
import React, {ReactNode} from "react";
import PluginDetailsModal from "Frontend/components/general/modals/PluginDetailsModal";
@@ -105,11 +120,11 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
return state === PluginState.DISABLED;
}
function togglePluginEnabled() {
async function togglePluginEnabled() {
if (isDisabled(plugin.state)) {
PluginEndpoint.enablePlugin(plugin.id);
await PluginEndpoint.enablePlugin(plugin.id);
} else {
PluginEndpoint.disablePlugin(plugin.id);
await PluginEndpoint.disablePlugin(plugin.id);
}
}
@@ -0,0 +1,84 @@
import {Card, Chip, Image} from "@heroui/react";
import React, {useMemo} from "react";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import CollectionDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionDto";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import Rand from "rand-seed";
import {useNavigate} from "react-router";
interface StartPageDisplayCardProps {
item: LibraryDto | CollectionDto;
}
export function StartPageDisplayCard({item}: StartPageDisplayCardProps) {
const navigate = useNavigate();
const isCollection = (libraryOrCollection: LibraryDto | CollectionDto): libraryOrCollection is CollectionDto => {
return 'description' in libraryOrCollection;
};
const isLibrary = (libraryOrCollection: LibraryDto | CollectionDto): libraryOrCollection is LibraryDto => {
return !('description' in libraryOrCollection);
};
const gamesState = useSnapshot(gameState);
const randomImageId = useMemo<number | null>(() => getRandomImageId(), [item]);
const link = useMemo<string>(() => getLink(), [item]);
const type = isCollection(item) ? 'Collection' : 'Library';
/**
* Gets a random cover ID from the games in the specified library or collection.
* Since the Random class is seeded with the game ID, the same game and image will always be selected for a given library/collection (unless the games inside change).
* @return {number | null} The random cover ID or null if none found.
*/
function getRandomImageId(): number | null {
let games: GameDto[] = [];
if (isCollection(item)) {
games = gamesState.randomlyOrderedGamesByCollectionId[item.id] as GameDto[];
} else if (isLibrary(item)) {
games = gamesState.randomlyOrderedGamesByLibraryId[item.id] as GameDto[];
}
if (!games || games.length == 0) return null;
// Find the first game that has at least one screenshot available
let game: GameDto | undefined = games.find(game => game.images && game.images.length > 0);
if (!game) return null;
const random = new Rand(`${item.id}-${game.id}`);
const randomImageIndex = Math.floor(random.next() * game.images!.length);
return game.images![randomImageIndex].id;
}
function getLink(): string {
if (isCollection(item)) {
return `/collection/${item.id}`;
} else if (isLibrary(item)) {
return `/library/${item.id}`;
}
return '#';
}
return randomImageId && (
<Card isPressable={true}
onPress={() => navigate(link)}
className="h-48 w-96 relative overflow-hidden scale-95 hover:scale-100 shine transition-all select-none">
<Image
src={`images/cover/${randomImageId}`}
className="absolute inset-0 w-full h-full object-cover brightness-40 z-0"
removeWrapper
/>
<div className="flex flex-col gap-1 relative z-10 items-center justify-center h-full">
<h2 className="text-white text-2xl font-bold text-center px-4">
{item.name}
</h2>
<Chip size="sm" radius="sm">{type}</Chip>
</div>
</Card>
);
}
@@ -0,0 +1,57 @@
import CollectionAdminDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionAdminDto";
import React, {useEffect, useState} from "react";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
import {Card} from "@heroui/react";
interface CollectionHeaderProps {
collection: CollectionAdminDto;
className?: string;
}
export default function CollectionHeader({collection, className}: CollectionHeaderProps) {
const MAX_COVER_COUNT = 5;
const state = useSnapshot(gameState);
const [randomGames, setRandomGames] = useState<GameDto[]>([]);
useEffect(() => {
if (!state.randomlyOrderedGamesByCollectionId) return;
setRandomGames(getRandomGames());
}, [state]);
function getRandomGames() {
if (!state.randomlyOrderedGamesByCollectionId[collection.id]) return [];
const games = state.randomlyOrderedGamesByCollectionId[collection.id]
.filter(game => game.images && game.images.length > 0);
if (!games) return [];
return games.slice(0, MAX_COVER_COUNT);
}
return (
<Card className={`overflow-hidden rounded-lg relative pointer-events-none select-none ${className}`}>
<IconBackgroundPattern/>
<div className="flex flex-row items-center w-full h-full brightness-50">
{randomGames.map((game, idx) => (
<div
key={idx}
className="flex-none overflow-hidden -ml-[10%]"
style={{
width: `calc(100% / ${MAX_COVER_COUNT - 2})`,
clipPath: 'polygon(15% 0, 100% 0, 85% 100%, 0% 100%)',
}}
>
<img
src={`/images/screenshot/${game.images![0].id}`}
alt={`Image ${idx}`}
/>
</div>
))}
</div>
<div className="absolute inset-0 flex items-center justify-center">
<h2 className="text-white text-3xl font-bold">{collection.name}</h2>
</div>
</Card>
);
}
@@ -1,16 +1,110 @@
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {GameCover} from "Frontend/components/general/covers/GameCover";
import type {CellComponentProps} from "react-window";
import {Grid} from "react-window";
import {useEffect, useRef, useState} from "react";
interface CoverGridProps {
games: GameDto[];
}
// Constants for grid layout
const MIN_COLUMN_WIDTH = 180; // Minimum width per item (minmax value from original)
const MAX_COLUMN_WIDTH = 212; // Maximum width per item (minmax value from original)
const GAP = 16; // gap-4 = 1rem = 16px
const ASPECT_RATIO = 12 / 17; // Game cover aspect ratio (width/height)
export default function CoverGrid({games}: CoverGridProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0);
// Update container width on resize
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current) {
setContainerWidth(containerRef.current.offsetWidth);
}
};
const resizeObserver = new ResizeObserver(updateDimensions);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
updateDimensions();
return () => resizeObserver.disconnect();
}, []);
// Calculate how many columns can fit
const columnCount = Math.max(1, Math.floor((containerWidth + GAP) / (MIN_COLUMN_WIDTH + GAP)));
// Calculate actual column width to distribute space evenly (up to MAX_COLUMN_WIDTH)
const actualColumnWidth = Math.min(
MAX_COLUMN_WIDTH,
Math.floor((containerWidth - (columnCount - 1) * GAP) / columnCount)
);
// Calculate cover height based on width and aspect ratio
// GameCover's size prop is the height, so we need to calculate height from width
const coverHeight = Math.floor(actualColumnWidth / ASPECT_RATIO);
// Calculate row count
const rowCount = Math.ceil(games.length / columnCount);
// Cell renderer for react-window Grid
const Cell = ({
columnIndex,
rowIndex,
style
}: CellComponentProps<{}>) => {
const gameIndex = rowIndex * columnCount + columnIndex;
// Return empty cell if we're past the end of the games array
if (gameIndex >= games.length) {
return <div style={style}/>;
}
const game = games[gameIndex];
return (
<div
style={{
...style,
paddingBottom: GAP,
display: 'flex',
justifyContent: 'center',
boxSizing: 'border-box'
}}
>
<GameCover game={game} interactive={true} size={coverHeight} lazy={true}/>
</div>
);
};
// Column width function to handle the last column differently
const getColumnWidth = (index: number) => {
// Last column doesn't need gap after it
if (index === columnCount - 1) {
return actualColumnWidth;
}
return actualColumnWidth + GAP;
};
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,212px))] gap-4 justify-center">
{games.map((game) => (
<GameCover key={game.id} game={game} interactive={true}/>
))}
<div ref={containerRef} className="w-full">
{containerWidth > 0 && (
<Grid<{}>
columnCount={columnCount}
columnWidth={getColumnWidth}
rowCount={rowCount}
rowHeight={coverHeight + GAP}
defaultWidth={containerWidth}
cellComponent={Cell}
cellProps={{}}
style={{overflowX: 'hidden'}}
/>
)}
</div>
);
}
@@ -1,66 +1,166 @@
import React, {useEffect, useRef, useState} from "react";
import {GameCover} from "Frontend/components/general/covers/GameCover";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {ArrowRightIcon} from "@phosphor-icons/react";
import {CaretLeftIcon, CaretRightIcon} from "@phosphor-icons/react";
import {Button, Link} from "@heroui/react";
import {Grid, GridImperativeAPI} from "react-window";
interface CoverRowProps {
games: GameDto[];
title: string;
onPressShowMore: () => void;
link: string;
}
const aspectRatio = 12 / 17; // aspect ratio of the game cover
const defaultImageHeight = 300; // default height for the image
const defaultImageWidth = aspectRatio * defaultImageHeight; // default width for the image
const gap = 8; // gap between items in pixels (gap-2 = 0.5rem = 8px)
export function CoverRow({games, title, onPressShowMore}: CoverRowProps) {
export function CoverRow({games, title, link}: CoverRowProps) {
const gridRef = useRef<GridImperativeAPI | null>(null);
const [scrollPosition, setScrollPosition] = useState(0);
const [containerWidth, setContainerWidth] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(games.length);
// Update container width on resize
useEffect(() => {
const calculateVisible = () => {
const updateWidth = () => {
if (containerRef.current) {
const containerWidth = containerRef.current.offsetWidth;
const maxFit = Math.floor((containerWidth - defaultImageWidth) / defaultImageWidth) + 1;
setVisibleCount(maxFit < games.length ? maxFit : games.length);
setContainerWidth(containerRef.current.offsetWidth);
}
};
const resizeObserver = new ResizeObserver(calculateVisible);
const resizeObserver = new ResizeObserver(updateWidth);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
calculateVisible(); // initial calculation
updateWidth();
return () => resizeObserver.disconnect();
}, [games.length]);
}, []);
const showMore = visibleCount < games.length;
// Handle scroll updates - track scroll position from the grid element
useEffect(() => {
let gridElement: HTMLDivElement | null = null;
const handleScroll = () => {
if (gridElement) {
setScrollPosition(gridElement.scrollLeft);
}
};
// Small delay to ensure grid is mounted
const timer = setTimeout(() => {
gridElement = gridRef.current?.element ?? null;
if (gridElement) {
gridElement.addEventListener('scroll', handleScroll);
// Initial scroll position
setScrollPosition(gridElement.scrollLeft);
}
}, 100);
return () => {
clearTimeout(timer);
if (gridElement) {
gridElement.removeEventListener('scroll', handleScroll);
}
};
}, [containerWidth, games.length]);
const totalWidth = games.length * (defaultImageWidth + gap);
const maxScroll = Math.max(0, totalWidth - containerWidth);
const scrollLeft = () => {
const gridElement = gridRef.current?.element;
if (gridElement) {
const itemWidth = defaultImageWidth + gap;
const scrollAmount = itemWidth * 3; // Scroll exactly 3 items
const newPosition = Math.max(0, scrollPosition - scrollAmount);
gridElement.scrollTo({
left: newPosition,
behavior: "smooth"
});
}
};
const scrollRight = () => {
const gridElement = gridRef.current?.element;
if (gridElement) {
const itemWidth = defaultImageWidth + gap;
const scrollAmount = itemWidth * 3; // Scroll exactly 3 items
const newPosition = Math.min(maxScroll, scrollPosition + scrollAmount);
gridElement.scrollTo({
left: newPosition,
behavior: "smooth"
});
}
};
const canScrollLeft = scrollPosition > 1; // Allow small margin for floating point issues
const canScrollRight = scrollPosition < maxScroll - 1 && maxScroll > 0;
// Cell renderer for react-window Grid
const Cell = ({columnIndex, style}: {
ariaAttributes: { "aria-colindex": number; role: "gridcell" };
columnIndex: number;
rowIndex: number;
style: React.CSSProperties;
}) => {
const game = games[columnIndex];
return (
<div style={{...style, paddingRight: gap}}>
<GameCover game={game} radius="sm" interactive={true}/>
</div>
);
};
return (
<div className="flex flex-col mb-4">
<p className="text-2xl font-bold mb-4">{title}</p>
<div className="w-full relative">
<div ref={containerRef} className="flex flex-row gap-2 rounded-md bg-transparent">
{games.slice(0, visibleCount).map((game, index) => (
<GameCover key={index} game={game} radius="sm" interactive={true}/>
))}
<div className="flex flex-row justify-between items-baseline mb-4">
<Link href={link} className="flex flex-row gap-1 w-fit items-baseline" color="foreground"
underline="hover">
<p className="text-2xl font-bold">{title}</p>
<CaretRightIcon weight="bold" size={16}/>
</Link>
<div className="flex flex-row gap-2">
<Button
isIconOnly
size="sm"
variant="flat"
onPress={scrollLeft}
isDisabled={!canScrollLeft}
aria-label="Scroll left"
>
<CaretLeftIcon weight="bold" size={20}/>
</Button>
<Button
isIconOnly
size="sm"
variant="flat"
onPress={scrollRight}
isDisabled={!canScrollRight}
aria-label="Scroll right"
>
<CaretRightIcon weight="bold" size={20}/>
</Button>
</div>
{showMore && (
<div className="flex flex-row items-center justify-end cursor-pointer"
onClick={onPressShowMore}>
<div className="absolute h-full w-1/4 right-0 bottom-0
bg-linear-to-r from-transparent to-background
transition-all duration-300 ease-in-out hover:opacity-80"/>
<div
className="absolute h-full right-0 bottom-0 flex flex-row items-center gap-2 pointer-events-none">
<p className="text-xl font-semibold">Show more</p>
<ArrowRightIcon weight="bold"/>
</div>
</div>
</div>
<div ref={containerRef} className="w-full relative overflow-hidden">
{containerWidth > 0 && (
<Grid<{}>
gridRef={gridRef}
columnCount={games.length}
columnWidth={defaultImageWidth + gap}
rowCount={1}
rowHeight={defaultImageHeight}
defaultHeight={defaultImageHeight}
defaultWidth={containerWidth}
cellComponent={Cell}
cellProps={{}}
className="scrollbar-hide"
style={{overflow: 'auto'}}
/>
)}
</div>
</div>
@@ -1,21 +1,105 @@
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {Image} from "@heroui/react";
import {GameCoverFallback} from "Frontend/components/general/covers/GameCoverFallback";
import {useEffect, useRef, useState} from "react";
import {decode} from "blurhash";
// Cache to track which images have been loaded across component remounts
const loadedImagesCache = new Set<number>();
interface GameCoverProps {
game: GameDto;
size?: number;
radius?: "none" | "sm" | "md" | "lg";
interactive?: boolean;
lazy?: boolean;
}
export function GameCover({game, size = 300, radius = "sm", interactive = false}: GameCoverProps) {
const coverContent = Number.isInteger(game.coverId) ? (
<div className={`${interactive ? "rounded-md scale-95 hover:scale-100 shine transition-all" : ""}`}>
export function GameCover({game, size = 300, radius = "sm", interactive = false, lazy = false}: GameCoverProps) {
const [shouldLoad, setShouldLoad] = useState(!lazy);
// Check cache to see if this image has already been loaded
const [isImageLoaded, setIsImageLoaded] = useState(
game.cover ? loadedImagesCache.has(game.cover.id) : false
);
const [blurhashUrl, setBlurhashUrl] = useState<string | undefined>(undefined);
const containerRef = useRef<HTMLDivElement>(null);
// Generate blurhash placeholder image
useEffect(() => {
if (game.cover?.blurhash) {
try {
// Decode blurhash to pixel data
const pixels = decode(game.cover.blurhash, 32, 45); // Small size for placeholder
// Create canvas and draw pixels
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 45;
const ctx = canvas.getContext('2d');
if (ctx) {
const imageData = ctx.createImageData(32, 45);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
// Convert canvas to data URL
setBlurhashUrl(canvas.toDataURL());
}
} catch (e) {
console.error('Failed to decode blurhash:', e);
}
}
}, [game.cover?.blurhash]);
useEffect(() => {
if (!lazy || shouldLoad) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setShouldLoad(true);
observer.disconnect();
}
});
},
{
rootMargin: '200px', // Start loading 200px before the element enters viewport
}
);
if (containerRef.current) {
observer.observe(containerRef.current);
}
return () => observer.disconnect();
}, [lazy, shouldLoad]);
// Preload the real image when shouldLoad becomes true
useEffect(() => {
if (!shouldLoad || !game.cover || isImageLoaded) return;
const img = document.createElement('img');
img.src = `images/cover/${game.cover.id}`;
img.onload = () => {
loadedImagesCache.add(game.cover!.id);
setIsImageLoaded(true);
};
img.onerror = () => {
// If image fails to load, we'll just show the fallback
setIsImageLoaded(true);
};
}, [shouldLoad, game.cover, isImageLoaded]);
const coverContent = game.cover ? (
<div
ref={containerRef}
className={`${interactive ? "rounded-md scale-95 hover:scale-100 shine transition-all" : ""}`}
>
<Image
alt={game.title}
className="z-0 object-cover aspect-12/17"
src={`images/cover/${game.coverId}`}
src={shouldLoad && isImageLoaded ? `images/cover/${game.cover.id}` : blurhashUrl}
radius={radius}
height={size}
fallbackSrc={<GameCoverFallback title={game.title} size={size} radius={radius}/>}
@@ -1,6 +1,5 @@
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import React from "react";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
@@ -17,7 +16,9 @@ export default function LibraryHeader({library, className}: LibraryHeaderProps)
const randomGames = getRandomGames();
function getRandomGames() {
const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[];
if (!state.randomlyOrderedGamesByLibraryId[library.id]) return [];
const games = state.randomlyOrderedGamesByLibraryId[library.id]
.filter(game => game.images && game.images.length > 0);
if (!games) return [];
return games.slice(0, MAX_COVER_COUNT);
}
@@ -36,7 +37,7 @@ export default function LibraryHeader({library, className}: LibraryHeaderProps)
}}
>
<img
src={`/images/screenshot/${game.imageIds![0]}`}
src={`/images/screenshot/${game.images![0].id}`}
alt={`Image ${idx}`}
/>
</div>
@@ -1,7 +1,7 @@
import {FieldArray, useField} from "formik";
import {Button, Chip, Input, Popover, PopoverContent, PopoverTrigger} from "@heroui/react";
import {KeyboardEvent, useState} from "react";
import { PlusIcon } from "@phosphor-icons/react";
import {PlusIcon} from "@phosphor-icons/react";
// @ts-ignore
const ArrayInput = ({label, ...props}) => {
@@ -35,13 +35,23 @@ const ArrayInput = ({label, ...props}) => {
<div className="flex flex-row flex-wrap gap-2 items-center">
{field.value.map((element: any, index: number) => (
<Chip key={index} onClose={() => arrayHelpers.remove(index)}>
<Chip key={index}
onClose={() => arrayHelpers.remove(index)}
isDisabled={props.isDisabled}
>
{element}
</Chip>
))}
<Popover placement="bottom" showArrow={true}>
<PopoverTrigger>
<Button isIconOnly size="sm" variant="light" radius="full"><PlusIcon/></Button>
<Button isIconOnly
size="sm"
variant="light"
radius="full"
isDisabled={props.isDisabled}
>
<PlusIcon/>
</Button>
</PopoverTrigger>
<PopoverContent>
<Input
@@ -77,7 +77,7 @@ export default function FileTreeView({onPathChange}: { onPathChange: (file: stri
if (subDirectories === undefined) return;
const newNodes = fileDtosToNodes(subDirectories as FileDto[]);
const updatedTree = updateTreeWithNewNodes(fileTree!!, element.id, newNodes);
const updatedTree = updateTreeWithNewNodes(fileTree!, element.id, newNodes);
setFileTree(updatedTree);
setFlattenedFileTree(flattenTree(updatedTree));
@@ -2,7 +2,7 @@ import {Image, useDisclosure} from "@heroui/react";
import React from "react";
import {useField} from "formik";
import {GameCoverPickerModal} from "Frontend/components/general/modals/GameCoverPickerModal";
import { ImageBrokenIcon, PencilIcon } from "@phosphor-icons/react";
import {ImageBrokenIcon, PencilIcon} from "@phosphor-icons/react";
// @ts-ignore
@@ -16,12 +16,12 @@ export default function GameCoverPicker({game, showErrorUntouched = false, ...pr
return (<>
<div className="relative group aspect-12/17 cursor-pointer bg-background/50"
onClick={gameCoverPickerModal.onOpenChange}>
{field.value || game.coverId ?
{field.value || game.cover?.id ?
<div className="size-full overflow-hidden">
<Image
alt={game.title}
className="z-0 object-cover group-hover:brightness-25"
src={field.value ? field.value : `images/cover/${game.coverId}`}
src={field.value ? field.value : `images/cover/${game.cover?.id}`}
{...props}
{...field}
radius="none"
@@ -16,12 +16,12 @@ export default function GameHeaderPicker({game, showErrorUntouched = false, ...p
return (<>
<div className="relative group size-full cursor-pointer bg-background/50"
onClick={gameHeaderPickerModal.onOpenChange}>
{field.value || game.headerId ?
{field.value || game.header?.id ?
<div className="size-full overflow-hidden">
<Image
alt={game.title}
className="z-0 object-cover group-hover:brightness-25"
src={field.value ? field.value : `images/cover/${game.headerId}`}
src={field.value ? field.value : `images/cover/${game.header?.id}`}
{...props}
{...field}
radius="none"
@@ -14,6 +14,7 @@ import * as Yup from "yup";
import ArrayInputAutocomplete from "Frontend/components/general/input/ArrayInputAutocomplete";
import {useSnapshot} from "valtio/react";
import {platformState} from "Frontend/state/PlatformState";
import CheckboxInput from "Frontend/components/general/input/CheckboxInput";
interface LibraryManagementDetailsProps {
library: LibraryDto;
@@ -45,7 +46,7 @@ export default function LibraryManagementDetails({library}: LibraryManagementDet
color: "success"
});
navigate("/administration/libraries");
navigate("/administration/games");
} catch (e) {
addToast({
title: "Error deleting library",
@@ -84,6 +85,8 @@ export default function LibraryManagementDetails({library}: LibraryManagementDet
<Input label="Library name" name="name"/>
<CheckboxInput label="Display on homepage" name="metadata.displayOnHomepage" className="mb-4"/>
<ArrayInputAutocomplete options={Array.from(availablePlatforms)} name="platforms" label="Platforms"/>
<DirectoryMappingInput name="directories"/>
@@ -38,12 +38,12 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
const rowsPerPage = 25;
const state = useSnapshot(gameState);
const games = state.gamesByLibraryId[library.id] ? state.gamesByLibraryId[library.id] as GameAdminDto[] : [];
const games = state.gamesByLibraryId[library.id] ? state.gamesByLibraryId[library.id] : [];
const [searchTerm, setSearchTerm] = useState("");
const [filter, setFilter] = useState<"all" | "confirmed" | "nonConfirmed">("all");
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "title", direction: "ascending"});
const [selectedGame, setSelectedGame] = useState<GameAdminDto>(games[0]);
const [selectedGame, setSelectedGame] = useState<GameAdminDto>(games[0] as GameAdminDto);
const editGameModal = useDisclosure();
const matchGameModal = useDisclosure();
@@ -94,7 +94,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
function getFilteredGames() {
let filteredGames = (games as GameAdminDto[]).filter((game) =>
game.metadata.path!!.toLowerCase().includes(searchTerm.toLowerCase()) ||
game.metadata.path!.toLowerCase().includes(searchTerm.toLowerCase()) ||
game.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
game.publishers?.some(publisher => publisher.toLowerCase().includes(searchTerm.toLowerCase())) ||
game.developers?.some(developer => developer.toLowerCase().includes(searchTerm.toLowerCase()))
@@ -102,10 +102,10 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
if (filter === "confirmed") {
return filteredGames.filter(g => g.metadata.matchConfirmed);
}
if (filter === "nonConfirmed") {
} else if (filter === "nonConfirmed") {
return filteredGames.filter(g => !g.metadata.matchConfirmed);
}
return filteredGames;
}
@@ -178,7 +178,8 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
<Link href={`/game/${item.id}`}
color="foreground"
className="text-sm"
underline="hover">{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
underline="hover">
{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
</Link>
</TableCell>
<TableCell>
@@ -238,7 +239,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
<EditGameMetadataModal game={selectedGame}
isOpen={editGameModal.isOpen}
onOpenChange={editGameModal.onOpenChange}/>
<MatchGameModal path={selectedGame.metadata.path!!}
<MatchGameModal path={selectedGame.metadata.path!}
libraryId={library.id}
replaceGameId={selectedGame.id}
initialSearchTerm={selectedGame.title}
@@ -85,7 +85,7 @@ export default function LibraryManagementIgnoredPaths({library}: LibraryManageme
}
function getFilteredPaths() {
return library.ignoredPaths!!.filter((path) =>
return library.ignoredPaths!.filter((path) =>
path.path.toLowerCase().includes(searchTerm.toLowerCase())
)
}
@@ -165,7 +165,10 @@ export default function LibraryManagementIgnoredPaths({library}: LibraryManageme
</Tooltip>
<Tooltip content="Remove entry from list">
<Button isIconOnly size="sm" color="danger"
onPress={() => deleteIgnoredPath(item.path)}><TrashIcon/>
onPress={() => deleteIgnoredPath(item.path)}
isDisabled={item.path.sourceType !== IgnoredPathSourceTypeDto.USER}
>
<TrashIcon/>
</Button>
</Tooltip>
</div>
@@ -0,0 +1,90 @@
import React from "react";
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {Form, Formik} from "formik";
import Input from "Frontend/components/general/input/Input";
import {CollectionEndpoint} from "Frontend/generated/endpoints";
import CollectionCreateDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionCreateDto";
import * as Yup from "yup";
import TextAreaInput from "Frontend/components/general/input/TextAreaInput";
interface CollectionCreationModalProps {
isOpen: boolean;
onOpenChange: () => void;
}
export default function CollectionCreationModal({
isOpen,
onOpenChange
}: CollectionCreationModalProps) {
async function createCollection(collection: CollectionCreateDto) {
await CollectionEndpoint.createCollection(collection);
addToast({
title: "New collection created",
description: `Collection ${collection.name} created!`,
color: "success"
});
}
return (<>
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="xl">
<ModalContent>
{(onClose) => (
<Formik
initialValues={{
name: "",
description: ""
}}
validationSchema={Yup.object({
name: Yup.string()
.required("Collection name is required")
.max(255, "Collection name must be 255 characters or less")
})}
isInitialValid={false}
onSubmit={async (values: any) => {
await createCollection(values);
onClose();
}}
>
{(formik) =>
<Form>
<ModalHeader className="flex flex-col gap-1">Create a new collection</ModalHeader>
<ModalBody>
<div className="flex flex-col gap-2">
<Input
name="name"
label="Collection Name"
placeholder="Enter collection name"
value={formik.values.name}
required
/>
<TextAreaInput
name="description"
label="Collection Description"
placeholder="Enter collection description"
value={formik.values.description}
/>
</div>
</ModalBody>
<ModalFooter className="flex flex-row justify-end">
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting}
type="submit"
>
{formik.isSubmitting ? "" : "Add"}
</Button>
</ModalFooter>
</Form>
}
</Formik>
)}
</ModalContent>
</Modal>
</>
);
}
@@ -0,0 +1,181 @@
import {useSnapshot} from "valtio/react";
import {
Button,
Input,
Link,
Select,
SelectItem,
SortDescriptor,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
Tooltip
} from "@heroui/react";
import React, {useMemo, useState} from "react";
import {GameAdminDto} from "Frontend/dtos/GameDtos";
import {CollectionEndpoint} from "Frontend/generated/endpoints";
import {MinusIcon, PlusIcon} from "@phosphor-icons/react";
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
import {libraryState} from "Frontend/state/LibraryState";
import {gameState} from "Frontend/state/GameState";
import {collectionState} from "Frontend/state/CollectionState";
interface CollectionGamesTableProps {
collectionId: number;
}
export default function CollectionGamesTable({collectionId}: CollectionGamesTableProps) {
const gamesState = useSnapshot(gameState);
const games = gamesState.games as GameAdminDto[];
const librariesState = useSnapshot(libraryState);
const libraries = librariesState.state as Record<number, LibraryAdminDto>;
const collectionsState = useSnapshot(collectionState);
const collection = collectionsState.state[collectionId];
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "path", direction: "ascending"});
const [searchTerm, setSearchTerm] = useState("");
const [filter, setFilter] = useState<"all" | "inCollection" | "notInCollection">("all");
function libraryName(game: GameAdminDto) {
return libraries[game.libraryId]?.name || "Unknown";
}
const gameInCollectionMap = useMemo(() => {
const map = new Map<number, boolean>();
games.forEach(game => {
map.set(game.id, collection.gameIds!.includes(game.id));
});
return map;
}, [games, collection.gameIds]);
function isGameInCollection(game: GameAdminDto) {
return gameInCollectionMap.get(game.id) ?? false;
}
const filteredGames = useMemo(() => {
return games
.filter((game) => game.title.toLowerCase().includes(searchTerm.toLowerCase()))
.filter(game => {
if (filter === "inCollection") {
return isGameInCollection(game);
} else if (filter === "notInCollection") {
return !isGameInCollection(game);
}
return true;
});
}, [games, searchTerm, filter, gameInCollectionMap]);
const sortedGames = useMemo(() => {
return filteredGames
.slice()
.sort((a, b) => {
let cmp: number;
switch (sortDescriptor.column) {
case "title":
cmp = a.title.localeCompare(b.title);
break;
case "library":
cmp = (libraryName(a)).localeCompare(libraryName(b));
break;
default:
cmp = 0;
}
if (sortDescriptor.direction === "descending") {
cmp *= -1;
}
return cmp;
})
.map(game => ({...game, _inCollection: isGameInCollection(game)}));
}, [filteredGames, sortDescriptor, libraries, gameInCollectionMap]);
async function addGameToCollection(game: GameAdminDto) {
await CollectionEndpoint.addGameToCollection(collectionId, game.id);
}
async function removeGameFromCollection(game: GameAdminDto) {
await CollectionEndpoint.removeGameFromCollection(collectionId, game.id);
}
return (
<div className="flex flex-col gap-2">
<div className="flex flex-row gap-2 justify-between">
<Input
className="w-96"
isClearable
placeholder="Search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onClear={() => setSearchTerm("")}
/>
<Select
selectedKeys={[filter]}
disallowEmptySelection
onSelectionChange={keys => setFilter(Array.from(keys)[0] as any)}
className="w-64"
>
<SelectItem key="all">Show all games</SelectItem>
<SelectItem key="inCollection">Show only games in collection</SelectItem>
<SelectItem key="notInCollection">Show only games not in collection</SelectItem>
</Select>
</div>
<Table isStriped isHeaderSticky
sortDescriptor={sortDescriptor}
onSortChange={setSortDescriptor}
classNames={{
base: "h-96 overflow-scroll"
}}>
<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>);
}
+4
View File
@@ -6,6 +6,10 @@ import {router} from './routes';
const container = document.getElementById('outlet')!;
const root = createRoot(container);
declare module 'valtio' {
function useSnapshot<T extends object>(p: T): T
}
root.render(
<StrictMode>
<RouterProvider router={router}/>
+20 -15
View File
@@ -4,10 +4,10 @@ import HomeView from "Frontend/views/HomeView";
import SetupView from "Frontend/views/SetupView";
import {ThemeSelector} from "Frontend/components/theming/ThemeSelector";
import App from "Frontend/App";
import {LibraryManagement} from "Frontend/components/administration/LibraryManagement";
import {GameManagement} from "Frontend/components/administration/GameManagement";
import {UserManagement} from "Frontend/components/administration/UserManagement";
import ProfileManagement from "Frontend/components/administration/ProfileManagement";
import {SsoManagement} from "Frontend/components/administration/SsoManagement";
import {SecurityManagement} from "Frontend/components/administration/SecurityManagement";
import {AdministrationView} from "Frontend/views/AdministrationView";
import {ProfileView} from "Frontend/views/ProfileView";
import {MessageManagement} from "Frontend/components/administration/MessageManagement";
@@ -20,13 +20,14 @@ import {SystemManagement} from "Frontend/components/administration/SystemManagem
import GameView from "Frontend/views/GameView";
import LibraryManagementView from "Frontend/views/LibraryManagementView";
import SearchView from "Frontend/views/SearchView";
import RecentlyAddedView from "Frontend/views/RecentlyAddedView";
import LibraryView from "Frontend/views/LibraryView";
import {RouterConfigurationBuilder} from "@vaadin/hilla-file-router/runtime.js";
import ErrorView from "Frontend/views/ErrorView";
import GameRequestView from "Frontend/views/GameRequestView";
import {GameRequestManagement} from "Frontend/components/administration/GameRequestManagement";
import {DownloadManagement} from "Frontend/components/administration/DownloadManagement";
import CollectionManagementView from "Frontend/views/CollectionManagementView";
import CollectionView from "Frontend/views/CollectionView";
export const {router, routes} = new RouterConfigurationBuilder()
.withReactRoutes([
@@ -45,11 +46,6 @@ export const {router, routes} = new RouterConfigurationBuilder()
element: <SearchView/>,
handle: {title: 'Search'}
},
{
path: 'recently-added',
element: <RecentlyAddedView/>,
handle: {title: 'Recently Added'}
},
{
path: '/requests',
element: <GameRequestView/>,
@@ -59,6 +55,10 @@ export const {router, routes} = new RouterConfigurationBuilder()
path: 'library/:libraryId',
element: <LibraryView/>
},
{
path: 'collection/:collectionId',
element: <CollectionView/>
},
{
path: 'game/:gameId',
element: <GameView/>
@@ -86,15 +86,20 @@ export const {router, routes} = new RouterConfigurationBuilder()
handle: {title: 'Administration'},
children: [
{
path: 'libraries',
element: <LibraryManagement/>,
handle: {title: 'Administration - Libraries'}
path: 'games',
element: <GameManagement/>,
handle: {title: 'Administration - Games'}
},
{
path: 'libraries/library/:libraryId',
path: 'games/library/:libraryId',
element: <LibraryManagementView/>,
handle: {title: 'Administration - Library'}
},
{
path: 'games/collection/:collectionId',
element: <CollectionManagementView/>,
handle: {title: 'Administration - Collection'}
},
{
path: 'requests',
element: <GameRequestManagement/>,
@@ -111,9 +116,9 @@ export const {router, routes} = new RouterConfigurationBuilder()
handle: {title: 'Administration - Users'}
},
{
path: 'sso',
element: <SsoManagement/>,
handle: {title: 'Administration - SSO'}
path: 'security',
element: <SecurityManagement/>,
handle: {title: 'Administration - Security'}
},
{
path: 'messages',
@@ -0,0 +1,70 @@
import {Subscription} from "@vaadin/hilla-frontend";
import {proxy} from "valtio/index";
import {CollectionEndpoint} from "Frontend/generated/endpoints";
import CollectionDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionDto";
import CollectionEvent from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionEvent";
type CollectionState = {
subscription?: Subscription<CollectionEvent[]>;
isLoaded: boolean;
state: Record<number, CollectionDto>;
collections: CollectionDto[];
sorted: CollectionDto[];
};
export const collectionState = proxy<CollectionState>({
get isLoaded() {
return this.subscription != null;
},
state: {},
get collections() {
return Object.values<CollectionDto>(this.state);
},
get sorted() {
return Object.values<CollectionDto>(this.state).sort((a: any, b: any) => {
const orderA = a.metadata?.displayOrder ?? -1;
const orderB = b.metadata?.displayOrder ?? -1;
// Handle -1 as "end of list"
const effectiveOrderA = orderA === -1 ? Number.MAX_SAFE_INTEGER : orderA;
const effectiveOrderB = orderB === -1 ? Number.MAX_SAFE_INTEGER : orderB;
const orderDiff = effectiveOrderA - effectiveOrderB;
if (orderDiff !== 0) {
return orderDiff;
}
// Fallback to creation date (newer first)
return new Date(a.createdAt!).getTime() - new Date(b.createdAt!).getTime();
});
}
});
/** Subscribe to and process state updates from backend **/
export async function initializeCollectionState() {
if (collectionState.isLoaded) return;
// Fetch initial collection list
const initialEntries = await CollectionEndpoint.getAll();
initialEntries.forEach((collection: CollectionDto) => {
collectionState.state[collection.id] = collection;
});
// Subscribe to real-time updates
collectionState.subscription = CollectionEndpoint.subscribeToCollectionEvents().onNext((collectionEvents: CollectionEvent[]) => {
collectionEvents.forEach((collectionEvent: CollectionEvent) => {
switch (collectionEvent.type) {
case "created":
case "updated":
//@ts-ignore
collectionState.state[collectionEvent.collection.id] = collectionEvent.collection;
break;
case "deleted":
//@ts-ignore
delete collectionState.state[collectionEvent.collectionId];
break;
}
})
});
}
+21 -14
View File
@@ -11,10 +11,10 @@ type GameState = {
state: Record<number, GameDto>;
games: GameDto[];
gamesByLibraryId: Record<number, GameDto[]>;
gamesByCollectionId: Record<number, GameDto[]>;
sortedAlphabetically: GameDto[];
recentlyAdded: GameDto[];
recentlyUpdated: GameDto[];
randomlyOrderedGamesByLibraryId: Record<number, GameDto[]>;
randomlyOrderedGamesByCollectionId: Record<number, GameDto[]>;
knownPublishers: Set<string>;
knownDevelopers: Set<string>;
knownGenres: Set<string>;
@@ -38,26 +38,33 @@ export const gameState = proxy<GameState>({
return acc;
}, {});
},
get gamesByCollectionId() {
return this.sortedAlphabetically.reduce((acc: Record<number, GameDto[]>, game: GameDto) => {
game.collectionIds?.forEach((collectionId: number) => {
(acc[collectionId] ||= []).push(game);
});
return acc;
}, {});
},
get sortedAlphabetically() {
return this.games
.sort((a: GameDto, b: GameDto) => a.title.localeCompare(b.title, undefined, {sensitivity: 'base'}));
},
get recentlyAdded() {
return this.games
.sort((a: GameDto, b: GameDto) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 25);
},
get recentlyUpdated() {
return this.games
.sort((a: GameDto, b: GameDto) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
.slice(0, 25);
},
get randomlyOrderedGamesByLibraryId() {
const result: Record<number, GameDto[]> = {};
for (const libraryId in this.gamesByLibraryId) {
const rand = new Rand(libraryId.toString());
const rand = new Rand(`library-${libraryId}`);
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;
},
get randomlyOrderedGamesByCollectionId() {
const result: Record<number, GameDto[]> = {};
for (const collectionId in this.gamesByCollectionId) {
const rand = new Rand(`collection-${collectionId}`);
result[collectionId] = this.gamesByCollectionId[collectionId]
.sort((a: GameDto, b: GameDto) => a.id - b.id)
.sort(() => rand.next() - 0.5);
}
+14 -2
View File
@@ -23,8 +23,20 @@ export const libraryState = proxy<LibraryState>({
},
get sorted() {
return Object.values<LibraryDto>(this.state).sort((a, b) => {
if (a.name === undefined || b.name === undefined) return 0;
return a.name.localeCompare(b.name);
const orderA = a.metadata!.displayOrder;
const orderB = b.metadata!.displayOrder;
// Handle -1 as "end of list"
const effectiveOrderA = orderA === -1 ? Number.MAX_SAFE_INTEGER : orderA;
const effectiveOrderB = orderB === -1 ? Number.MAX_SAFE_INTEGER : orderB;
const orderDiff = effectiveOrderA - effectiveOrderB;
if (orderDiff !== 0) {
return orderDiff;
}
// Fallback to creation date (newer first)
return new Date(a.createdAt!).getTime() - new Date(b.createdAt!).getTime();
});
}
});
+9 -4
View File
@@ -9,7 +9,7 @@ type PluginState = {
isLoaded: boolean;
state: Record<string, PluginDto>;
plugins: PluginDto[];
pluginsByType: Record<string, PluginDto[]>;
sortedByType: Record<string, PluginDto[]>;
};
export const pluginState = proxy<PluginState>({
@@ -20,8 +20,8 @@ export const pluginState = proxy<PluginState>({
get plugins() {
return Object.values<PluginDto>(this.state);
},
get pluginsByType() {
return groupPluginsByType(this.state);
get sortedByType() {
return sortPluginsByType(this.state);
}
});
@@ -52,7 +52,7 @@ export async function initializePluginState() {
/** Computed **/
function groupPluginsByType(pluginsMap: Record<string, PluginDto>): Record<string, PluginDto[]> {
function sortPluginsByType(pluginsMap: Record<string, PluginDto>): Record<string, PluginDto[]> {
const pluginsByType: Record<string, PluginDto[]> = {};
// Convert map to array of plugins
@@ -72,5 +72,10 @@ function groupPluginsByType(pluginsMap: Record<string, PluginDto>): Record<strin
}
}
// Sort plugins within each type by priority (descending order - higher priority first)
for (const type in pluginsByType) {
pluginsByType[type].sort((a, b) => b.priority - a.priority);
}
return pluginsByType;
}
+2 -3
View File
@@ -1,6 +1,5 @@
import {Middleware, MiddlewareContext, MiddlewareNext} from '@vaadin/hilla-frontend';
import {addToast} from "@heroui/react";
import {getReasonPhrase} from "http-status-codes";
export const ErrorHandlingMiddleware: Middleware = async function (
context: MiddlewareContext,
@@ -22,13 +21,13 @@ export const ErrorHandlingMiddleware: Middleware = async function (
if (json.type == "dev.hilla.exception.EndpointException" || json.type == "com.vaadin.hilla.exception.EndpointException") {
addToast({
title: getReasonPhrase(response.status),
title: "Error",
description: json.message,
color: "danger"
})
} else {
addToast({
title: getReasonPhrase(response.status),
title: "Error",
description: `${endpoint}.${method}`,
color: "danger"
})
@@ -13,8 +13,8 @@ import withSideMenu, {MenuItem} from "Frontend/components/general/withSideMenu";
const menuItems: MenuItem[] = [
{
title: "Libraries",
url: "libraries",
title: "Games",
url: "games",
icon: <GameControllerIcon/>
},
{
@@ -33,8 +33,8 @@ const menuItems: MenuItem[] = [
icon: <UsersIcon/>
},
{
title: "SSO",
url: "sso",
title: "Security",
url: "security",
icon: <LockKeyIcon/>
},
{
@@ -0,0 +1,127 @@
import {useNavigate, useParams} from "react-router";
import React, {useEffect} from "react";
import {addToast, Button} from "@heroui/react";
import {ArrowLeftIcon, CheckIcon} from "@phosphor-icons/react";
import {useSnapshot} from "valtio/react";
import CollectionAdminDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionAdminDto";
import {collectionState} from "Frontend/state/CollectionState";
import {Form, Formik} from "formik";
import * as Yup from "yup";
import Input from "Frontend/components/general/input/Input";
import Section from "Frontend/components/general/Section";
import {deepDiff} from "Frontend/util/utils";
import {CollectionEndpoint} from "Frontend/generated/endpoints";
import CollectionUpdateDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionUpdateDto";
import TextAreaInput from "Frontend/components/general/input/TextAreaInput";
import CollectionHeader from "Frontend/components/general/covers/CollectionHeader";
import CollectionGamesTable from "Frontend/components/general/modals/CollectionGamesTable";
import CheckboxInput from "Frontend/components/general/input/CheckboxInput";
export default function CollectionManagementView() {
const {collectionId} = useParams();
const navigate = useNavigate();
const [collectionSaved, setCollectionSaved] = React.useState(false);
const collections = useSnapshot(collectionState);
// Parse and validate collectionId early
const collectionIdNum = collectionId ? parseInt(collectionId) : null;
// Early return if invalid collection ID
useEffect(() => {
if (!collectionIdNum || (collections.isLoaded && !collections.state[collectionIdNum])) {
navigate("/administration/games");
}
}, [collections, collectionIdNum, navigate]);
// If collectionId is invalid, return null (will redirect via useEffect)
if (!collectionIdNum) {
return null;
}
// At this point, collectionIdNum is guaranteed to be a number
const collection = collections.state[collectionIdNum] as CollectionAdminDto;
async function handleSubmit(values: CollectionUpdateDto): Promise<void> {
const changed = deepDiff(collection, values) as CollectionUpdateDto;
if (Object.keys(changed).length === 0) return;
changed.id = collection.id;
await CollectionEndpoint.updateCollection(changed);
setCollectionSaved(true);
setTimeout(() => setCollectionSaved(false), 2000);
}
async function deleteCollection(): Promise<void> {
try {
await CollectionEndpoint.deleteCollection(collection.id);
addToast({
title: "Collection deleted",
description: `Collection ${collection.name} deleted!`,
color: "success"
});
navigate("/administration/games");
} catch (e) {
addToast({
title: "Error deleting collection",
description: `Collection ${collection.name} could not be deleted!`,
color: "warning"
});
}
}
return collection && (
<div className="flex flex-col gap-4">
<div className="flex flex-row gap-4 items-center">
<Button isIconOnly variant="light" onPress={() => history.back()}>
<ArrowLeftIcon/>
</Button>
<h1 className="text-2xl font-bold">Manage Collection</h1>
</div>
<CollectionHeader collection={collection} className="h-32"/>
<Formik
initialValues={collection}
onSubmit={handleSubmit}
enableReinitialize={true}
validationSchema={Yup.object({
name: Yup.string()
.required("Collection name is required")
.max(255, "Collection name must be 255 characters or less")
})}
>
{(formik) => (
<Form>
<div className="flex flex-row grow justify-between mb-4">
<h1 className="text-2xl font-bold">Edit collection details</h1>
<Button
color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting || collectionSaved || !formik.dirty}
type="submit"
>
{formik.isSubmitting ? "" : collectionSaved ? <CheckIcon/> : "Save"}
</Button>
</div>
<Input label="Collection name" name="name"/>
<TextAreaInput label="Collection description" name="description"/>
<CheckboxInput label="Display on homepage" name="metadata.displayOnHomepage" className="mb-4"/>
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-bold">Manage games in collection</h1>
<CollectionGamesTable collectionId={collectionIdNum}/>
</div>
<Section title="Danger zone"/>
<Button color="danger" onPress={deleteCollection}>
Delete collection
</Button>
</Form>
)}
</Formik>
</div>
);
}
@@ -0,0 +1,32 @@
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import React, {useEffect} from "react";
import {useNavigate, useParams} from "react-router";
import CoverGrid from "Frontend/components/general/covers/CoverGrid";
import {collectionState} from "Frontend/state/CollectionState";
export default function CollectionView() {
const {collectionId} = useParams();
const navigate = useNavigate();
const collections = useSnapshot(collectionState);
const games = collectionId ? useSnapshot(gameState).gamesByCollectionId[parseInt(collectionId!)] || [] : [];
useEffect(() => {
window.scrollTo(0, 0)
}, [])
useEffect(() => {
if (collections.isLoaded && (!collectionId || !collections.state[parseInt(collectionId)])) {
navigate("/", {replace: true});
}
document.title = collections.state[parseInt(collectionId!)]?.name || "Gameyfin";
}, [collectionId, collections]);
return (
<div className="flex flex-col gap-6">
<p className="text-4xl font-bold text-center">{collections.state[parseInt(collectionId!)]?.name}</p>
<CoverGrid games={games}/>
{games.length === 0 && <p className="text-center text-gray-500">This collection is empty.</p>}
</div>
);
}
+42 -19
View File
@@ -24,8 +24,9 @@ import EditGameMetadataModal from "Frontend/components/general/modals/EditGameMe
import GameUpdateDto from "Frontend/generated/org/gameyfin/app/games/dto/GameUpdateDto";
import Markdown from "react-markdown";
import remarkBreaks from "remark-breaks";
import {GameAdminDto} from "Frontend/dtos/GameDtos";
import ChipList from "Frontend/components/general/ChipList";
import {collectionState} from "Frontend/state/CollectionState";
import {GameMetadataAdminDto} from "Frontend/dtos/GameDtos";
export default function GameView() {
const {gameId} = useParams();
@@ -37,7 +38,8 @@ export default function GameView() {
const matchGameModal = useDisclosure();
const state = useSnapshot(gameState);
const game = gameId ? state.state[parseInt(gameId)] as GameAdminDto : undefined;
const game = gameId ? state.state[parseInt(gameId)] : undefined;
const collections = useSnapshot(collectionState).state;
const [downloadOptions, setDownloadOptions] = useState<Record<string, ComboButtonOption>>();
@@ -69,7 +71,7 @@ export default function GameView() {
await GameEndpoint.updateGame(
{
id: game.id,
metadata: {matchConfirmed: !game.metadata.matchConfirmed}
metadata: {matchConfirmed: !(game.metadata as GameMetadataAdminDto).matchConfirmed}
} as GameUpdateDto
)
}
@@ -87,17 +89,17 @@ export default function GameView() {
return game && (
<div className="flex flex-col gap-4">
<div className="overflow-hidden relative rounded-t-lg">
{game.headerId ? (
{game.header?.id ? (
<img
className="w-full h-96 object-cover brightness-50 blur-sm scale-110"
alt="Game header"
src={`/images/header/${game.headerId}`}
src={`/images/header/${game.header?.id}`}
/>
) : game.imageIds && game.imageIds.length > 0 ? (
) : game.images && game.images.length > 0 ? (
<img
className="w-full h-96 object-cover brightness-50 blur-sm scale-110"
alt="Game screenshot"
src={`/images/screenshot/${game.imageIds[0]}`}
src={`/images/screenshot/${game.images[0].id}`}
/>
) : (
<div className="w-full h-96 bg-secondary relative"/>
@@ -137,7 +139,7 @@ export default function GameView() {
<div className="flex flex-row items-center gap-8">
{isAdmin(auth) && <div className="flex flex-row gap-2">
<Button isIconOnly onPress={toggleMatchConfirmed}>
{game.metadata.matchConfirmed ?
{(game.metadata as GameMetadataAdminDto).matchConfirmed ?
<Tooltip content="Unconfirm match">
<CheckCircleIcon weight="fill" className="fill-success"/>
</Tooltip> :
@@ -220,7 +222,7 @@ export default function GameView() {
color="foreground" underline="hover">
{dev}
</Link>
{index !== game.developers!!.length - 1 && <p>/</p>}
{index !== game.developers!.length - 1 && <p>/</p>}
</>
)
: <Tooltip content="Missing data" color="foreground" placement="right">
@@ -295,6 +297,25 @@ export default function GameView() {
}
</td>
</tr>
{game.collectionIds.length > 0 &&
<tr>
<td className="text-default-500 w-0 min-w-32">Collections</td>
<td className="flex flex-row gap-1">
{[...game.collectionIds]
.map((collectionId) => collections[collectionId])
.sort((a, b) => a.id - b.id)
.map((collection, index) =>
<>
<Link key={collection.id} href={`/collection/${collection.id}`}
color="foreground" underline="hover">
{collection.name}
</Link>
{index !== game.collectionIds!.length - 1 && <p>/</p>}
</>
)}
</td>
</tr>
}
</tbody>
</table>
</div>
@@ -302,22 +323,24 @@ export default function GameView() {
<div className="flex flex-col gap-4">
<p className="text-default-500">Media</p>
<ImageCarousel
imageUrls={game.imageIds?.map(id => `/images/screenshot/${id}`)}
imageUrls={game.images?.map(image => `/images/screenshot/${image.id}`)}
videosUrls={game.videoUrls}
className="-mx-24"
/>
</div>
</div>
</div>
<EditGameMetadataModal game={game}
isOpen={editGameModal.isOpen}
onOpenChange={editGameModal.onOpenChange}/>
<MatchGameModal path={game.metadata.path!!}
libraryId={game.libraryId}
replaceGameId={game.id}
initialSearchTerm={game.title}
isOpen={matchGameModal.isOpen}
onOpenChange={matchGameModal.onOpenChange}/>
{isAdmin(auth) && <>
<EditGameMetadataModal game={game}
isOpen={editGameModal.isOpen}
onOpenChange={editGameModal.onOpenChange}/>
<MatchGameModal path={(game.metadata as GameMetadataAdminDto).path!}
libraryId={game.libraryId}
replaceGameId={game.id}
initialSearchTerm={game.title}
isOpen={matchGameModal.isOpen}
onOpenChange={matchGameModal.onOpenChange}/>
</>}
</div>
);
}
+62 -10
View File
@@ -1,26 +1,78 @@
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {CoverRow} from "Frontend/components/general/covers/CoverRow";
import {useSnapshot} from "valtio/react";
import {libraryState} from "Frontend/state/LibraryState";
import {gameState} from "Frontend/state/GameState";
import {useNavigate} from "react-router";
import React, {useEffect, useState} from "react";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import {collectionState} from "Frontend/state/CollectionState";
import CollectionDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionDto";
import {StartPageDisplayCard} from "Frontend/components/general/cards/StartPageDisplayCard";
import {Link} from "@heroui/react";
import {CaretRightIcon} from "@phosphor-icons/react";
export default function HomeView() {
const navigate = useNavigate();
const librariesState = useSnapshot(libraryState);
const collectionsState = useSnapshot(collectionState);
const gamesState = useSnapshot(gameState);
const recentlyAddedGames = gamesState.recentlyAdded as GameDto[];
const gamesByLibrary = gamesState.gamesByLibraryId as Record<number, GameDto[]>;
const gamesByLibrary = gamesState.gamesByLibraryId;
const gamesByCollection = gamesState.gamesByCollectionId;
const [filteredAndSortedLibraries, setFilteredAndSortedLibraries] = useState<LibraryDto[]>([]);
const [filteredAndSortedCollections, setFilteredAndSortedCollections] = useState<CollectionDto[]>([]);
useEffect(() => {
const libraries = librariesState.sorted
.filter(library => library.metadata!.displayOnHomepage)
.filter(library =>
gamesByLibrary[library.id] && gamesByLibrary[library.id].length > 0
);
setFilteredAndSortedLibraries(libraries);
const collections = collectionsState.sorted
.filter(collection => collection.metadata!.displayOnHomepage)
.filter(collection =>
gamesByCollection[collection.id] && gamesByCollection[collection.id].length > 0
);
setFilteredAndSortedCollections(collections);
}, [librariesState.sorted, collectionsState.sorted, gamesByLibrary, gamesByCollection]);
return (
<div className="w-full">
<div className="flex flex-col gap-2">
<CoverRow title="Recently added" games={recentlyAddedGames}
onPressShowMore={() => navigate("/recently-added")}/>
{librariesState.libraries.map((library) => (
<div className="flex flex-col gap-4">
{(filteredAndSortedLibraries.length + filteredAndSortedCollections.length > 0) &&
<div className="flex flex-col gap-2">
<Link href="/search" className="flex flex-row gap-1 w-fit items-baseline" color="foreground"
underline="hover">
<p className="text-2xl font-bold mb-4">Your games</p>
<CaretRightIcon weight="bold" size={16}/>
</Link>
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(353px,1fr))]">
{filteredAndSortedLibraries.length > 0 &&
filteredAndSortedLibraries.map((library: LibraryDto) => (
<StartPageDisplayCard key={library.id} item={library}/>
))
}
{filteredAndSortedCollections.length > 0 &&
filteredAndSortedCollections.map((collection: CollectionDto) => (
<StartPageDisplayCard key={collection.id} item={collection}/>
))
}
</div>
</div>
}
{filteredAndSortedLibraries.map((library) => (
<CoverRow key={library.id} title={library.name}
games={gamesByLibrary[library.id] || []}
onPressShowMore={() => navigate("/library/" + library.id)}
link={"/library/" + library.id}
/>
))}
{filteredAndSortedCollections.map((collection) => (
<CoverRow key={collection.id} title={collection.name}
games={gamesByCollection[collection.id] || []}
link={"/collection/" + collection.id}
/>
))}
</div>
@@ -19,18 +19,18 @@ export default function LibraryManagementView() {
useEffect(() => {
if (state.isLoaded && (!libraryId || !state.state[parseInt(libraryId)])) {
navigate("/administration/libraries");
navigate("/administration/games");
}
}, [state, libraryId]);
return libraryId && state.state[parseInt(libraryId)] && <div className="flex flex-col gap-4">
<div className="flex flex-row gap-4 items-center">
<Button isIconOnly variant="light" onPress={() => navigate("/administration/libraries")}>
<Button isIconOnly variant="light" onPress={() => history.back()}>
<ArrowLeftIcon/>
</Button>
<h1 className="text-2xl font-bold">Manage library</h1>
</div>
<LibraryHeader library={state.state[parseInt(libraryId)] as LibraryAdminDto} className="h-32"/>
<LibraryHeader library={state.state[parseInt(libraryId)]} className="h-32"/>
<Tabs color="primary" fullWidth
selectedKey={hash.length > 0 ? hash : "#details"}
onSelectionChange={(newKey) => navigate(newKey.toString(), {replace: true})}>
+8 -4
View File
@@ -4,25 +4,29 @@ import {gameState} from "Frontend/state/GameState";
import React, {useEffect} from "react";
import {useNavigate, useParams} from "react-router";
import CoverGrid from "Frontend/components/general/covers/CoverGrid";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
export default function LibraryView() {
const {libraryId} = useParams();
const navigate = useNavigate();
const libraries = useSnapshot(libraryState);
const games = (libraryId ? useSnapshot(gameState).gamesByLibraryId[parseInt(libraryId!!)] || [] : []) as GameDto[];
const games = useSnapshot(gameState).gamesByLibraryId[parseInt(libraryId!)] || [];
useEffect(() => {
window.scrollTo(0, 0)
}, [])
useEffect(() => {
if (libraries.isLoaded && (!libraryId || !libraries.state[parseInt(libraryId)])) {
navigate("/", {replace: true});
}
document.title = libraries.state[parseInt(libraryId!!)]?.name || "Gameyfin";
document.title = libraries.state[parseInt(libraryId!)]?.name || "Gameyfin";
}, [libraryId, libraries]);
return (
<div className="flex flex-col gap-6">
<p className="text-4xl font-bold text-center">{libraries.state[parseInt(libraryId!!)]?.name}</p>
<p className="text-4xl font-bold text-center">{libraries.state[parseInt(libraryId!)]?.name}</p>
<CoverGrid games={games}/>
{games.length === 0 && <p className="text-center text-gray-500">This library is empty.</p>}
</div>
);
}
@@ -1,16 +0,0 @@
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import React from "react";
import CoverGrid from "Frontend/components/general/covers/CoverGrid";
export default function RecentlyAddedView() {
const games = useSnapshot(gameState).recentlyAdded as GameDto[];
return (
<div className="flex flex-col gap-4">
<p className="text-4xl font-bold text-center">Recently added</p>
<CoverGrid games={games}/>
</div>
);
}
+6 -4
View File
@@ -13,19 +13,18 @@ import {useSearchParams} from "react-router";
import React, {useEffect, useMemo, useState} from "react";
import {Fzf} from "fzf";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import CoverGrid from "Frontend/components/general/covers/CoverGrid";
import {compoundRating} from "Frontend/util/utils";
export default function SearchView() {
const games = useSnapshot(gameState).sortedAlphabetically as GameDto[];
const knownDevelopers = useSnapshot(gameState).knownDevelopers as Set<string>;
const games = useSnapshot(gameState).sortedAlphabetically;
const knownDevelopers = useSnapshot(gameState).knownDevelopers;
const knownGenres = useSnapshot(gameState).knownGenres;
const knownThemes = useSnapshot(gameState).knownThemes;
const knownFeatures = useSnapshot(gameState).knownFeatures;
const knownPerspectives = useSnapshot(gameState).knownPerspectives;
const knownKeywords = useSnapshot(gameState).knownKeywords;
const libraries = useSnapshot(libraryState).libraries as LibraryDto[];
const libraries = useSnapshot(libraryState).libraries;
const [searchParams, setSearchParams] = useSearchParams();
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
@@ -46,6 +45,9 @@ export default function SearchView() {
// Load initial filter values from URL parameters on component mount
useEffect(() => {
// Scroll to top on load
window.scrollTo(0, 0)
// Get all parameters from the URL
const term = searchParams.get("term") || "";
const libs = searchParams.getAll("lib");