Finish game metadata editing

Add comment field to GameView
This commit is contained in:
grimsi
2025-06-14 15:20:49 +02:00
parent c969bdeda3
commit b1dd4cb8c1
5 changed files with 203 additions and 18 deletions
@@ -27,7 +27,7 @@ const ArrayInput = ({label, ...props}) => {
}
return (
<div className="flex flex-col gap-2">
<div className="flex flex-col flex-1 gap-2">
<div className="flex flex-row justify-between">
<p>{label}</p>
<small>{field.value.length} {field.value.length == 1 ? "element" : "elements"}</small>
@@ -1,5 +1,14 @@
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {
Accordion,
AccordionItem,
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader
} from "@heroui/react";
import {Form, Formik} from "formik";
import Input from "Frontend/components/general/input/Input";
import React from "react";
@@ -7,9 +16,10 @@ import GameUpdateDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameU
import {deepDiff} from "Frontend/util/utils";
import {GameEndpoint} from "Frontend/generated/endpoints";
import TextAreaInput from "Frontend/components/general/input/TextAreaInput";
import DatePickerInput from "Frontend/components/general/input/DatePickerInput";
import * as Yup from "yup";
import GameCoverPicker from "Frontend/components/general/input/GameCoverPicker";
import DatePickerInput from "Frontend/components/general/input/DatePickerInput";
import ArrayInput from "Frontend/components/general/input/ArrayInput";
interface EditGameMetadataModalProps {
game: GameDto;
@@ -53,12 +63,40 @@ export default function EditGameMetadataModal({game, isOpen, onOpenChange}: Edit
<div className="flex flex-col flex-1">
<Input key="metadata.path" name="metadata.path" label="Path"
isDisabled/>
<Input key="title" name="title" label="Title"/>
<Input key="title" name="title" label="Title" isRequired/>
<DatePickerInput key="release" name="release" label="Release"/>
</div>
</div>
<TextAreaInput key="summary" name="summary" label="Summary (HTML)"/>
<TextAreaInput key="comment" name="comment" label="Comment (Markdown)"/>
<Accordion>
<AccordionItem key="additional-metadata"
aria-label="Additional Metadata"
title="Additional Metadata"
className="flex flex-col">
<div className="flex flex-row gap-4">
<ArrayInput key="developers" name="developers" label="Developers"/>
<div className="w-0 border-s border-foreground/70"/>
<ArrayInput key="publishers" name="publishers" label="Publishers"/>
</div>
<div className="flex flex-row gap-4">
<ArrayInput key="genres" name="genres" label="Genres"/>
<div className="w-0 border-s border-foreground/70"/>
<ArrayInput key="themes" name="themes" label="Themes"/>
</div>
<div className="flex flex-row gap-4">
<ArrayInput key="keywords" name="keywords" label="Keywords"/>
<div className="w-0 border-s border-foreground/70"/>
<ArrayInput key="features" name="features" label="Features"/>
</div>
<div className="flex flex-row gap-4">
<ArrayInput key="perspectives" name="perspectives"
label="Perspectives"/>
<div className="w-0 border-s border-foreground/70"/>
<div className="flex-1"/>
</div>
</AccordionItem>
</Accordion>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
+94 -13
View File
@@ -1,18 +1,22 @@
import {useEffect, useState} from "react";
import {DownloadProviderEndpoint} from "Frontend/generated/endpoints";
import React, {useEffect, useState} from "react";
import {DownloadProviderEndpoint, GameEndpoint} from "Frontend/generated/endpoints";
import {useNavigate, useParams} from "react-router";
import {GameCover} from "Frontend/components/general/covers/GameCover";
import ComboButton, {ComboButtonOption} from "Frontend/components/general/input/ComboButton";
import ImageCarousel from "Frontend/components/general/covers/ImageCarousel";
import {Button, Chip, Link, Tooltip, useDisclosure} from "@heroui/react";
import {Accordion, AccordionItem, addToast, Button, Chip, Link, Tooltip, useDisclosure} from "@heroui/react";
import {humanFileSize, isAdmin, toTitleCase} from "Frontend/util/utils";
import {DownloadEndpoint} from "Frontend/endpoints/endpoints";
import {gameState, initializeGameState} from "Frontend/state/GameState";
import {useSnapshot} from "valtio/react";
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
import {Info, MagnifyingGlass, TriangleDashed} from "@phosphor-icons/react";
import {CheckCircle, Info, MagnifyingGlass, Pencil, Trash, TriangleDashed} from "@phosphor-icons/react";
import {useAuth} from "Frontend/util/auth";
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
import EditGameMetadataModal from "Frontend/components/general/modals/EditGameMetadataModal";
import GameUpdateDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameUpdateDto";
import Markdown from "react-markdown";
import remarkBreaks from "remark-breaks";
export default function GameView() {
const {gameId} = useParams();
@@ -20,6 +24,7 @@ export default function GameView() {
const navigate = useNavigate();
const auth = useAuth();
const editGameModal = useDisclosure();
const matchGameModal = useDisclosure();
const state = useSnapshot(gameState);
@@ -51,6 +56,26 @@ export default function GameView() {
});
}, [gameId]);
async function toggleMatchConfirmed() {
if (!game) return;
await GameEndpoint.updateGame(
{
id: game.id,
metadata: {matchConfirmed: !game.metadata.matchConfirmed}
} as GameUpdateDto
)
}
async function deleteGame() {
if (!game) return;
await GameEndpoint.deleteGame(game.id);
addToast({
title: "Game deleted",
description: `${game.title} removed from Gameyfin!`,
color: "success"
});
}
return game && (
<div className="flex flex-col gap-4">
<div className="overflow-hidden relative rounded-t-lg">
@@ -84,11 +109,36 @@ export default function GameView() {
</div>
</div>
<div className="flex flex-row items-center gap-8">
{isAdmin(auth) && <Tooltip content="Edit game">
<Button isIconOnly onPress={matchGameModal.onOpenChange}>
<MagnifyingGlass/>
{isAdmin(auth) && <div className="flex flex-row gap-2">
<Button isIconOnly onPress={toggleMatchConfirmed}>
{game.metadata.matchConfirmed ?
<Tooltip content="Unconfirm match">
<CheckCircle weight="fill" className="fill-success"/>
</Tooltip> :
<Tooltip content="Confirm match">
<CheckCircle/>
</Tooltip>}
</Button>
</Tooltip>}
<Tooltip content="Edit metadata">
<Button isIconOnly onPress={editGameModal.onOpenChange}>
<Pencil/>
</Button>
</Tooltip>
<Tooltip content="Search for metadata">
<Button isIconOnly onPress={matchGameModal.onOpenChange}>
<MagnifyingGlass/>
</Button>
</Tooltip>
<Tooltip content="Remove from library">
<Button isIconOnly color="danger"
onPress={async () => {
await deleteGame();
navigate("/");
}}>
<Trash/>
</Button>
</Tooltip>
</div>}
{downloadOptions && <ComboButton description={humanFileSize(game.metadata.fileSize)}
options={downloadOptions}
preferredOptionKey="preferred-download-method"
@@ -96,6 +146,31 @@ export default function GameView() {
</div>
</div>
<div className="flex flex-col gap-8">
{game.comment &&
<Accordion variant="splitted"
itemClasses={{base: "-mx-2", content: "mx-8 mb-4", heading: "font-bold"}}>
<AccordionItem key="information"
aria-label="Information"
title="Information"
startContent={<Info weight="fill"/>}>
<Markdown
remarkPlugins={[remarkBreaks]}
components={{
a(props) {
return <Link isExternal
showAnchorIcon
color="foreground"
underline="always"
href={props.href}
size="sm">
{props.children}
</Link>
}
}}
>{game.comment}</Markdown>
</AccordionItem>
</Accordion>
}
<div className="flex flex-row gap-12">
<div className="flex flex-col flex-1 gap-2">
<p className="text-default-500">Summary</p>
@@ -112,11 +187,14 @@ export default function GameView() {
<td className="text-default-500 w-0 min-w-32">Developed by</td>
<td className="flex flex-row gap-1">
{game.developers && game.developers.length > 0
? [...game.developers].sort().map(dev =>
<Link key={dev} href={`/search?dev=${encodeURIComponent(dev)}`}
color="foreground" underline="hover">
{dev}
</Link>
? [...game.developers].sort().map((dev, index) =>
<>
<Link key={dev} href={`/search?dev=${encodeURIComponent(dev)}`}
color="foreground" underline="hover">
{dev}
</Link>
{index !== game.developers!!.length - 1 && <p>/</p>}
</>
)
: <Tooltip content="Missing data" color="foreground" placement="right">
<TriangleDashed className="fill-default-500 h-6 bottom-0"/>
@@ -195,6 +273,9 @@ export default function GameView() {
</div>
</div>
</div>
<EditGameMetadataModal game={game}
isOpen={editGameModal.isOpen}
onOpenChange={editGameModal.onOpenChange}/>
<MatchGameModal path={game.metadata.path!!}
libraryId={game.libraryId}
replaceGameId={game.id}
@@ -11,10 +11,11 @@ import de.grimsi.gameyfin.core.plugins.management.PluginManagementEntry
import de.grimsi.gameyfin.core.replaceRomanNumerals
import de.grimsi.gameyfin.games.dto.*
import de.grimsi.gameyfin.games.entities.*
import de.grimsi.gameyfin.games.entities.GameMetadata
import de.grimsi.gameyfin.games.repositories.GameRepository
import de.grimsi.gameyfin.libraries.Library
import de.grimsi.gameyfin.media.ImageService
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import de.grimsi.gameyfin.pluginapi.gamemetadata.*
import de.grimsi.gameyfin.users.UserService
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.async
@@ -147,6 +148,64 @@ class GameService(
existingGame.summary = it
existingGame.metadata.fields["summary"]?.source = GameFieldUserSource(user = user)
}
gameUpdateDto.developers?.let {
existingGame.developers =
it.map { name -> companyService.createOrGet(Company(name = name, type = CompanyType.DEVELOPER)) }
existingGame.metadata.fields["developers"]?.source = GameFieldUserSource(user = user)
}
gameUpdateDto.publishers?.let {
existingGame.publishers =
it.map { name -> companyService.createOrGet(Company(name = name, type = CompanyType.PUBLISHER)) }
existingGame.metadata.fields["publishers"]?.source = GameFieldUserSource(user = user)
}
gameUpdateDto.genres?.let {
existingGame.genres = it.mapNotNull { name ->
try {
Genre.valueOf(name)
} catch (_: IllegalArgumentException) {
log.error { "Invalid value for genre '$name', must be one of ${Genre.entries}" }
null
}
}
existingGame.metadata.fields["genres"]?.source = GameFieldUserSource(user = user)
}
gameUpdateDto.themes?.let {
existingGame.themes = it.mapNotNull { name ->
try {
Theme.valueOf(name)
} catch (_: IllegalArgumentException) {
log.error { "Invalid value for theme '$name', must be one of ${Theme.entries}" }
null
}
}
existingGame.metadata.fields["themes"]?.source = GameFieldUserSource(user = user)
}
gameUpdateDto.keywords?.let {
existingGame.keywords = it
existingGame.metadata.fields["keywords"]?.source = GameFieldUserSource(user = user)
}
gameUpdateDto.features?.let {
existingGame.features = it.mapNotNull { name ->
try {
GameFeature.valueOf(name)
} catch (_: IllegalArgumentException) {
log.error { "Invalid value for feature '$name', must be one of ${GameFeature.entries}" }
null
}
}
existingGame.metadata.fields["features"]?.source = GameFieldUserSource(user = user)
}
gameUpdateDto.perspectives?.let {
existingGame.perspectives = it.mapNotNull { name ->
try {
PlayerPerspective.valueOf(name)
} catch (_: IllegalArgumentException) {
log.error { "Invalid value for perspective '$name', must be one of ${PlayerPerspective.entries}" }
null
}
}
existingGame.metadata.fields["perspectives"]?.source = GameFieldUserSource(user = user)
}
gameUpdateDto.metadata?.let { metadata ->
@@ -9,5 +9,12 @@ data class GameUpdateDto(
val coverUrl: String?,
val comment: String?,
val summary: String?,
val developers: List<String>?,
val publishers: List<String>?,
val genres: List<String>?,
val themes: List<String>?,
val keywords: List<String>?,
val features: List<String>?,
val perspectives: List<String>?,
val metadata: GameUpdateMetadataDto?
)