mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
Implement library management
This commit is contained in:
@@ -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)}
|
||||
|
||||
@@ -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/>},
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user