mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-16 16:20:04 +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 }) {
|
export default function Section({title}: { title: string }) {
|
||||||
return (
|
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"/>
|
<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 LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
|
||||||
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
|
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
|
||||||
import React, {useEffect, useState} from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
@@ -20,10 +20,10 @@ import {
|
|||||||
TreasureChest,
|
TreasureChest,
|
||||||
Trophy
|
Trophy
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import LibraryDetailsModal from "Frontend/components/general/modals/LibraryDetailsModal";
|
|
||||||
import LibraryUpdateDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryUpdateDto";
|
import LibraryUpdateDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryUpdateDto";
|
||||||
import ScanType from "Frontend/generated/de/grimsi/gameyfin/libraries/enums/ScanType";
|
import ScanType from "Frontend/generated/de/grimsi/gameyfin/libraries/enums/ScanType";
|
||||||
import {randomGamesFromLibrary} from "Frontend/util/utils";
|
import {randomGamesFromLibrary} from "Frontend/util/utils";
|
||||||
|
import {useNavigate} from "react-router";
|
||||||
|
|
||||||
export function LibraryOverviewCard({library, updateLibrary, removeLibrary}: {
|
export function LibraryOverviewCard({library, updateLibrary, removeLibrary}: {
|
||||||
library: LibraryDto,
|
library: LibraryDto,
|
||||||
@@ -31,9 +31,8 @@ export function LibraryOverviewCard({library, updateLibrary, removeLibrary}: {
|
|||||||
removeLibrary: (library: LibraryDto) => Promise<void>
|
removeLibrary: (library: LibraryDto) => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
const MAX_COVER_COUNT = 5;
|
const MAX_COVER_COUNT = 5;
|
||||||
|
const navigate = useNavigate();
|
||||||
const [randomGames, setRandomGames] = useState<GameDto[]>([]);
|
const [randomGames, setRandomGames] = useState<GameDto[]>([]);
|
||||||
const libraryDetailsModal = useDisclosure();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
randomGamesFromLibrary(library, MAX_COVER_COUNT).then((games) => {
|
randomGamesFromLibrary(library, MAX_COVER_COUNT).then((games) => {
|
||||||
@@ -85,7 +84,7 @@ export function LibraryOverviewCard({library, updateLibrary, removeLibrary}: {
|
|||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Configuration" placement="bottom" color="foreground">
|
<Tooltip content="Configuration" placement="bottom" color="foreground">
|
||||||
<Button isIconOnly variant="light" onPress={libraryDetailsModal.onOpen}>
|
<Button isIconOnly variant="light" onPress={() => navigate('library/' + library.id)}>
|
||||||
<SlidersHorizontal/>
|
<SlidersHorizontal/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -103,13 +102,6 @@ export function LibraryOverviewCard({library, updateLibrary, removeLibrary}: {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</Card>
|
</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" : ""}`}>
|
<div className={`${hover ? "rounded-md scale-95 hover:scale-100 shine transition-all" : ""}`}>
|
||||||
<Image
|
<Image
|
||||||
alt={game.title}
|
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}`}
|
src={`images/cover/${game.coverId}`}
|
||||||
radius={radius}
|
radius={radius}
|
||||||
height={size}
|
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 React from "react";
|
||||||
import {
|
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
|
||||||
addToast,
|
|
||||||
Button,
|
|
||||||
Code,
|
|
||||||
Modal,
|
|
||||||
ModalBody,
|
|
||||||
ModalContent,
|
|
||||||
ModalFooter,
|
|
||||||
ModalHeader,
|
|
||||||
useDisclosure
|
|
||||||
} from "@heroui/react";
|
|
||||||
import {Form, Formik} from "formik";
|
import {Form, Formik} from "formik";
|
||||||
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
|
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
|
||||||
import {LibraryEndpoint} from "Frontend/generated/endpoints";
|
import {LibraryEndpoint} from "Frontend/generated/endpoints";
|
||||||
import Input from "Frontend/components/general/input/Input";
|
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 * as Yup from "yup";
|
||||||
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMappingInput";
|
||||||
import DirectoryMappingDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/DirectoryMappingDto";
|
|
||||||
|
|
||||||
interface LibraryCreationModalProps {
|
interface LibraryCreationModalProps {
|
||||||
libraries: LibraryDto[];
|
libraries: LibraryDto[];
|
||||||
@@ -33,12 +20,9 @@ export default function LibraryCreationModal({
|
|||||||
isOpen,
|
isOpen,
|
||||||
onOpenChange
|
onOpenChange
|
||||||
}: LibraryCreationModalProps) {
|
}: LibraryCreationModalProps) {
|
||||||
const pathPickerModal = useDisclosure();
|
|
||||||
|
|
||||||
async function createLibrary(library: LibraryDto) {
|
async function createLibrary(library: LibraryDto) {
|
||||||
try {
|
try {
|
||||||
const newLibrary = await LibraryEndpoint.createLibrary(library as LibraryDto);
|
const newLibrary = await LibraryEndpoint.createLibrary(library as LibraryDto);
|
||||||
if (newLibrary === undefined) return;
|
|
||||||
setLibraries([...libraries, newLibrary]);
|
setLibraries([...libraries, newLibrary]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
addToast({
|
addToast({
|
||||||
@@ -76,16 +60,7 @@ export default function LibraryCreationModal({
|
|||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(formik) => {
|
{(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>
|
<Form>
|
||||||
<ModalHeader className="flex flex-col gap-1">Add a new library</ModalHeader>
|
<ModalHeader className="flex flex-col gap-1">Add a new library</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
@@ -97,57 +72,7 @@ export default function LibraryCreationModal({
|
|||||||
value={formik.values.name}
|
value={formik.values.name}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-row justify-between items-center">
|
<DirectoryMappingInput name="directories"/>
|
||||||
<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>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
@@ -162,12 +87,8 @@ export default function LibraryCreationModal({
|
|||||||
{formik.isSubmitting ? "" : "Add"}
|
{formik.isSubmitting ? "" : "Add"}
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
<PathPickerModal returnSelectedPath={addDirectoryMapping}
|
|
||||||
isOpen={pathPickerModal.isOpen}
|
|
||||||
onOpenChange={pathPickerModal.onOpenChange}/>
|
|
||||||
</Form>
|
</Form>
|
||||||
);
|
}
|
||||||
}}
|
|
||||||
</Formik>
|
</Formik>
|
||||||
)}
|
)}
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export type MenuItem = {
|
|||||||
icon: ReactElement<Icon>
|
icon: ReactElement<Icon>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function withSideMenu(menuItems: MenuItem[]) {
|
export default function withSideMenu(baseUrl: string, menuItems: MenuItem[]) {
|
||||||
return function PageWithSideMenu() {
|
return function PageWithSideMenu() {
|
||||||
const [selectedItem, setSelectedItem] = useState<string>(initialSelected)
|
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
|
* If the key starts with "/" assume it's an absolute link, else assume it's relative
|
||||||
*/
|
*/
|
||||||
function link(l: string): string {
|
function link(l: string): string {
|
||||||
if (l.startsWith("/")) return l;
|
if (l.startsWith("/")) return baseUrl + l;
|
||||||
const p = window.location.pathname
|
return baseUrl + "/" + l;
|
||||||
return p.substring(0, p.lastIndexOf("/") + 1) + l;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Match the initially selected item by current URL path
|
* Match the initially selected item by current URL path
|
||||||
*/
|
*/
|
||||||
function initialSelected(): string {
|
function initialSelected(): string {
|
||||||
const p = window.location.pathname
|
const p = window.location.pathname;
|
||||||
return p.substring(p.lastIndexOf("/") + 1, p.length);
|
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 (
|
return (
|
||||||
<div className="flex flex-row">
|
<div className="flex flex-row">
|
||||||
<div className="flex flex-col pr-8">
|
<div className="flex flex-col pr-8">
|
||||||
<Listbox className="min-w-60"
|
<Listbox className="min-w-60" color="primary">
|
||||||
color="primary">
|
|
||||||
{menuItems.map((i) => (
|
{menuItems.map((i) => (
|
||||||
<ListboxItem key={key(i.url)} startContent={i.icon} href={link(i.url)}
|
<ListboxItem key={key(i.url)} startContent={i.icon} href={link(i.url)}
|
||||||
onPress={() => setSelectedItem(i.url)}
|
onPress={() => setSelectedItem(i.url)}
|
||||||
|
|||||||
@@ -52,11 +52,11 @@ export const routes = protectRoutes([
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: 'libraries',
|
path: 'libraries',
|
||||||
element: <LibraryManagement/>,
|
element: <LibraryManagement/>
|
||||||
children: [{
|
},
|
||||||
path: 'library/:libraryId',
|
{
|
||||||
|
path: 'libraries/library/:libraryId',
|
||||||
element: <LibraryManagementView/>
|
element: <LibraryManagementView/>
|
||||||
}]
|
|
||||||
},
|
},
|
||||||
{path: 'users', element: <UserManagement/>},
|
{path: 'users', element: <UserManagement/>},
|
||||||
{path: 'sso', element: <SsoManagement/>},
|
{path: 'sso', element: <SsoManagement/>},
|
||||||
|
|||||||
@@ -137,5 +137,55 @@ export async function randomGamesFromLibrary(library: LibraryDto, count?: number
|
|||||||
return games
|
return games
|
||||||
.sort((a: GameDto, b: GameDto) => a.id - b.id)
|
.sort((a: GameDto, b: GameDto) => a.id - b.id)
|
||||||
.sort(() => rand.next() - 0.5)
|
.sort(() => rand.next() - 0.5)
|
||||||
|
.filter(g => g.imageIds && g.imageIds.length > 0)
|
||||||
.slice(0, count ?? games.length);
|
.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;
|
export default AdministrationView;
|
||||||
@@ -22,7 +22,7 @@ export default function GameView() {
|
|||||||
label: provider.name,
|
label: provider.name,
|
||||||
description: provider.shortDescription ?? provider.description,
|
description: provider.shortDescription ?? provider.description,
|
||||||
action: () => {
|
action: () => {
|
||||||
if (gameId) DownloadEndpoint.downloadGame(parseInt(gameId!), provider.key);
|
if (gameId) DownloadEndpoint.downloadGame(parseInt(gameId), provider.key);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return acc;
|
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() {
|
export default function LibraryManagementView() {
|
||||||
const {libraryId} = useParams();
|
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;
|
export default ProfileView;
|
||||||
@@ -18,6 +18,8 @@ class LibraryEndpoint(
|
|||||||
return libraryService.getAllLibraries()
|
return libraryService.getAllLibraries()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getById(libraryId: Long): LibraryDto = libraryService.getById(libraryId)
|
||||||
|
|
||||||
fun getGamesInLibrary(libraryId: Long): Collection<GameDto> {
|
fun getGamesInLibrary(libraryId: Long): Collection<GameDto> {
|
||||||
return libraryService.getGamesInLibrary(libraryId)
|
return libraryService.getGamesInLibrary(libraryId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,19 @@ class LibraryService(
|
|||||||
return entities.map { toDto(it) }
|
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.
|
* Deletes a library from the repository.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user