Add minimal UI for game requests

Fix some minor bugs
This commit is contained in:
grimsi
2025-09-02 18:49:51 +02:00
parent 6198c143db
commit ae7a65ccbc
25 changed files with 318 additions and 87 deletions
+2
View File
@@ -15,6 +15,7 @@ import {initializePluginState} from "Frontend/state/PluginState";
import {isAdmin} from "Frontend/util/utils";
import {useRouteMetadata} from "Frontend/util/routing";
import {useEffect} from "react";
import {initializeGameRequestState} from "Frontend/state/GameRequestState";
export default function App() {
client.middlewares = [ErrorHandlingMiddleware];
@@ -45,6 +46,7 @@ function ViewWithAuth() {
initializeLibraryState();
initializeGameState();
initializeGameRequestState();
if (isAdmin(auth)) {
initializeScanState();
@@ -21,7 +21,7 @@ import {useSnapshot} from "valtio/react";
import {pluginState} from "Frontend/state/PluginState";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
interface EditGameMetadataModalProps {
interface MatchGameModalProps {
path: string;
libraryId: number;
replaceGameId?: number;
@@ -37,7 +37,7 @@ export default function MatchGameModal({
initialSearchTerm,
isOpen,
onOpenChange
}: EditGameMetadataModalProps) {
}: MatchGameModalProps) {
const [searchTerm, setSearchTerm] = useState("");
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
const [isSearching, setIsSearching] = useState(false);
@@ -0,0 +1,162 @@
import {
addToast,
Button,
Input,
Modal,
ModalBody,
ModalContent,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
Tooltip
} from "@heroui/react";
import React, {useEffect, useState} from "react";
import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react";
import {GameEndpoint, GameRequestEndpoint} from "Frontend/generated/endpoints";
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
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 GameRequestCreationDto from "Frontend/generated/org/gameyfin/app/requests/dto/GameRequestCreationDto";
interface RequestGameModalProps {
isOpen: boolean;
onOpenChange: () => void;
}
export default function RequestGameModal({
isOpen,
onOpenChange
}: RequestGameModalProps) {
const [searchTerm, setSearchTerm] = useState("");
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [isRequesting, setIsRequesting] = useState<string | null>(null);
const plugins = useSnapshot(pluginState).state;
useEffect(() => {
setSearchTerm("");
setSearchResults([]);
}, [isOpen]);
async function requestGame(game: GameSearchResultDto) {
const request: GameRequestCreationDto = {
title: game.title,
release: game.release
}
await GameRequestEndpoint.create(request);
addToast({
title: "Request submitted",
description: `Your request for "${game.title}" has been submitted.`,
color: "success"
})
}
async function search() {
setIsSearching(true);
const results = await GameEndpoint.getPotentialMatches(searchTerm);
setSearchResults(results);
setIsSearching(false);
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}
hideCloseButton
isDismissable={!isSearching && !isRequesting}
isKeyboardDismissDisabled={!isSearching && !isRequesting}
backdrop="opaque" size="5xl">
<ModalContent>
{(onClose) => (
<ModalBody className="my-4">
<div className="flex flex-col items-center">
<h2 className="text-xl font-semibold">Request a game</h2>
</div>
<div className="flex flex-row gap-2 mb-4">
<Input value={searchTerm}
onValueChange={setSearchTerm}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await search();
}
}}
/>
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
<MagnifyingGlass/>
</Button>
</div>
<div>
<Table removeWrapper isStriped isHeaderSticky
classNames={{
base: "h-80 overflow-y-auto",
}}
>
<TableHeader>
<TableColumn>Title & Release</TableColumn>
<TableColumn>Developer(s)</TableColumn>
<TableColumn>Publisher(s)</TableColumn>
{/* width={1} keeps the column as far to the right as possible*/}
<TableColumn>Sources</TableColumn>
<TableColumn width={1}> </TableColumn>
</TableHeader>
<TableBody emptyContent="Your search did not match any games." items={searchResults}>
{(item) => (
<TableRow key={item.id}>
<TableCell>
{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
</TableCell>
<TableCell>
<div className="flex flex-col">
{item.developers ? item.developers.map(
developer => <p>{developer}</p>
) : "unknown"}
</div>
</TableCell>
<TableCell>
<div className="flex flex-col">
{item.publishers ? item.publishers.map(
publisher => <p>{publisher}</p>
) : "unknown"}
</div>
</TableCell>
<TableCell>
<div className="flex flex-row gap-2">
{Object.values(item.originalIds).map(
originalId => <PluginIcon
plugin={plugins[originalId.pluginId] as PluginDto}/>
)}
</div>
</TableCell>
<TableCell>
<Tooltip content="Pick this result">
<Button isIconOnly size="sm"
isDisabled={isRequesting !== null}
isLoading={isRequesting === item.id}
onPress={async () => {
setIsRequesting(item.id);
await requestGame(item);
setIsRequesting(null);
onClose();
}}>
<ArrowRight/>
</Button>
</Tooltip>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</ModalBody>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,50 @@
import {Subscription} from "@vaadin/hilla-frontend";
import {proxy} from "valtio/index";
import {GameRequestEndpoint} from "Frontend/generated/endpoints";
import GameRequestEvent from "Frontend/generated/org/gameyfin/app/requests/dto/GameRequestEvent";
import GameRequestDto from "Frontend/generated/org/gameyfin/app/requests/dto/GameRequestDto";
type GameRequestState = {
subscription?: Subscription<GameRequestEvent[]>;
isLoaded: boolean;
state: Record<number, GameRequestDto>;
gameRequests: GameRequestDto[];
};
export const gameRequestState = proxy<GameRequestState>({
get isLoaded() {
return this.subscription != null;
},
state: {},
get gameRequests() {
return Object.values<GameRequestDto>(this.state);
}
});
/** Subscribe to and process state updates from backend **/
export async function initializeGameRequestState() {
if (gameRequestState.isLoaded) return;
// Fetch initial game request list
const initialEntries = await GameRequestEndpoint.getAll();
initialEntries.forEach((gameRequest: GameRequestDto) => {
gameRequestState.state[gameRequest.id] = gameRequest;
});
// Subscribe to real-time updates
gameRequestState.subscription = GameRequestEndpoint.subscribe().onNext((gameRequestEvents: GameRequestEvent[]) => {
gameRequestEvents.forEach((gameRequestEvent: GameRequestEvent) => {
switch (gameRequestEvent.type) {
case "created":
case "updated":
//@ts-ignore
gameRequestState.state[gameRequestEvent.id] = gameRequestEvent;
break;
case "deleted":
//@ts-ignore
delete gameRequestState.state[gameRequestEvent.id];
break;
}
})
});
}
+30 -3
View File
@@ -1,11 +1,21 @@
import {useEffect, useState} from 'react';
import ProfileMenu from "Frontend/components/ProfileMenu";
import {Button, Divider, Link, Navbar, NavbarBrand, NavbarContent, NavbarItem, Tooltip} from "@heroui/react";
import {
Button,
Divider,
Link,
Navbar,
NavbarBrand,
NavbarContent,
NavbarItem,
Tooltip,
useDisclosure
} from "@heroui/react";
import GameyfinLogo from "Frontend/components/theming/GameyfinLogo";
import * as PackageJson from "../../../../package.json";
import {Outlet, useLocation, useNavigate} from "react-router";
import {useAuth} from "Frontend/util/auth";
import {ArrowLeft, DiceSix, Heart, House, ListMagnifyingGlass, SignIn} from "@phosphor-icons/react";
import {ArrowLeft, DiceSix, Heart, House, ListMagnifyingGlass, PlusCircle, SignIn} from "@phosphor-icons/react";
import Confetti, {ConfettiProps} from "react-confetti-boom";
import {useTheme} from "next-themes";
import {useUserPreferenceService} from "Frontend/util/user-preference-service";
@@ -14,6 +24,7 @@ import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import ScanProgressPopover from "Frontend/components/general/ScanProgressPopover";
import {isAdmin} from "Frontend/util/utils";
import RequestGameModal from "Frontend/components/general/modals/RequestGameModal";
export default function MainLayout() {
const navigate = useNavigate();
@@ -26,6 +37,8 @@ export default function MainLayout() {
const [isExploding, setIsExploding] = useState(false);
const games = useSnapshot(gameState).games;
const requestGameModal = useDisclosure();
useEffect(() => {
userPreferenceService.sync()
.then(() => loadUserTheme().catch(console.error))
@@ -93,7 +106,17 @@ export default function MainLayout() {
</Button>
</Tooltip>
</NavbarContent>}
<NavbarContent justify="end">
<NavbarContent justify="end" className="items-center">
{auth.state.user &&
<NavbarItem>
<Tooltip content="Request a game" placement="bottom">
<Button isIconOnly color="primary" variant="light"
onPress={requestGameModal.onOpen}>
<PlusCircle size={26} weight="fill"/>
</Button>
</Tooltip>
</NavbarItem>
}
{isAdmin(auth) &&
<NavbarItem>
<ScanProgressPopover/>
@@ -142,6 +165,10 @@ export default function MainLayout() {
</p>
</footer>
</div>
<RequestGameModal isOpen={requestGameModal.isOpen}
onOpenChange={requestGameModal.onOpenChange}/>
</div>
);
}