diff --git a/gameyfin/src/main/frontend/components/general/input/DatePickerInput.tsx b/gameyfin/src/main/frontend/components/general/input/DatePickerInput.tsx new file mode 100644 index 0000000..57eaee4 --- /dev/null +++ b/gameyfin/src/main/frontend/components/general/input/DatePickerInput.tsx @@ -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(parseDate(field.value)); + + return ( + { + 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} + /> + ); +} \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/general/input/GameCoverPicker.tsx b/gameyfin/src/main/frontend/components/general/input/GameCoverPicker.tsx new file mode 100644 index 0000000..7049322 --- /dev/null +++ b/gameyfin/src/main/frontend/components/general/input/GameCoverPicker.tsx @@ -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 (<> +
+ {game.title}} + /> +
+ +
+
+ field.onChange({target: {name: field.name, value: coverUrl}})} + /> + ); +} \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/general/modals/EditGameMetadataModal.tsx b/gameyfin/src/main/frontend/components/general/modals/EditGameMetadataModal.tsx index 4f79862..8de7070 100644 --- a/gameyfin/src/main/frontend/components/general/modals/EditGameMetadataModal.tsx +++ b/gameyfin/src/main/frontend/components/general/modals/EditGameMetadataModal.tsx @@ -7,6 +7,9 @@ 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"; interface EditGameMetadataModalProps { game: GameDto; @@ -21,6 +24,7 @@ export default function EditGameMetadataModal({game, isOpen, onOpenChange}: Edit {(onClose) => { async function updateGame(values: GameUpdateDto) { + //@ts-ignore const changed = deepDiff(game, values) as GameUpdateDto; if (Object.keys(changed).length === 0) return; @@ -33,6 +37,9 @@ export default function EditGameMetadataModal({game, isOpen, onOpenChange}: Edit {(formik: any) => (
@@ -40,8 +47,17 @@ export default function EditGameMetadataModal({game, isOpen, onOpenChange}: Edit Update game metadata - - +
+ {/*@ts-ignore*/} + +
+ + + +
+
+
diff --git a/gameyfin/src/main/frontend/components/general/modals/GameCoverPickerModal.tsx b/gameyfin/src/main/frontend/components/general/modals/GameCoverPickerModal.tsx new file mode 100644 index 0000000..2fdd7fc --- /dev/null +++ b/gameyfin/src/main/frontend/components/general/modals/GameCoverPickerModal.tsx @@ -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([]); + 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 ( + + + {(onClose) => { + return (<> + + Enter a URL or search for a cover + + +
+ setCoverUrlState("")} + /> + +
+
+ { + if (e.key === "Enter") { + e.preventDefault(); + await search(); + } + }} + /> + +
+ + {searchResults.length === 0 && "No results found."} + {searchResults.map((result) => ( + {result.title} { + setCoverUrl(result.coverUrl!); + onClose(); + }} + /> + ))} + +
+ ) + }} +
+
+ ); +} \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/general/modals/MatchGameModal.tsx b/gameyfin/src/main/frontend/components/general/modals/MatchGameModal.tsx index f9ba3f3..2f8863d 100644 --- a/gameyfin/src/main/frontend/components/general/modals/MatchGameModal.tsx +++ b/gameyfin/src/main/frontend/components/general/modals/MatchGameModal.tsx @@ -51,7 +51,7 @@ export default function MatchGameModal({ async function search() { setIsSearching(true); - const results = await GameEndpoint.getPotentialMatches(searchTerm); + const results = await GameEndpoint.getPotentialMatches(searchTerm, true); setSearchResults(results); setIsSearching(false); } diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameEndpoint.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameEndpoint.kt index 05f599b..1769606 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameEndpoint.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameEndpoint.kt @@ -31,8 +31,8 @@ class GameEndpoint( } @RolesAllowed(Role.Names.ADMIN) - fun getPotentialMatches(searchTerm: String): List { - return gameService.getPotentialMatches(searchTerm) + fun getPotentialMatches(searchTerm: String, groupResults: Boolean): List { + return gameService.getPotentialMatches(searchTerm, groupResults) } @RolesAllowed(Role.Names.ADMIN) diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt index 7b08d5c..f5c035c 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt @@ -29,7 +29,9 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import reactor.core.publisher.Flux import reactor.core.publisher.Sinks +import java.net.URI import java.nio.file.Path +import java.time.ZoneOffset import kotlin.time.Duration.Companion.milliseconds import kotlin.time.toJavaDuration import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata as PluginApiMetadata @@ -125,6 +127,17 @@ class GameService( existingGame.title = it 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 { existingGame.comment = it existingGame.metadata.fields["comment"]?.source = GameFieldUserSource(user = user) @@ -146,7 +159,7 @@ class GameService( gameRepository.deleteById(gameId) } - fun getPotentialMatches(searchTerm: String): List { + fun getPotentialMatches(searchTerm: String, groupResults: Boolean = true): List { // 1. Query all plugins for up to 10 results each val results = metadataPlugins.flatMap { plugin -> try { @@ -157,14 +170,32 @@ class GameService( 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 // (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() } // 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 @@ -538,7 +569,7 @@ fun Game.toDto(): GameDto { coverId = this.coverImage?.id, comment = this.comment, summary = this.summary, - release = this.release, + release = this.release?.atZone(ZoneOffset.UTC)?.toLocalDate(), userRating = this.userRating, criticRating = this.criticRating, publishers = this.publishers.map { it.name }, diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameDto.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameDto.kt index 269b4eb..8738122 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameDto.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameDto.kt @@ -2,6 +2,7 @@ package de.grimsi.gameyfin.games.dto import com.fasterxml.jackson.annotation.JsonInclude import java.time.Instant +import java.time.LocalDate @JsonInclude(JsonInclude.Include.NON_NULL) class GameDto( @@ -13,7 +14,7 @@ class GameDto( val coverId: Long?, val comment: String?, val summary: String?, - val release: Instant?, + val release: LocalDate?, val userRating: Int?, val criticRating: Int?, val publishers: List?, diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameUpdateDto.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameUpdateDto.kt index 9670a56..ad6e5b9 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameUpdateDto.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/dto/GameUpdateDto.kt @@ -1,8 +1,12 @@ package de.grimsi.gameyfin.games.dto +import java.time.LocalDate + data class GameUpdateDto( val id: Long, val title: String?, + val release: LocalDate?, + val coverUrl: String?, val comment: String?, val summary: String?, val metadata: GameUpdateMetadataDto?