diff --git a/gameyfin/src/main/frontend/components/general/LibraryManagementDetails.tsx b/gameyfin/src/main/frontend/components/general/LibraryManagementDetails.tsx new file mode 100644 index 0000000..c4b7e75 --- /dev/null +++ b/gameyfin/src/main/frontend/components/general/LibraryManagementDetails.tsx @@ -0,0 +1,92 @@ +import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto"; +import {Check} from "@phosphor-icons/react"; +import {addToast, Button} from "@heroui/react"; +import React from "react"; +import {Form, Formik} from "formik"; +import {deepDiff} from "Frontend/util/utils"; +import LibraryUpdateDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryUpdateDto"; +import {LibraryEndpoint} from "Frontend/generated/endpoints"; +import Input from "Frontend/components/general/input/Input"; +import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMappingInput"; +import Section from "Frontend/components/general/Section"; +import {useNavigate} from "react-router"; +import * as Yup from "yup"; + +interface LibraryManagementDetailsProps { + library: LibraryDto; +} + +export default function LibraryManagementDetails({library}: LibraryManagementDetailsProps) { + const navigate = useNavigate(); + const [librarySaved, setLibrarySaved] = React.useState(false); + + async function handleSubmit(values: LibraryDto): Promise { + const changed = deepDiff(library, values) as LibraryUpdateDto; + + if (Object.keys(changed).length === 0) return; + + changed.id = library.id; + await LibraryEndpoint.updateLibrary(changed); + setLibrarySaved(true); + setTimeout(() => setLibrarySaved(false), 2000); + } + + async function handleDelete(): Promise { + try { + await LibraryEndpoint.removeLibrary(library.id); + + addToast({ + title: "Library deleted", + description: `Library ${library.name} deleted!`, + color: "success" + }); + + navigate("/administration/libraries"); + } catch (e) { + addToast({ + title: "Error deleting library", + description: `Library ${library.name} could not be deleted!`, + color: "warning" + }); + } + } + + return + {(formik) => ( +
+
+

Edit library details

+ +
+ + + + + +
+ + + )} + ; +} \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/general/Section.tsx b/gameyfin/src/main/frontend/components/general/Section.tsx index f03e22f..34f4fd6 100644 --- a/gameyfin/src/main/frontend/components/general/Section.tsx +++ b/gameyfin/src/main/frontend/components/general/Section.tsx @@ -3,7 +3,7 @@ import {Divider} from "@heroui/react"; export default function Section({title}: { title: string }) { return ( <> -

{title}

+

{title}

); diff --git a/gameyfin/src/main/frontend/components/general/cards/LibraryOverviewCard.tsx b/gameyfin/src/main/frontend/components/general/cards/LibraryOverviewCard.tsx index 802ba4f..d8074eb 100644 --- a/gameyfin/src/main/frontend/components/general/cards/LibraryOverviewCard.tsx +++ b/gameyfin/src/main/frontend/components/general/cards/LibraryOverviewCard.tsx @@ -1,4 +1,4 @@ -import {Button, Card, Chip, Tooltip, useDisclosure} from "@heroui/react"; +import {Button, Card, Chip, Tooltip} from "@heroui/react"; import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto"; import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto"; import React, {useEffect, useState} from "react"; @@ -20,10 +20,10 @@ import { TreasureChest, Trophy } from "@phosphor-icons/react"; -import LibraryDetailsModal from "Frontend/components/general/modals/LibraryDetailsModal"; import LibraryUpdateDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryUpdateDto"; import ScanType from "Frontend/generated/de/grimsi/gameyfin/libraries/enums/ScanType"; import {randomGamesFromLibrary} from "Frontend/util/utils"; +import {useNavigate} from "react-router"; export function LibraryOverviewCard({library, updateLibrary, removeLibrary}: { library: LibraryDto, @@ -31,9 +31,8 @@ export function LibraryOverviewCard({library, updateLibrary, removeLibrary}: { removeLibrary: (library: LibraryDto) => Promise }) { const MAX_COVER_COUNT = 5; - + const navigate = useNavigate(); const [randomGames, setRandomGames] = useState([]); - const libraryDetailsModal = useDisclosure(); useEffect(() => { randomGamesFromLibrary(library, MAX_COVER_COUNT).then((games) => { @@ -85,7 +84,7 @@ export function LibraryOverviewCard({library, updateLibrary, removeLibrary}: { - @@ -103,13 +102,6 @@ export function LibraryOverviewCard({library, updateLibrary, removeLibrary}: { } - ); } \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/general/covers/GameCover.tsx b/gameyfin/src/main/frontend/components/general/covers/GameCover.tsx index ac01c70..52a50bb 100644 --- a/gameyfin/src/main/frontend/components/general/covers/GameCover.tsx +++ b/gameyfin/src/main/frontend/components/general/covers/GameCover.tsx @@ -15,7 +15,7 @@ export function GameCover({game, size = 300, radius = "sm", hover = false}: Game
{game.title}([]); + + useEffect(() => { + randomGamesFromLibrary(library, maxCoverCount).then((games) => { + setRandomGames(games); + }); + }, [library, maxCoverCount]); + + return ( +
+
+ {randomGames + .map((game, idx) => ( +
+ {`Image +
+ )) + .slice(0, maxCoverCount)} +
+
+

{library.name}

+
+
+ ); +} \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/general/input/DirectoryMappingInput.tsx b/gameyfin/src/main/frontend/components/general/input/DirectoryMappingInput.tsx new file mode 100644 index 0000000..7813eef --- /dev/null +++ b/gameyfin/src/main/frontend/components/general/input/DirectoryMappingInput.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import {Button, Code, useDisclosure} from "@heroui/react"; +import {ArrowRight, Minus, Plus, XCircle} from "@phosphor-icons/react"; +import PathPickerModal from "Frontend/components/general/modals/PathPickerModal"; +import {SmallInfoField} from "Frontend/components/general/SmallInfoField"; +import DirectoryMappingDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/DirectoryMappingDto"; +import {useField} from "formik"; + +interface DirectoryMappingInputProps { + name: string; +} + +export default function DirectoryMappingInput({name}: DirectoryMappingInputProps) { + const pathPickerModal = useDisclosure(); + const [field, meta, helpers] = useField({name}); + + function addDirectoryMapping(directory: DirectoryMappingDto) { + helpers.setValue([...(field.value || []), directory]); + } + + function removeDirectoryMapping(directory: DirectoryMappingDto) { + helpers.setValue((field.value || []).filter((d) => d !== directory)); + } + + return ( +
+
+

Directories

+ +
+ {(field.value || []).map((directory) => ( + + + {directory.externalPath && ( + <> +
+ +
+ + + )} + +
+ ))} +
+ {meta.touched && meta.error && ( + + )} +
+ +
+ ); +} \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/general/modals/LibraryCreationModal.tsx b/gameyfin/src/main/frontend/components/general/modals/LibraryCreationModal.tsx index 53fc469..cdd5627 100644 --- a/gameyfin/src/main/frontend/components/general/modals/LibraryCreationModal.tsx +++ b/gameyfin/src/main/frontend/components/general/modals/LibraryCreationModal.tsx @@ -1,24 +1,11 @@ import React from "react"; -import { - addToast, - Button, - Code, - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, - useDisclosure -} from "@heroui/react"; +import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react"; import {Form, Formik} from "formik"; import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto"; import {LibraryEndpoint} from "Frontend/generated/endpoints"; import Input from "Frontend/components/general/input/Input"; -import PathPickerModal from "Frontend/components/general/modals/PathPickerModal"; -import {ArrowRight, Minus, Plus, XCircle} from "@phosphor-icons/react"; import * as Yup from "yup"; -import {SmallInfoField} from "Frontend/components/general/SmallInfoField"; -import DirectoryMappingDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/DirectoryMappingDto"; +import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMappingInput"; interface LibraryCreationModalProps { libraries: LibraryDto[]; @@ -33,12 +20,9 @@ export default function LibraryCreationModal({ isOpen, onOpenChange }: LibraryCreationModalProps) { - const pathPickerModal = useDisclosure(); - async function createLibrary(library: LibraryDto) { try { const newLibrary = await LibraryEndpoint.createLibrary(library as LibraryDto); - if (newLibrary === undefined) return; setLibraries([...libraries, newLibrary]); } catch (e) { addToast({ @@ -76,98 +60,35 @@ export default function LibraryCreationModal({ onClose(); }} > - {(formik) => { - function addDirectoryMapping(directory: DirectoryMappingDto) { - formik.setFieldValue("directories", [...formik.values.directories, directory]); - } - - function removeDirectoryMapping(directory: DirectoryMappingDto) { - formik.setFieldValue("directories", formik.values.directories.filter((d: DirectoryMappingDto) => d.internalPath !== directory.internalPath)); - } - - return ( -
- Add a new library - -
- -
-

Directories

- -
- {formik.values.directories.map((directory: DirectoryMappingDto) => ( - - - {directory.externalPath && ( - <> -
- -
- - - )} - -
- ))} -
- {(() => { - const meta = formik.getFieldMeta("directories"); - return meta.touched && meta.error && ( - - ); - })()} -
-
-
- - - - - - - ); - }} + {(formik) => +
+ Add a new library + +
+ + +
+
+ + + + +
+ } )} diff --git a/gameyfin/src/main/frontend/components/general/withSideMenu.tsx b/gameyfin/src/main/frontend/components/general/withSideMenu.tsx index 7c3a78f..3c00e74 100644 --- a/gameyfin/src/main/frontend/components/general/withSideMenu.tsx +++ b/gameyfin/src/main/frontend/components/general/withSideMenu.tsx @@ -9,7 +9,7 @@ export type MenuItem = { icon: ReactElement } -export default function withSideMenu(menuItems: MenuItem[]) { +export default function withSideMenu(baseUrl: string, menuItems: MenuItem[]) { return function PageWithSideMenu() { const [selectedItem, setSelectedItem] = useState(initialSelected) @@ -24,24 +24,26 @@ export default function withSideMenu(menuItems: MenuItem[]) { * If the key starts with "/" assume it's an absolute link, else assume it's relative */ function link(l: string): string { - if (l.startsWith("/")) return l; - const p = window.location.pathname - return p.substring(0, p.lastIndexOf("/") + 1) + l; + if (l.startsWith("/")) return baseUrl + l; + return baseUrl + "/" + l; } /** * Match the initially selected item by current URL path */ function initialSelected(): string { - const p = window.location.pathname - return p.substring(p.lastIndexOf("/") + 1, p.length); + const p = window.location.pathname; + const idx = p.indexOf(baseUrl); + if (idx === -1) return ""; + const afterBase = p.substring(idx + baseUrl.length); + // Remove leading slash, then split and take the first segment + return afterBase.replace(/^\/+/, "").split("/")[0] || ""; } return (
- + {menuItems.map((i) => ( setSelectedItem(i.url)} diff --git a/gameyfin/src/main/frontend/routes.tsx b/gameyfin/src/main/frontend/routes.tsx index 40388e7..7001815 100644 --- a/gameyfin/src/main/frontend/routes.tsx +++ b/gameyfin/src/main/frontend/routes.tsx @@ -52,11 +52,11 @@ export const routes = protectRoutes([ children: [ { path: 'libraries', - element: , - children: [{ - path: 'library/:libraryId', - element: - }] + element: + }, + { + path: 'libraries/library/:libraryId', + element: }, {path: 'users', element: }, {path: 'sso', element: }, diff --git a/gameyfin/src/main/frontend/util/utils.ts b/gameyfin/src/main/frontend/util/utils.ts index 938444e..2190312 100644 --- a/gameyfin/src/main/frontend/util/utils.ts +++ b/gameyfin/src/main/frontend/util/utils.ts @@ -137,5 +137,55 @@ export async function randomGamesFromLibrary(library: LibraryDto, count?: number return games .sort((a: GameDto, b: GameDto) => a.id - b.id) .sort(() => rand.next() - 0.5) + .filter(g => g.imageIds && g.imageIds.length > 0) .slice(0, count ?? games.length); +} + + +/** + * Return an object with the changed fields between two objects. + * The returned object will only contain the changed fields with values from the current object. + * @param initial + * @param current + */ +export function deepDiff(initial: T, current: T): Partial { + const diff: Partial = {}; + + function compareObjects(obj1: any, obj2: any): any { + if (typeof obj1 !== 'object' || typeof obj2 !== 'object' || obj1 === null || obj2 === null) { + if (obj1 !== obj2) { + return obj2; + } + return undefined; + } + + if (Array.isArray(obj1) && Array.isArray(obj2)) { + if (obj1.length !== obj2.length) { + return obj2; + } else { + const arrayDiff = obj1.map((item: any, index: number) => compareObjects(item, obj2[index])); + if (arrayDiff.some(item => item !== undefined)) { + return arrayDiff; + } + return undefined; + } + } + + const keys = new Set([...Object.keys(obj1), ...Object.keys(obj2)]); + const objDiff: any = {}; + keys.forEach(key => { + const valueDiff = compareObjects(obj1[key], obj2[key]); + if (valueDiff !== undefined) { + objDiff[key] = valueDiff; + } + }); + + if (Object.keys(objDiff).length > 0) { + return objDiff; + } + return undefined; + } + + const result = compareObjects(initial, current); + return result || {}; } \ No newline at end of file diff --git a/gameyfin/src/main/frontend/views/AdministrationView.tsx b/gameyfin/src/main/frontend/views/AdministrationView.tsx index 6210fb8..456bdb5 100644 --- a/gameyfin/src/main/frontend/views/AdministrationView.tsx +++ b/gameyfin/src/main/frontend/views/AdministrationView.tsx @@ -39,5 +39,5 @@ const menuItems: MenuItem[] = [ } ] -export const AdministrationView = withSideMenu(menuItems); +export const AdministrationView = withSideMenu("/administration", menuItems); export default AdministrationView; \ No newline at end of file diff --git a/gameyfin/src/main/frontend/views/GameView.tsx b/gameyfin/src/main/frontend/views/GameView.tsx index 0c4a120..2ef38bf 100644 --- a/gameyfin/src/main/frontend/views/GameView.tsx +++ b/gameyfin/src/main/frontend/views/GameView.tsx @@ -22,7 +22,7 @@ export default function GameView() { label: provider.name, description: provider.shortDescription ?? provider.description, action: () => { - if (gameId) DownloadEndpoint.downloadGame(parseInt(gameId!), provider.key); + if (gameId) DownloadEndpoint.downloadGame(parseInt(gameId), provider.key); }, }; return acc; diff --git a/gameyfin/src/main/frontend/views/LibraryManagementView.tsx b/gameyfin/src/main/frontend/views/LibraryManagementView.tsx index 2d7cacb..d2117d5 100644 --- a/gameyfin/src/main/frontend/views/LibraryManagementView.tsx +++ b/gameyfin/src/main/frontend/views/LibraryManagementView.tsx @@ -1,7 +1,48 @@ -import {useParams} from "react-router"; +import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto"; +import {useNavigate, useParams} from "react-router"; +import React, {useEffect, useState} from "react"; +import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto"; +import {LibraryEndpoint} from "Frontend/generated/endpoints"; +import LibraryHeader from "Frontend/components/general/covers/LibraryHeader"; +import {Button, Tab, Tabs} from "@heroui/react"; +import {ArrowLeft} from "@phosphor-icons/react"; +import LibraryManagementDetails from "Frontend/components/general/LibraryManagementDetails"; + export default function LibraryManagementView() { const {libraryId} = useParams(); + const navigate = useNavigate(); + const [library, setLibrary] = useState(); + const [games, setGames] = useState([]); - return (<>); + useEffect(() => { + if (!libraryId) return; + LibraryEndpoint.getById(parseInt(libraryId)).then((library: LibraryDto) => { + setLibrary(library); + }); + LibraryEndpoint.getGamesInLibrary(parseInt(libraryId)).then((games) => { + setGames(games); + }); + }, []); + + return library &&
+
+ +

Manage library

+
+ + + + + + +

Games

+
+ +

Unmatched paths

+
+
+
; } \ No newline at end of file diff --git a/gameyfin/src/main/frontend/views/ProfileView.tsx b/gameyfin/src/main/frontend/views/ProfileView.tsx index 785da19..ac62e63 100644 --- a/gameyfin/src/main/frontend/views/ProfileView.tsx +++ b/gameyfin/src/main/frontend/views/ProfileView.tsx @@ -19,5 +19,5 @@ const menuItems = [ } ] -export const ProfileView = withSideMenu(menuItems); +export const ProfileView = withSideMenu("/settings", menuItems); export default ProfileView; \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryEndpoint.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryEndpoint.kt index 03ddeac..42baf11 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryEndpoint.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryEndpoint.kt @@ -18,6 +18,8 @@ class LibraryEndpoint( return libraryService.getAllLibraries() } + fun getById(libraryId: Long): LibraryDto = libraryService.getById(libraryId) + fun getGamesInLibrary(libraryId: Long): Collection { return libraryService.getGamesInLibrary(libraryId) } diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryService.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryService.kt index 07f7120..a167e9a 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryService.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryService.kt @@ -75,6 +75,19 @@ class LibraryService( return entities.map { toDto(it) } } + /** + * Retrieves a library by its ID. + * + * @param libraryId: ID of the library to retrieve. + * @return The LibraryDto object representing the library. + */ + fun getById(libraryId: Long): LibraryDto { + val library = libraryRepository.findByIdOrNull(libraryId) + ?: throw IllegalArgumentException("Library with ID $libraryId not found") + + return toDto(library) + } + /** * Deletes a library from the repository. *