mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-16 00:30:02 +00:00
Implement GameCoverPicker
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
import {useField} from "formik";
|
||||||
|
import {DatePicker, DateValue} from "@heroui/react";
|
||||||
|
import {parseDate} from "@internationalized/date";
|
||||||
|
import {useState} from "react";
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
export default function DatePickerInput({label, showErrorUntouched = false, ...props}) {
|
||||||
|
// @ts-ignore
|
||||||
|
const [field, meta] = useField(props);
|
||||||
|
const [value, setValue] = useState<DateValue | null>(parseDate(field.value));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DatePicker
|
||||||
|
className="min-h-20 flex-grow"
|
||||||
|
showMonthAndYearPickers
|
||||||
|
fullWidth={false}
|
||||||
|
{...props}
|
||||||
|
{...field}
|
||||||
|
value={value}
|
||||||
|
onChange={(date) => {
|
||||||
|
setValue(date);
|
||||||
|
field.onChange({
|
||||||
|
target: {
|
||||||
|
name: field.name,
|
||||||
|
value: date ? date.toString() : ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
id={label}
|
||||||
|
label={label}
|
||||||
|
isInvalid={(meta.touched || showErrorUntouched) && !!meta.error}
|
||||||
|
errorMessage={meta.initialError || meta.error}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {Image, useDisclosure} from "@heroui/react";
|
||||||
|
import {GameCoverFallback} from "Frontend/components/general/covers/GameCoverFallback";
|
||||||
|
import React from "react";
|
||||||
|
import {useField} from "formik";
|
||||||
|
import {GameCoverPickerModal} from "Frontend/components/general/modals/GameCoverPickerModal";
|
||||||
|
import {Pencil} from "@phosphor-icons/react";
|
||||||
|
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
export default function GameCoverPicker({game, label, showErrorUntouched = false, ...props}) {
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const [field] = useField(props);
|
||||||
|
|
||||||
|
const gameCoverPickerModal = useDisclosure();
|
||||||
|
|
||||||
|
return (<>
|
||||||
|
<div className="relative group w-fit h-fit cursor-pointer"
|
||||||
|
onClick={gameCoverPickerModal.onOpenChange}>
|
||||||
|
<Image
|
||||||
|
alt={game.title}
|
||||||
|
className="z-0 object-cover aspect-[12/17] group-hover:brightness-50"
|
||||||
|
src={field.value ? field.value : `images/cover/${game.coverId}`}
|
||||||
|
{...props}
|
||||||
|
{...field}
|
||||||
|
radius="none"
|
||||||
|
height={216}
|
||||||
|
fallbackSrc={<GameCoverFallback title={game.title}
|
||||||
|
size={216}
|
||||||
|
radius="none"/>}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Pencil size={46}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<GameCoverPickerModal
|
||||||
|
game={game}
|
||||||
|
isOpen={gameCoverPickerModal.isOpen}
|
||||||
|
onOpenChange={gameCoverPickerModal.onOpenChange}
|
||||||
|
setCoverUrl={(coverUrl) => field.onChange({target: {name: field.name, value: coverUrl}})}
|
||||||
|
/>
|
||||||
|
</>);
|
||||||
|
}
|
||||||
@@ -7,6 +7,9 @@ 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 GameCoverPicker from "Frontend/components/general/input/GameCoverPicker";
|
||||||
|
|
||||||
interface EditGameMetadataModalProps {
|
interface EditGameMetadataModalProps {
|
||||||
game: GameDto;
|
game: GameDto;
|
||||||
@@ -21,6 +24,7 @@ export default function EditGameMetadataModal({game, isOpen, onOpenChange}: Edit
|
|||||||
{(onClose) => {
|
{(onClose) => {
|
||||||
|
|
||||||
async function updateGame(values: GameUpdateDto) {
|
async function updateGame(values: GameUpdateDto) {
|
||||||
|
//@ts-ignore
|
||||||
const changed = deepDiff(game, values) as GameUpdateDto;
|
const changed = deepDiff(game, values) as GameUpdateDto;
|
||||||
if (Object.keys(changed).length === 0) return;
|
if (Object.keys(changed).length === 0) return;
|
||||||
|
|
||||||
@@ -33,6 +37,9 @@ export default function EditGameMetadataModal({game, isOpen, onOpenChange}: Edit
|
|||||||
<Formik initialValues={game}
|
<Formik initialValues={game}
|
||||||
enableReinitialize={true}
|
enableReinitialize={true}
|
||||||
onSubmit={updateGame}
|
onSubmit={updateGame}
|
||||||
|
validationSchema={Yup.object({
|
||||||
|
title: Yup.string().required("Title is required")
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
{(formik: any) => (
|
{(formik: any) => (
|
||||||
<Form>
|
<Form>
|
||||||
@@ -40,8 +47,17 @@ export default function EditGameMetadataModal({game, isOpen, onOpenChange}: Edit
|
|||||||
Update game metadata
|
Update game metadata
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<Input key="title" name="title" label="Title"/>
|
<div className="flex flex-row gap-8">
|
||||||
<TextAreaInput key="summary" name="summary" label="Summary (Markdown)"/>
|
{/*@ts-ignore*/}
|
||||||
|
<GameCoverPicker key="coverUrl" name="coverUrl" game={game}/>
|
||||||
|
<div className="flex flex-col flex-1">
|
||||||
|
<Input key="metadata.path" name="metadata.path" label="Path"
|
||||||
|
isDisabled/>
|
||||||
|
<Input key="title" name="title" label="Title"/>
|
||||||
|
<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)"/>
|
<TextAreaInput key="comment" name="comment" label="Comment (Markdown)"/>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
|
||||||
|
import {Button, Image, Input, Modal, ModalBody, ModalContent, ModalHeader, ScrollShadow} from "@heroui/react";
|
||||||
|
import React, {useEffect, useState} from "react";
|
||||||
|
import GameSearchResultDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameSearchResultDto";
|
||||||
|
import {GameEndpoint} from "Frontend/generated/endpoints";
|
||||||
|
import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react";
|
||||||
|
|
||||||
|
interface GameCoverPickerModalProps {
|
||||||
|
game: GameDto;
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: () => void;
|
||||||
|
setCoverUrl: (url: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}: GameCoverPickerModalProps) {
|
||||||
|
const [coverUrl, setCoverUrlState] = useState("");
|
||||||
|
|
||||||
|
const [searchTerm, setSearchTerm] = useState(game.title);
|
||||||
|
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && searchTerm.length > 0 && searchResults.length === 0) {
|
||||||
|
search();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
setIsSearching(true);
|
||||||
|
const results = await GameEndpoint.getPotentialMatches(searchTerm, false);
|
||||||
|
let validResults = results.filter(result => result.coverUrl && result.coverUrl.length > 0 && result.coverUrl !== "null");
|
||||||
|
setSearchResults(validResults);
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="2xl">
|
||||||
|
<ModalContent>
|
||||||
|
{(onClose) => {
|
||||||
|
return (<>
|
||||||
|
<ModalHeader>
|
||||||
|
Enter a URL or search for a cover
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-row gap-2 mb-4">
|
||||||
|
<Input isClearable
|
||||||
|
placeholder="Enter a URL"
|
||||||
|
value={coverUrl}
|
||||||
|
onValueChange={setCoverUrlState}
|
||||||
|
onClear={() => setCoverUrlState("")}
|
||||||
|
/>
|
||||||
|
<Button isIconOnly onPress={() => {
|
||||||
|
setCoverUrl(coverUrl);
|
||||||
|
onClose();
|
||||||
|
}}>
|
||||||
|
<ArrowRight/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row gap-2 mb-4">
|
||||||
|
<Input placeholder="Search"
|
||||||
|
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>
|
||||||
|
<ScrollShadow
|
||||||
|
className="flex flex-row flex-wrap gap-4 h-96 overflow-scroll justify-evenly">
|
||||||
|
{searchResults.length === 0 && "No results found."}
|
||||||
|
{searchResults.map((result) => (
|
||||||
|
<Image
|
||||||
|
key={result.id}
|
||||||
|
alt={result.title}
|
||||||
|
className="z-0 object-cover aspect-[12/17] cursor-pointer"
|
||||||
|
src={result.coverUrl!}
|
||||||
|
radius="none"
|
||||||
|
height={216}
|
||||||
|
onClick={() => {
|
||||||
|
setCoverUrl(result.coverUrl!);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollShadow>
|
||||||
|
</ModalBody>
|
||||||
|
</>)
|
||||||
|
}}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ export default function MatchGameModal({
|
|||||||
|
|
||||||
async function search() {
|
async function search() {
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
const results = await GameEndpoint.getPotentialMatches(searchTerm);
|
const results = await GameEndpoint.getPotentialMatches(searchTerm, true);
|
||||||
setSearchResults(results);
|
setSearchResults(results);
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ class GameEndpoint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@RolesAllowed(Role.Names.ADMIN)
|
@RolesAllowed(Role.Names.ADMIN)
|
||||||
fun getPotentialMatches(searchTerm: String): List<GameSearchResultDto> {
|
fun getPotentialMatches(searchTerm: String, groupResults: Boolean): List<GameSearchResultDto> {
|
||||||
return gameService.getPotentialMatches(searchTerm)
|
return gameService.getPotentialMatches(searchTerm, groupResults)
|
||||||
}
|
}
|
||||||
|
|
||||||
@RolesAllowed(Role.Names.ADMIN)
|
@RolesAllowed(Role.Names.ADMIN)
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ import org.springframework.stereotype.Service
|
|||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import reactor.core.publisher.Flux
|
import reactor.core.publisher.Flux
|
||||||
import reactor.core.publisher.Sinks
|
import reactor.core.publisher.Sinks
|
||||||
|
import java.net.URI
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
import java.time.ZoneOffset
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.toJavaDuration
|
import kotlin.time.toJavaDuration
|
||||||
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata as PluginApiMetadata
|
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata as PluginApiMetadata
|
||||||
@@ -125,6 +127,17 @@ class GameService(
|
|||||||
existingGame.title = it
|
existingGame.title = it
|
||||||
existingGame.metadata.fields["title"]?.source = GameFieldUserSource(user = user)
|
existingGame.metadata.fields["title"]?.source = GameFieldUserSource(user = user)
|
||||||
}
|
}
|
||||||
|
gameUpdateDto.release?.let {
|
||||||
|
existingGame.release = it.atStartOfDay(ZoneOffset.UTC).toInstant()
|
||||||
|
existingGame.metadata.fields["release"]?.source = GameFieldUserSource(user = user)
|
||||||
|
}
|
||||||
|
gameUpdateDto.coverUrl?.let {
|
||||||
|
val newCoverImage = Image(originalUrl = URI.create(it).toURL(), type = ImageType.COVER)
|
||||||
|
imageService.downloadIfNew(newCoverImage)
|
||||||
|
|
||||||
|
existingGame.coverImage = newCoverImage
|
||||||
|
existingGame.metadata.fields["coverImage"]?.source = GameFieldUserSource(user = user)
|
||||||
|
}
|
||||||
gameUpdateDto.comment?.let {
|
gameUpdateDto.comment?.let {
|
||||||
existingGame.comment = it
|
existingGame.comment = it
|
||||||
existingGame.metadata.fields["comment"]?.source = GameFieldUserSource(user = user)
|
existingGame.metadata.fields["comment"]?.source = GameFieldUserSource(user = user)
|
||||||
@@ -146,7 +159,7 @@ class GameService(
|
|||||||
gameRepository.deleteById(gameId)
|
gameRepository.deleteById(gameId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getPotentialMatches(searchTerm: String): List<GameSearchResultDto> {
|
fun getPotentialMatches(searchTerm: String, groupResults: Boolean = true): List<GameSearchResultDto> {
|
||||||
// 1. Query all plugins for up to 10 results each
|
// 1. Query all plugins for up to 10 results each
|
||||||
val results = metadataPlugins.flatMap { plugin ->
|
val results = metadataPlugins.flatMap { plugin ->
|
||||||
try {
|
try {
|
||||||
@@ -157,14 +170,32 @@ class GameService(
|
|||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val providerToManagementEntry =
|
||||||
|
results.toMap().entries.associate { it.key to pluginService.getPluginManagementEntry(it.key.javaClass) }
|
||||||
|
|
||||||
|
if (!groupResults) {
|
||||||
|
// If grouping is not requested, return the results directly
|
||||||
|
return results.mapNotNull { (plugin, metadata) ->
|
||||||
|
GameSearchResultDto(
|
||||||
|
title = metadata.title.normalizeGameTitle(),
|
||||||
|
coverUrl = metadata.coverUrl.toString(),
|
||||||
|
release = metadata.release,
|
||||||
|
publishers = metadata.publishedBy?.toList(),
|
||||||
|
developers = metadata.developedBy?.toList(),
|
||||||
|
originalIds = mapOf(
|
||||||
|
plugin.javaClass.name to OriginalIdDto(
|
||||||
|
providerToManagementEntry[plugin]?.pluginId ?: return@mapNotNull null, metadata.originalId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}.sortedByDescending { FuzzySearch.ratio(searchTerm, it.title) }
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Group by title
|
// 2. Group by title
|
||||||
// (NOTE: This _could_ lead to problems if multiple games have the (almost) same title - see Battlefront 2)
|
// (NOTE: This _could_ lead to problems if multiple games have the (almost) same title - see Battlefront 2)
|
||||||
val grouped = results.groupBy { (_, metadata) -> metadata.title.normalizeGameTitle() }
|
val grouped = results.groupBy { (_, metadata) -> metadata.title.normalizeGameTitle() }
|
||||||
|
|
||||||
// 3. Merge each group into one GameSearchResultDto using plugin priorities
|
// 3. Merge each group into one GameSearchResultDto using plugin priorities
|
||||||
val providerToManagementEntry =
|
|
||||||
results.toMap().entries.associate { it.key to pluginService.getPluginManagementEntry(it.key.javaClass) }
|
|
||||||
|
|
||||||
fun pluginPriority(plugin: GameMetadataProvider) = providerToManagementEntry[plugin]?.priority ?: 0
|
fun pluginPriority(plugin: GameMetadataProvider) = providerToManagementEntry[plugin]?.priority ?: 0
|
||||||
|
|
||||||
@@ -538,7 +569,7 @@ fun Game.toDto(): GameDto {
|
|||||||
coverId = this.coverImage?.id,
|
coverId = this.coverImage?.id,
|
||||||
comment = this.comment,
|
comment = this.comment,
|
||||||
summary = this.summary,
|
summary = this.summary,
|
||||||
release = this.release,
|
release = this.release?.atZone(ZoneOffset.UTC)?.toLocalDate(),
|
||||||
userRating = this.userRating,
|
userRating = this.userRating,
|
||||||
criticRating = this.criticRating,
|
criticRating = this.criticRating,
|
||||||
publishers = this.publishers.map { it.name },
|
publishers = this.publishers.map { it.name },
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package de.grimsi.gameyfin.games.dto
|
|||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude
|
import com.fasterxml.jackson.annotation.JsonInclude
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
class GameDto(
|
class GameDto(
|
||||||
@@ -13,7 +14,7 @@ class GameDto(
|
|||||||
val coverId: Long?,
|
val coverId: Long?,
|
||||||
val comment: String?,
|
val comment: String?,
|
||||||
val summary: String?,
|
val summary: String?,
|
||||||
val release: Instant?,
|
val release: LocalDate?,
|
||||||
val userRating: Int?,
|
val userRating: Int?,
|
||||||
val criticRating: Int?,
|
val criticRating: Int?,
|
||||||
val publishers: List<String>?,
|
val publishers: List<String>?,
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package de.grimsi.gameyfin.games.dto
|
package de.grimsi.gameyfin.games.dto
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
data class GameUpdateDto(
|
data class GameUpdateDto(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val title: String?,
|
val title: String?,
|
||||||
|
val release: LocalDate?,
|
||||||
|
val coverUrl: String?,
|
||||||
val comment: String?,
|
val comment: String?,
|
||||||
val summary: String?,
|
val summary: String?,
|
||||||
val metadata: GameUpdateMetadataDto?
|
val metadata: GameUpdateMetadataDto?
|
||||||
|
|||||||
Reference in New Issue
Block a user