Implement library management

This commit is contained in:
grimsi
2025-05-20 20:52:43 +02:00
parent d5eb4b9a73
commit c770ed182e
16 changed files with 381 additions and 142 deletions
@@ -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<void> {
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<void> {
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
initialValues={library}
onSubmit={handleSubmit}
enableReinitialize={true}
validationSchema={Yup.object({
name: Yup.string()
.required("Library name is required")
.max(255, "Library name must be 255 characters or less"),
directories: Yup.array()
.of(Yup.object())
.min(1, "At least one directory is required")
})}
>
{(formik) => (
<Form>
<div className="flex flex-row flex-grow justify-between mb-4">
<h1 className="text-2xl font-bold">Edit library details</h1>
<Button
color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting || librarySaved || !formik.dirty}
type="submit"
>
{formik.isSubmitting ? "" : librarySaved ? <Check/> : "Save"}
</Button>
</div>
<Input label="Library name" name="name"/>
<DirectoryMappingInput name="directories"/>
<Section title="Danger zone"/>
<Button color="danger" onPress={handleDelete}>
Delete library
</Button>
</Form>
)}
</Formik>;
}
@@ -3,7 +3,7 @@ import {Divider} from "@heroui/react";
export default function Section({title}: { title: string }) {
return (
<>
<h2 className={"text-xl font-bold mt-8 mb-1"}>{title}</h2>
<h2 className="text-xl font-bold mt-8 mb-1">{title}</h2>
<Divider className="mb-4"/>
</>
);
@@ -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<void>
}) {
const MAX_COVER_COUNT = 5;
const navigate = useNavigate();
const [randomGames, setRandomGames] = useState<GameDto[]>([]);
const libraryDetailsModal = useDisclosure();
useEffect(() => {
randomGamesFromLibrary(library, MAX_COVER_COUNT).then((games) => {
@@ -85,7 +84,7 @@ export function LibraryOverviewCard({library, updateLibrary, removeLibrary}: {
</Button>
</Tooltip>
<Tooltip content="Configuration" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={libraryDetailsModal.onOpen}>
<Button isIconOnly variant="light" onPress={() => navigate('library/' + library.id)}>
<SlidersHorizontal/>
</Button>
</Tooltip>
@@ -103,13 +102,6 @@ export function LibraryOverviewCard({library, updateLibrary, removeLibrary}: {
</div>
}
</Card>
<LibraryDetailsModal
library={library}
isOpen={libraryDetailsModal.isOpen}
onOpenChange={libraryDetailsModal.onOpenChange}
updateLibrary={updateLibrary}
removeLibrary={removeLibrary}
/>
</>
);
}
@@ -15,7 +15,7 @@ export function GameCover({game, size = 300, radius = "sm", hover = false}: Game
<div className={`${hover ? "rounded-md scale-95 hover:scale-100 shine transition-all" : ""}`}>
<Image
alt={game.title}
className="z-0 w-full h-full object-cover aspect-[12/17]"
className="z-0 size-full object-cover aspect-[12/17]"
src={`images/cover/${game.coverId}`}
radius={radius}
height={size}
@@ -0,0 +1,47 @@
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
import React, {useEffect, useState} from "react";
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
import {randomGamesFromLibrary} from "Frontend/util/utils";
interface LibraryHeaderProps {
library: LibraryDto;
maxCoverCount?: number;
className?: string;
}
export default function LibraryHeader({library, maxCoverCount = 5, className}: LibraryHeaderProps) {
const [randomGames, setRandomGames] = useState<GameDto[]>([]);
useEffect(() => {
randomGamesFromLibrary(library, maxCoverCount).then((games) => {
setRandomGames(games);
});
}, [library, maxCoverCount]);
return (
<div className={`overflow-hidden rounded-lg relative pointer-events-none select-none ${className}`}>
<div className="flex flex-row items-center w-full h-full brightness-50">
{randomGames
.map((game, idx) => (
<div
key={idx}
className="flex-none overflow-hidden -ml-[10%]"
style={{
width: `calc(100% / ${maxCoverCount - 2})`,
clipPath: 'polygon(15% 0, 100% 0, 85% 100%, 0% 100%)',
}}
>
<img
src={`/images/screenshot/${game.imageIds![0]}`}
alt={`Image ${idx}`}
/>
</div>
))
.slice(0, maxCoverCount)}
</div>
<div className="absolute inset-0 flex items-center justify-center">
<h2 className="text-white text-3xl font-bold">{library.name}</h2>
</div>
</div>
);
}
@@ -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<DirectoryMappingDto[]>({name});
function addDirectoryMapping(directory: DirectoryMappingDto) {
helpers.setValue([...(field.value || []), directory]);
}
function removeDirectoryMapping(directory: DirectoryMappingDto) {
helpers.setValue((field.value || []).filter((d) => d !== directory));
}
return (
<div className="flex flex-col gap-2">
<div className="flex flex-row justify-between items-center">
<p className="font-bold">Directories</p>
<Button isIconOnly variant="light" size="sm" color="default"
onPress={pathPickerModal.onOpen}>
<Plus/>
</Button>
</div>
{(field.value || []).map((directory) => (
<Code
className="w-full flex items-center gap-2 overflow-hidden px-2 py-1"
key={directory.internalPath}>
<input
type="text"
value={directory.internalPath}
readOnly
className="flex-1 bg-transparent border-none outline-none overflow-x-auto whitespace-nowrap"
/>
{directory.externalPath && (
<>
<div className="flex-shrink-0 flex items-center justify-center mx-2">
<ArrowRight size={20}/>
</div>
<input
type="text"
value={directory.externalPath}
readOnly
className="flex-1 bg-transparent border-none outline-none overflow-x-auto whitespace-nowrap"
/>
</>
)}
<Button
isIconOnly
variant="light"
size="sm"
color="default"
onPress={() => removeDirectoryMapping(directory)}
className="ml-2"
>
<Minus/>
</Button>
</Code>
))}
<div className="min-h-6 text-danger">
{meta.touched && meta.error && (
<SmallInfoField icon={XCircle} message={meta.error}/>
)}
</div>
<PathPickerModal returnSelectedPath={addDirectoryMapping}
isOpen={pathPickerModal.isOpen}
onOpenChange={pathPickerModal.onOpenChange}/>
</div>
);
}
@@ -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 (
<Form>
<ModalHeader className="flex flex-col gap-1">Add a new library</ModalHeader>
<ModalBody>
<div className="flex flex-col gap-2">
<Input
name="name"
label="Library Name"
placeholder="Enter library name"
value={formik.values.name}
required
/>
<div className="flex flex-row justify-between items-center">
<p className="font-bold">Directories</p>
<Button isIconOnly variant="light" size="sm" color="default"
onPress={pathPickerModal.onOpen}>
<Plus/>
</Button>
</div>
{formik.values.directories.map((directory: DirectoryMappingDto) => (
<Code
className="w-full flex items-center gap-2 overflow-hidden px-2 py-1"
key={directory.internalPath}>
<input
type="text"
value={directory.internalPath}
readOnly
className="flex-1 bg-transparent border-none outline-none overflow-x-auto whitespace-nowrap"
/>
{directory.externalPath && (
<>
<div
className="flex-shrink-0 flex items-center justify-center mx-2">
<ArrowRight size={20}/>
</div>
<input
type="text"
value={directory.externalPath}
readOnly
className="flex-1 bg-transparent border-none outline-none overflow-x-auto whitespace-nowrap"
/>
</>
)}
<Button
isIconOnly
variant="light"
size="sm"
color="default"
onPress={() => removeDirectoryMapping(directory)}
className="ml-2"
>
<Minus/>
</Button>
</Code>
))}
<div className="min-h-6 text-danger">
{(() => {
const meta = formik.getFieldMeta("directories");
return meta.touched && meta.error && (
<SmallInfoField icon={XCircle} message={meta.error}/>
);
})()}
</div>
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting}
type="submit"
>
{formik.isSubmitting ? "" : "Add"}
</Button>
</ModalFooter>
<PathPickerModal returnSelectedPath={addDirectoryMapping}
isOpen={pathPickerModal.isOpen}
onOpenChange={pathPickerModal.onOpenChange}/>
</Form>
);
}}
{(formik) =>
<Form>
<ModalHeader className="flex flex-col gap-1">Add a new library</ModalHeader>
<ModalBody>
<div className="flex flex-col gap-2">
<Input
name="name"
label="Library Name"
placeholder="Enter library name"
value={formik.values.name}
required
/>
<DirectoryMappingInput name="directories"/>
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting}
type="submit"
>
{formik.isSubmitting ? "" : "Add"}
</Button>
</ModalFooter>
</Form>
}
</Formik>
)}
</ModalContent>
@@ -9,7 +9,7 @@ export type MenuItem = {
icon: ReactElement<Icon>
}
export default function withSideMenu(menuItems: MenuItem[]) {
export default function withSideMenu(baseUrl: string, menuItems: MenuItem[]) {
return function PageWithSideMenu() {
const [selectedItem, setSelectedItem] = useState<string>(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 (
<div className="flex flex-row">
<div className="flex flex-col pr-8">
<Listbox className="min-w-60"
color="primary">
<Listbox className="min-w-60" color="primary">
{menuItems.map((i) => (
<ListboxItem key={key(i.url)} startContent={i.icon} href={link(i.url)}
onPress={() => setSelectedItem(i.url)}
+5 -5
View File
@@ -52,11 +52,11 @@ export const routes = protectRoutes([
children: [
{
path: 'libraries',
element: <LibraryManagement/>,
children: [{
path: 'library/:libraryId',
element: <LibraryManagementView/>
}]
element: <LibraryManagement/>
},
{
path: 'libraries/library/:libraryId',
element: <LibraryManagementView/>
},
{path: 'users', element: <UserManagement/>},
{path: 'sso', element: <SsoManagement/>},
+50
View File
@@ -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<T extends object>(initial: T, current: T): Partial<T> {
const diff: Partial<T> = {};
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 || {};
}
@@ -39,5 +39,5 @@ const menuItems: MenuItem[] = [
}
]
export const AdministrationView = withSideMenu(menuItems);
export const AdministrationView = withSideMenu("/administration", menuItems);
export default AdministrationView;
@@ -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;
@@ -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<LibraryDto>();
const [games, setGames] = useState<GameDto[]>([]);
return (<></>);
useEffect(() => {
if (!libraryId) return;
LibraryEndpoint.getById(parseInt(libraryId)).then((library: LibraryDto) => {
setLibrary(library);
});
LibraryEndpoint.getGamesInLibrary(parseInt(libraryId)).then((games) => {
setGames(games);
});
}, []);
return library && <div className="flex flex-col gap-4">
<div className="flex flex-row gap-4 items-center">
<Button isIconOnly variant="light" onPress={() => navigate("/administration/libraries")}>
<ArrowLeft/>
</Button>
<h1 className="text-2xl font-bold">Manage library</h1>
</div>
<LibraryHeader library={library} maxCoverCount={Math.min(games.length, 10)} className="h-32"/>
<Tabs color="primary" fullWidth>
<Tab title="Details">
<LibraryManagementDetails library={library}/>
</Tab>
<Tab title="Games">
<p>Games</p>
</Tab>
<Tab title="Unmatched paths">
<p>Unmatched paths</p>
</Tab>
</Tabs>
</div>;
}
@@ -19,5 +19,5 @@ const menuItems = [
}
]
export const ProfileView = withSideMenu(menuItems);
export const ProfileView = withSideMenu("/settings", menuItems);
export default ProfileView;
@@ -18,6 +18,8 @@ class LibraryEndpoint(
return libraryService.getAllLibraries()
}
fun getById(libraryId: Long): LibraryDto = libraryService.getById(libraryId)
fun getGamesInLibrary(libraryId: Long): Collection<GameDto> {
return libraryService.getGamesInLibrary(libraryId)
}
@@ -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.
*