mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +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 (
|
||||
<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}>
|
||||
|
||||
@@ -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?
|
||||
)
|
||||
Reference in New Issue
Block a user