mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-16 08:15:48 +00:00
Finish game metadata editing
Add comment field to GameView
This commit is contained in:
@@ -27,7 +27,7 @@ const ArrayInput = ({label, ...props}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col flex-1 gap-2">
|
||||||
<div className="flex flex-row justify-between">
|
<div className="flex flex-row justify-between">
|
||||||
<p>{label}</p>
|
<p>{label}</p>
|
||||||
<small>{field.value.length} {field.value.length == 1 ? "element" : "elements"}</small>
|
<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 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 {Form, Formik} from "formik";
|
||||||
import Input from "Frontend/components/general/input/Input";
|
import Input from "Frontend/components/general/input/Input";
|
||||||
import React from "react";
|
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 {deepDiff} from "Frontend/util/utils";
|
||||||
import {GameEndpoint} from "Frontend/generated/endpoints";
|
import {GameEndpoint} from "Frontend/generated/endpoints";
|
||||||
import TextAreaInput from "Frontend/components/general/input/TextAreaInput";
|
import TextAreaInput from "Frontend/components/general/input/TextAreaInput";
|
||||||
import DatePickerInput from "Frontend/components/general/input/DatePickerInput";
|
|
||||||
import * as Yup from "yup";
|
import * as Yup from "yup";
|
||||||
import GameCoverPicker from "Frontend/components/general/input/GameCoverPicker";
|
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 {
|
interface EditGameMetadataModalProps {
|
||||||
game: GameDto;
|
game: GameDto;
|
||||||
@@ -53,12 +63,40 @@ export default function EditGameMetadataModal({game, isOpen, onOpenChange}: Edit
|
|||||||
<div className="flex flex-col flex-1">
|
<div className="flex flex-col flex-1">
|
||||||
<Input key="metadata.path" name="metadata.path" label="Path"
|
<Input key="metadata.path" name="metadata.path" label="Path"
|
||||||
isDisabled/>
|
isDisabled/>
|
||||||
<Input key="title" name="title" label="Title"/>
|
<Input key="title" name="title" label="Title" isRequired/>
|
||||||
<DatePickerInput key="release" name="release" label="Release"/>
|
<DatePickerInput key="release" name="release" label="Release"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<TextAreaInput key="summary" name="summary" label="Summary (HTML)"/>
|
<TextAreaInput key="summary" name="summary" label="Summary (HTML)"/>
|
||||||
<TextAreaInput key="comment" name="comment" label="Comment (Markdown)"/>
|
<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>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button variant="light" onPress={onClose}>
|
<Button variant="light" onPress={onClose}>
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
import {useEffect, useState} from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
import {DownloadProviderEndpoint} from "Frontend/generated/endpoints";
|
import {DownloadProviderEndpoint, GameEndpoint} from "Frontend/generated/endpoints";
|
||||||
import {useNavigate, useParams} from "react-router";
|
import {useNavigate, useParams} from "react-router";
|
||||||
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
||||||
import ComboButton, {ComboButtonOption} from "Frontend/components/general/input/ComboButton";
|
import ComboButton, {ComboButtonOption} from "Frontend/components/general/input/ComboButton";
|
||||||
import ImageCarousel from "Frontend/components/general/covers/ImageCarousel";
|
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 {humanFileSize, isAdmin, toTitleCase} from "Frontend/util/utils";
|
||||||
import {DownloadEndpoint} from "Frontend/endpoints/endpoints";
|
import {DownloadEndpoint} from "Frontend/endpoints/endpoints";
|
||||||
import {gameState, initializeGameState} from "Frontend/state/GameState";
|
import {gameState, initializeGameState} from "Frontend/state/GameState";
|
||||||
import {useSnapshot} from "valtio/react";
|
import {useSnapshot} from "valtio/react";
|
||||||
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
|
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 {useAuth} from "Frontend/util/auth";
|
||||||
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
|
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() {
|
export default function GameView() {
|
||||||
const {gameId} = useParams();
|
const {gameId} = useParams();
|
||||||
@@ -20,6 +24,7 @@ export default function GameView() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
|
||||||
|
const editGameModal = useDisclosure();
|
||||||
const matchGameModal = useDisclosure();
|
const matchGameModal = useDisclosure();
|
||||||
|
|
||||||
const state = useSnapshot(gameState);
|
const state = useSnapshot(gameState);
|
||||||
@@ -51,6 +56,26 @@ export default function GameView() {
|
|||||||
});
|
});
|
||||||
}, [gameId]);
|
}, [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 && (
|
return game && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="overflow-hidden relative rounded-t-lg">
|
<div className="overflow-hidden relative rounded-t-lg">
|
||||||
@@ -84,11 +109,36 @@ export default function GameView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center gap-8">
|
<div className="flex flex-row items-center gap-8">
|
||||||
{isAdmin(auth) && <Tooltip content="Edit game">
|
{isAdmin(auth) && <div className="flex flex-row gap-2">
|
||||||
<Button isIconOnly onPress={matchGameModal.onOpenChange}>
|
<Button isIconOnly onPress={toggleMatchConfirmed}>
|
||||||
<MagnifyingGlass/>
|
{game.metadata.matchConfirmed ?
|
||||||
|
<Tooltip content="Unconfirm match">
|
||||||
|
<CheckCircle weight="fill" className="fill-success"/>
|
||||||
|
</Tooltip> :
|
||||||
|
<Tooltip content="Confirm match">
|
||||||
|
<CheckCircle/>
|
||||||
|
</Tooltip>}
|
||||||
</Button>
|
</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)}
|
{downloadOptions && <ComboButton description={humanFileSize(game.metadata.fileSize)}
|
||||||
options={downloadOptions}
|
options={downloadOptions}
|
||||||
preferredOptionKey="preferred-download-method"
|
preferredOptionKey="preferred-download-method"
|
||||||
@@ -96,6 +146,31 @@ export default function GameView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-8">
|
<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-row gap-12">
|
||||||
<div className="flex flex-col flex-1 gap-2">
|
<div className="flex flex-col flex-1 gap-2">
|
||||||
<p className="text-default-500">Summary</p>
|
<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="text-default-500 w-0 min-w-32">Developed by</td>
|
||||||
<td className="flex flex-row gap-1">
|
<td className="flex flex-row gap-1">
|
||||||
{game.developers && game.developers.length > 0
|
{game.developers && game.developers.length > 0
|
||||||
? [...game.developers].sort().map(dev =>
|
? [...game.developers].sort().map((dev, index) =>
|
||||||
<Link key={dev} href={`/search?dev=${encodeURIComponent(dev)}`}
|
<>
|
||||||
color="foreground" underline="hover">
|
<Link key={dev} href={`/search?dev=${encodeURIComponent(dev)}`}
|
||||||
{dev}
|
color="foreground" underline="hover">
|
||||||
</Link>
|
{dev}
|
||||||
|
</Link>
|
||||||
|
{index !== game.developers!!.length - 1 && <p>/</p>}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
: <Tooltip content="Missing data" color="foreground" placement="right">
|
: <Tooltip content="Missing data" color="foreground" placement="right">
|
||||||
<TriangleDashed className="fill-default-500 h-6 bottom-0"/>
|
<TriangleDashed className="fill-default-500 h-6 bottom-0"/>
|
||||||
@@ -195,6 +273,9 @@ export default function GameView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<EditGameMetadataModal game={game}
|
||||||
|
isOpen={editGameModal.isOpen}
|
||||||
|
onOpenChange={editGameModal.onOpenChange}/>
|
||||||
<MatchGameModal path={game.metadata.path!!}
|
<MatchGameModal path={game.metadata.path!!}
|
||||||
libraryId={game.libraryId}
|
libraryId={game.libraryId}
|
||||||
replaceGameId={game.id}
|
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.core.replaceRomanNumerals
|
||||||
import de.grimsi.gameyfin.games.dto.*
|
import de.grimsi.gameyfin.games.dto.*
|
||||||
import de.grimsi.gameyfin.games.entities.*
|
import de.grimsi.gameyfin.games.entities.*
|
||||||
|
import de.grimsi.gameyfin.games.entities.GameMetadata
|
||||||
import de.grimsi.gameyfin.games.repositories.GameRepository
|
import de.grimsi.gameyfin.games.repositories.GameRepository
|
||||||
import de.grimsi.gameyfin.libraries.Library
|
import de.grimsi.gameyfin.libraries.Library
|
||||||
import de.grimsi.gameyfin.media.ImageService
|
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 de.grimsi.gameyfin.users.UserService
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -147,6 +148,64 @@ class GameService(
|
|||||||
existingGame.summary = it
|
existingGame.summary = it
|
||||||
existingGame.metadata.fields["summary"]?.source = GameFieldUserSource(user = user)
|
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 ->
|
gameUpdateDto.metadata?.let { metadata ->
|
||||||
|
|||||||
@@ -9,5 +9,12 @@ data class GameUpdateDto(
|
|||||||
val coverUrl: String?,
|
val coverUrl: String?,
|
||||||
val comment: String?,
|
val comment: String?,
|
||||||
val summary: 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?
|
val metadata: GameUpdateMetadataDto?
|
||||||
)
|
)
|
||||||
Reference in New Issue
Block a user