Move package "de.grimsi.gameyfin" to "org.gameyfin"

This commit is contained in:
grimsi
2025-06-14 19:23:12 +02:00
parent be0ba28c54
commit d3d46b6b01
328 changed files with 710 additions and 678 deletions
@@ -0,0 +1,117 @@
import React, {useEffect, useState} from "react";
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Select,
SelectedItems,
Selection,
SelectItem
} from "@heroui/react";
import {UserEndpoint} from "Frontend/generated/endpoints";
import UserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/UserInfoDto";
import RoleChip from "Frontend/components/general/RoleChip";
import RoleAssignmentResult from "Frontend/generated/org/gameyfin/app/users/enums/RoleAssignmentResult";
interface AssignRolesModalProps {
isOpen: boolean;
onOpenChange: () => void;
user: UserInfoDto;
}
interface Role {
id: string;
}
export default function AssignRolesModal({isOpen, onOpenChange, user}: AssignRolesModalProps) {
const [availableRoles, setAvailableRoles] = useState<Role[]>([]);
const [selectedRole, setSelectedRole] = useState<Selection>();
const [error, setError] = useState<string>();
useEffect(() => {
setSelectedRole(rolesToSelection(user.roles));
UserEndpoint.getRolesBelow().then((availableRoles) => {
setAvailableRoles(availableRoles.map((role) => ({id: role.toString()})));
});
}, []);
function rolesToSelection(roles: Array<string>): Selection {
return new Set(roles.map((role) => role.toString()));
}
async function assignRoles() {
if (!selectedRole) return;
let selectedRolesArray = Array.from(selectedRole).map((role) => role.toString());
let result = await UserEndpoint.assignRoles(user.username, selectedRolesArray);
switch (result) {
case RoleAssignmentResult.SUCCESS:
window.location.reload();
break;
case RoleAssignmentResult.NO_ROLES_PROVIDED:
setError("Select at least one role");
break;
case RoleAssignmentResult.TARGET_POWER_LEVEL_TOO_HIGH:
setError("Power level of user too high");
break;
case RoleAssignmentResult.ASSIGNED_ROLE_POWER_LEVEL_TOO_HIGH:
setError("Power level of assigned role too high");
break;
default:
setError("An error occurred");
break;
}
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" isDismissable={false}
hideCloseButton={true} size="lg">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">Assign roles to {user.username}</ModalHeader>
<ModalBody className="flex flex-col gap-2">
<Select
items={availableRoles}
selectionMode="single"
disallowEmptySelection={true}
selectedKeys={selectedRole}
onSelectionChange={setSelectedRole}
placeholder="Select roles"
renderValue={(items: SelectedItems<Role>) => {
return (
<div className="flex flex-grow flex-wrap gap-2">
{items.map((item) => (
<RoleChip key={item.key} role={item.textValue as string}/>
))}
</div>
);
}}
>
{(role) => (
<SelectItem key={role.id} textValue={role.id}>
<RoleChip key={role.id} role={role.id}/>
</SelectItem>
)}
</Select>
{error &&
<small className="text-danger">{error}</small>
}
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary" onPress={assignRoles} isDisabled={!selectedRole}>
Assign roles
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,52 @@
import React, {useEffect, useState} from "react";
import {Button, Code, Input, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {UserEndpoint} from "Frontend/generated/endpoints";
import UserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/UserInfoDto";
interface ConfirmUserDeletionModalProps {
isOpen: boolean;
onOpenChange: () => void;
user: UserInfoDto;
}
export default function ConfirmUserDeletionModal({isOpen, onOpenChange, user}: ConfirmUserDeletionModalProps) {
const [confirmUsername, setConfirmUsername] = useState<string>("");
useEffect(() => {
setConfirmUsername("");
}, []);
async function deleteUser() {
await UserEndpoint.deleteUserByName(user.username);
window.location.reload();
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" isDismissable={false}
hideCloseButton={true} size="lg">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">Confirm user deletion</ModalHeader>
<ModalBody>
<p>
Confirm deletion of user <Code>{user.username}</Code> by entering the username
below
</p>
<Input onChange={(e) => setConfirmUsername(e.target.value)}/>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="danger" onPress={deleteUser}
isDisabled={confirmUsername != user.username}>
Confirm deletion
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,112 @@
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {
Accordion,
AccordionItem,
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader
} from "@heroui/react";
import {Form, Formik} from "formik";
import Input from "Frontend/components/general/input/Input";
import React from "react";
import GameUpdateDto from "Frontend/generated/org/gameyfin/app/games/dto/GameUpdateDto";
import {deepDiff} from "Frontend/util/utils";
import {GameEndpoint} from "Frontend/generated/endpoints";
import TextAreaInput from "Frontend/components/general/input/TextAreaInput";
import * as Yup from "yup";
import GameCoverPicker from "Frontend/components/general/input/GameCoverPicker";
import DatePickerInput from "Frontend/components/general/input/DatePickerInput";
import ArrayInput from "Frontend/components/general/input/ArrayInput";
interface EditGameMetadataModalProps {
game: GameDto;
isOpen: boolean;
onOpenChange: () => void;
}
export default function EditGameMetadataModal({game, isOpen, onOpenChange}: EditGameMetadataModalProps) {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="3xl">
<ModalContent>
{(onClose) => {
async function updateGame(values: GameUpdateDto) {
//@ts-ignore
const changed = deepDiff(game, values) as GameUpdateDto;
if (Object.keys(changed).length === 0) return;
changed.id = game.id;
await GameEndpoint.updateGame(changed);
onClose();
}
return (
<Formik initialValues={game}
enableReinitialize={true}
onSubmit={updateGame}
validationSchema={Yup.object({
title: Yup.string().required("Title is required")
})}
>
{(formik: any) => (
<Form>
<ModalHeader className="flex flex-col gap-1">
Update game metadata
</ModalHeader>
<ModalBody>
<div className="flex flex-row gap-8">
{/*@ts-ignore*/}
<GameCoverPicker key="coverUrl" name="coverUrl" game={game}/>
<div className="flex flex-col flex-1">
<Input key="metadata.path" name="metadata.path" label="Path"
isDisabled/>
<Input key="title" name="title" label="Title" isRequired/>
<DatePickerInput key="release" name="release" label="Release"/>
</div>
</div>
<TextAreaInput key="summary" name="summary" label="Summary (HTML)"/>
<TextAreaInput key="comment" name="comment" label="Comment (Markdown)"/>
<Accordion variant="splitted"
itemClasses={{
base: "-mx-2",
content: "max-h-80 overflow-y-auto",
}}>
<AccordionItem key="additional-metadata"
aria-label="Additional Metadata"
title="Additional Metadata">
<ArrayInput key="developers" name="developers" label="Developers"/>
<ArrayInput key="publishers" name="publishers" label="Publishers"/>
<ArrayInput key="genres" name="genres" label="Genres"/>
<ArrayInput key="themes" name="themes" label="Themes"/>
<ArrayInput key="keywords" name="keywords" label="Keywords"/>
<ArrayInput key="features" name="features" label="Features"/>
<ArrayInput key="perspectives" name="perspectives"
label="Perspectives"/>
</AccordionItem>
</Accordion>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button
color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting || !formik.dirty}
type="submit"
>
{formik.isSubmitting ? "" : "Save"}
</Button>
</ModalFooter>
</Form>
)}
</Formik>
)
}}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,110 @@
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {Button, Image, Input, Modal, ModalBody, ModalContent, ModalHeader, ScrollShadow} from "@heroui/react";
import React, {useEffect, useState} from "react";
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
import {GameEndpoint} from "Frontend/generated/endpoints";
import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react";
interface GameCoverPickerModalProps {
game: GameDto;
isOpen: boolean;
onOpenChange: () => void;
setCoverUrl: (url: string) => void;
}
export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}: GameCoverPickerModalProps) {
const [coverUrl, setCoverUrlState] = useState("");
const [searchTerm, setSearchTerm] = useState(game.title);
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
const [isSearching, setIsSearching] = useState(false)
useEffect(() => {
if (isOpen && searchTerm.length > 0 && searchResults.length === 0) {
search();
}
}, [isOpen]);
async function search() {
setIsSearching(true);
const results = await GameEndpoint.getPotentialMatches(searchTerm, false);
let validResults = results.filter(result => result.coverUrl && result.coverUrl.length > 0 && result.coverUrl !== "null");
setSearchResults(validResults);
setIsSearching(false);
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="2xl">
<ModalContent>
{(onClose) => {
return (<>
<ModalHeader>
Enter a URL or search for a cover
</ModalHeader>
<ModalBody className="flex flex-col gap-4">
<div className="flex flex-row gap-2 mb-4">
<Input isClearable
placeholder="Enter a URL"
value={coverUrl}
onValueChange={setCoverUrlState}
onClear={() => setCoverUrlState("")}
/>
<Button isIconOnly onPress={() => {
setCoverUrl(coverUrl);
onClose();
}}>
<ArrowRight/>
</Button>
</div>
<div className="flex flex-row gap-2 mb-4">
<Input placeholder="Search"
value={searchTerm}
onValueChange={setSearchTerm}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await search();
}
}}
/>
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
<MagnifyingGlass/>
</Button>
</div>
{searchResults.length === 0 && !isSearching &&
<p className="text-center">No results found.</p>
}
{searchResults.length === 0 && isSearching &&
<p className="text-center text-foreground/70">Searching...</p>
}
<ScrollShadow
className="grid grid-cols-auto-fill gap-4 h-96 overflow-scroll justify-evenly">
{searchResults.map((result) => (
<div className="relative group w-fit h-fit cursor-pointer"
onClick={() => {
setCoverUrl(result.coverUrl!);
onClose();
}}>
<Image
key={result.id}
alt={result.title}
className="z-0 object-cover aspect-[12/17] group-hover:brightness-50"
src={result.coverUrl!}
radius="none"
height={216}
/>
<div
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
>
<ArrowRight size={46}/>
</div>
</div>
))}
</ScrollShadow>
</ModalBody>
</>)
}}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,65 @@
import React, {useEffect, useState} from "react";
import {addToast, Button, Input, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {RegistrationEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
interface InviteUserModalProps {
isOpen: boolean;
onOpenChange: () => void;
}
export default function InviteUserModal({isOpen, onOpenChange}: InviteUserModalProps) {
const [email, setEmail] = useState<string | null>();
const [error, setError] = useState<string | null>();
useEffect(() => {
setEmail(null);
setError(null);
}, []);
async function inviteUser(onClose: () => void) {
if (!email) return;
if (await UserEndpoint.existsByMail(email)) {
setError("User with this email already exists");
return;
}
try {
await RegistrationEndpoint.createInvitation(email);
addToast({
title: "Invitation sent",
description: "The user will receive an email with further instructions shortly.",
color: "success"
});
onClose();
} catch (e) {
setError("Failed to create invitation");
}
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">Invite a new user</ModalHeader>
<ModalBody>
<p>Enter the email address of the user you want to invite:</p>
<Input errorMessage={error} onChange={(e) => setEmail(e.target.value)} type="email"/>
{error && <small className="text-danger">{error}</small>}
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="success" onPress={() => inviteUser(onClose)}
isDisabled={email === null || email === undefined || email.length < 1}>
Send invitation
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,103 @@
import React, {useState} from "react";
import {addToast, Button, Checkbox, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {Form, Formik} from "formik";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import Input from "Frontend/components/general/input/Input";
import * as Yup from "yup";
import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMappingInput";
interface LibraryCreationModalProps {
libraries: LibraryDto[];
setLibraries: (libraries: LibraryDto[]) => void;
isOpen: boolean;
onOpenChange: () => void;
}
export default function LibraryCreationModal({
libraries,
isOpen,
onOpenChange
}: LibraryCreationModalProps) {
const [scanAfterCreation, setScanAfterCreation] = useState<boolean>(true);
async function createLibrary(library: LibraryDto) {
try {
await LibraryEndpoint.createLibrary(library as LibraryDto, scanAfterCreation);
addToast({
title: "New library created",
description: `Library ${library.name} created!`,
color: "success"
});
} catch (e) {
addToast({
title: "Error creating library",
description: `Library ${library.name} could not be created!`,
color: "warning"
});
throw "Error creating library: " + e;
}
}
return (
<>
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="xl">
<ModalContent>
{(onClose) => (
<Formik initialValues={{name: "", directories: []}}
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")
})}
isInitialValid={false}
onSubmit={async (values: any) => {
await createLibrary(values);
onClose();
}}
>
{(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 className="flex flex-row justify-between">
<Checkbox isSelected={scanAfterCreation} onValueChange={setScanAfterCreation}>Scan
after creation?</Checkbox>
<div className="flex flex-row">
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting}
type="submit"
>
{formik.isSubmitting ? "" : "Add"}
</Button>
</div>
</ModalFooter>
</Form>
}
</Formik>
)}
</ModalContent>
</Modal>
</>
);
}
@@ -0,0 +1,152 @@
import {
Button,
Input,
Modal,
ModalBody,
ModalContent,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
Tooltip
} from "@heroui/react";
import React, {useEffect, useState} from "react";
import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react";
import {GameEndpoint} from "Frontend/generated/endpoints";
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
import PluginIcon from "../plugin/PluginIcon";
interface EditGameMetadataModalProps {
path: string;
libraryId: number;
replaceGameId?: number;
initialSearchTerm: string;
isOpen: boolean;
onOpenChange: () => void;
}
export default function MatchGameModal({
path,
libraryId,
replaceGameId,
initialSearchTerm,
isOpen,
onOpenChange
}: EditGameMetadataModalProps) {
const [searchTerm, setSearchTerm] = useState("");
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [isMatching, setIsMatching] = useState<string | null>(null);
useEffect(() => {
setSearchTerm(initialSearchTerm);
setSearchResults([]);
}, [isOpen]);
async function matchGame(result: GameSearchResultDto) {
await GameEndpoint.matchManually(result.originalIds, path, libraryId, replaceGameId);
}
async function search() {
setIsSearching(true);
const results = await GameEndpoint.getPotentialMatches(searchTerm, true);
setSearchResults(results);
setIsSearching(false);
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}
hideCloseButton
isDismissable={!isSearching && !isMatching}
isKeyboardDismissDisabled={!isSearching && !isMatching}
backdrop="opaque" size="5xl">
<ModalContent>
{(onClose) => (
<ModalBody className="my-4">
<div className="flex flex-col items-center">
<pre>{path}</pre>
</div>
<div className="flex flex-row gap-2 mb-4">
<Input value={searchTerm}
onValueChange={setSearchTerm}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await search();
}
}}
/>
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
<MagnifyingGlass/>
</Button>
</div>
<div>
<Table removeWrapper isStriped isHeaderSticky
classNames={{
base: "h-80 overflow-scroll",
}}
>
<TableHeader>
<TableColumn>Title & Release</TableColumn>
<TableColumn>Developer(s)</TableColumn>
<TableColumn>Publisher(s)</TableColumn>
{/* width={1} keeps the column as far to the right as possible*/}
<TableColumn>Sources</TableColumn>
<TableColumn width={1}> </TableColumn>
</TableHeader>
<TableBody emptyContent="Your filter did not match any games." items={searchResults}>
{(item) => (
<TableRow key={item.id}>
<TableCell>
{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
</TableCell>
<TableCell>
<div className="flex flex-col">
{item.developers ? item.developers.map(
developer => <p>{developer}</p>
) : "unknown"}
</div>
</TableCell>
<TableCell>
<div className="flex flex-col">
{item.publishers ? item.publishers.map(
publisher => <p>{publisher}</p>
) : "unknown"}
</div>
</TableCell>
<TableCell>
<div className="flex flex-row gap-2">
{Object.values(item.originalIds).map(
originalId => <PluginIcon pluginId={originalId.pluginId}/>
)}
</div>
</TableCell>
<TableCell>
<Tooltip content="Pick this result">
<Button isIconOnly size="sm"
isDisabled={isMatching !== null}
isLoading={isMatching === item.id}
onPress={async () => {
setIsMatching(item.id);
await matchGame(item);
setIsMatching(null);
onClose();
}}>
<ArrowRight/>
</Button>
</Tooltip>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</ModalBody>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,76 @@
import React, {useEffect, useState} from "react";
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {Input as NextInput} from "@heroui/input";
import {WarningCircle} from "@phosphor-icons/react";
import {MessageEndpoint, PasswordResetEndpoint} from "Frontend/generated/endpoints";
interface PasswordResetModalProps {
isOpen: boolean;
onOpenChange: () => void;
}
export default function PasswordResetModal({
isOpen,
onOpenChange
}: PasswordResetModalProps) {
const [canResetPassword, setCanResetPassword] = useState(false);
const [resetEmail, setResetEmail] = useState<string>();
useEffect(() => {
MessageEndpoint.isEnabled().then(setCanResetPassword);
}, []);
async function resetPassword() {
if (!resetEmail) return;
await PasswordResetEndpoint.requestPasswordReset(resetEmail);
addToast({
title: "Password reset requested",
description: "If the email address is registered, you will receive a message with further instructions.",
color: "success"
});
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">Request a password reset</ModalHeader>
<ModalBody>
{canResetPassword ?
<NextInput
onChange={(event: any) => {
setResetEmail(event.target.value);
}}
type="email"
placeholder="Email"
/> :
<div className="flex flex-row items-center gap-4 text-warning">
<WarningCircle size={40}/>
<p>
Password self-service is disabled.<br/>
To reset your password please contact your administrator.
</p>
</div>
}
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary"
isDisabled={!canResetPassword}
onPress={async () => {
await resetPassword();
onClose();
}}>
Send request
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,65 @@
import React, {useEffect, useState} from "react";
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Snippet} from "@heroui/react";
import TokenDto from "Frontend/generated/org/gameyfin/app/shared/token/TokenDto";
import {timeUntil} from "Frontend/util/utils";
interface PasswordResetTokenModalProps {
isOpen: boolean;
onOpenChange: () => void;
token: TokenDto;
}
export default function PasswordResetTokenModal({isOpen, onOpenChange, token}: PasswordResetTokenModalProps) {
const [timeUntilExpiry, setTimeUntilExpiry] = useState<string>("");
const timeoutRefresh = setInterval(updateTimeUntilExpiry, 1000);
useEffect(updateTimeUntilExpiry, [token]);
useEffect(() => {
return () => {
clearInterval(timeoutRefresh);
};
}, []);
function passwordResetLink() {
return `${document.baseURI}reset-password?token=${token.secret}`;
}
function updateTimeUntilExpiry() {
if (!token) return;
setTimeUntilExpiry(timeUntil(token.expiresAt as string));
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} isDismissable={false}
backdrop="opaque" size="4xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
The user can reset their password using the following link
</ModalHeader>
<ModalBody>
<Snippet symbol="">{passwordResetLink()}</Snippet>
{
!timeUntilExpiry.endsWith("ago")
? <small className="text-warning">
This link will expire {timeUntilExpiry}
</small>
: <small className="text-danger">
This link has expired {timeUntilExpiry}
</small>
}
</ModalBody>
<ModalFooter>
<Button color="primary" onPress={onClose}>
OK
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,80 @@
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {Form, Formik} from "formik";
import React, {useEffect, useState} from "react";
import Input from "Frontend/components/general/input/Input";
import FileTreeView from "Frontend/components/general/input/FileTreeView";
import DirectoryMappingDto from "Frontend/generated/org/gameyfin/app/libraries/dto/DirectoryMappingDto";
import {ArrowRight} from "@phosphor-icons/react";
interface PathPickerModalProps {
returnSelectedPath: (path: DirectoryMappingDto) => void;
isOpen: boolean;
onOpenChange: () => void;
}
export default function PathPickerModal({returnSelectedPath, isOpen, onOpenChange}: PathPickerModalProps) {
const [internalPath, setInternalPath] = useState("");
const [externalPath, setExternalPath] = useState("");
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="3xl">
<ModalContent>
{(onClose) => (
<Formik initialValues={{internalPath: internalPath, externalPath: externalPath}}
onSubmit={(values: DirectoryMappingDto) => {
returnSelectedPath(values);
setInternalPath("");
setExternalPath("");
onClose();
}}>
{(formik) => {
useEffect(() => {
formik.setFieldValue("internalPath", internalPath);
}, [internalPath]);
return (
<Form>
<ModalHeader className="flex flex-col gap-1">Select a folder</ModalHeader>
<ModalBody>
<div className="flex flex-row gap-2 items-center">
<Input
name="internalPath"
label="Selected directory"
placeholder="&nbsp;"
value={formik.values.internalPath}
isDisabled
required
/>
<ArrowRight className="mb-8"/>
<Input
name="externalPath"
label="External path (optional)"
placeholder="&nbsp;"
value={formik.values.externalPath}
/>
</div>
<div className="h-64 overflow-auto">
<FileTreeView onPathChange={setInternalPath}/>
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting}
type="submit"
>
{formik.isSubmitting ? "" : "Select"}
</Button>
</ModalFooter>
</Form>
);
}}
</Formik>
)}
</ModalContent>
</Modal>
)
}
@@ -0,0 +1,201 @@
import React, {useState} from "react";
import {addToast, Button, Link, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Tooltip} from "@heroui/react";
import {Form, Formik} from "formik";
import PluginLogo from "Frontend/components/general/plugin/PluginLogo";
import Markdown from "react-markdown";
import remarkBreaks from "remark-breaks";
import {PluginEndpoint} from "Frontend/generated/endpoints";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
import {ArrowClockwise} from "@phosphor-icons/react";
import PluginConfigMetadataDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginConfigMetadataDto";
import PluginConfigFormField from "Frontend/components/general/plugin/PluginConfigFormField";
interface PluginDetailsModalProps {
plugin: PluginDto;
isOpen: boolean;
onOpenChange: () => void;
}
enum ValidationState {
UNCHECKED,
VALID,
INVALID,
IN_PROGRESS
}
export default function PluginDetailsModal({plugin, isOpen, onOpenChange}: PluginDetailsModalProps) {
const [configValidated, setConfigValidated] = useState<ValidationState>(ValidationState.UNCHECKED);
async function saveConfig(values: Record<string, string>) {
await PluginEndpoint.updateConfig(plugin.id, values);
addToast({
title: "Configuration saved",
description: `Configuration for plugin ${plugin.name} saved!`,
color: "success"
});
}
function getEffectiveConfig(): Record<string, any> {
const effectiveConfig: Record<string, any> = {};
if (!plugin.configMetadata) return effectiveConfig;
for (const meta of plugin.configMetadata) {
const key = meta.key;
let value = plugin.config?.[key] ?? meta.default;
if (value != null) {
switch (meta.type.toLowerCase()) {
case "float":
case "int":
effectiveConfig[key] = Number(value);
break;
case "boolean":
effectiveConfig[key] = value === true || value === "true";
break;
default:
effectiveConfig[key] = value.toString();
}
}
}
return effectiveConfig;
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
<ModalContent>
{(onClose) => {
async function handleSubmit(values: Record<string, string>): Promise<void> {
await saveConfig(values);
onClose();
}
return (
<Formik initialValues={getEffectiveConfig()}
initialErrors={plugin.configValidation?.errors}
enableReinitialize={true}
onSubmit={handleSubmit}
>
{(formik: any) => (
<Form>
<ModalHeader className="flex flex-col gap-1">
Plugin configuration for {plugin.name}
</ModalHeader>
<ModalBody>
<div className="flex flex-col text-sm">
<div className="flex flex-row items-center gap-8 mb-4">
<PluginLogo plugin={plugin}/>
<table className="text-left table-auto">
<tbody>
{Object.entries({
"Author(s)": plugin.author,
"Version": plugin.version,
"License": plugin.license,
"URL": <Link isExternal
showAnchorIcon
color="foreground"
size="sm"
href={plugin.url}>
{plugin.url}
</Link>,
}).map(([key, value]) => {
if (!value) return;
return (
<tr key={key}>
<td className="text-default-500 w-0 min-w-20">{key}</td>
<td className="flex flex-row gap-1">{value}</td>
</tr>
)
})}
</tbody>
</table>
</div>
<p className="text-default-500">Description</p>
<Markdown
remarkPlugins={[remarkBreaks]}
components={{
a(props) {
return <Link isExternal
showAnchorIcon
color="foreground"
underline="always"
href={props.href}
size="sm">
{props.children}
</Link>
}
}}
>{plugin.description}</Markdown>
</div>
<div className="flex flex-row items-center mt-4 gap-2">
<h4 className="text-l font-bold">Configuration</h4>
{(plugin.configMetadata && plugin.configMetadata.length > 0) && <>
<div className="flex-1"/>
{(() => {
switch (configValidated) {
case ValidationState.VALID:
return <p className="text-small text-success">
Configuration valid
</p>;
case ValidationState.INVALID:
return <p className="text-small text-danger">
Configuration invalid
</p>;
default:
return null;
}
})()}
<Tooltip content="Re-validate configuration" placement="bottom"
color="foreground">
<Button isIconOnly variant="light" size="sm"
isLoading={configValidated === ValidationState.IN_PROGRESS}
onPress={async () => {
setConfigValidated(ValidationState.IN_PROGRESS);
let result = await PluginEndpoint.validateNewConfig(plugin.id, formik.values)
if (result.errors) {
formik.setErrors(result.errors);
setConfigValidated(ValidationState.INVALID);
} else {
setConfigValidated(ValidationState.VALID);
}
setTimeout(() => setConfigValidated(ValidationState.UNCHECKED), 5000);
}}>
<ArrowClockwise/>
</Button>
</Tooltip>
</>}
</div>
{(plugin.configMetadata && plugin.configMetadata.length > 0) ?
plugin.configMetadata.map((entry: PluginConfigMetadataDto) => (
<PluginConfigFormField
key={entry.key}
pluginConfigMetadata={entry}
showErrorUntouched={true}/>
)) : "This plugin has no configuration options."
}
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
{(plugin.configMetadata && plugin.configMetadata?.length > 0) ?
<Button
color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting || !formik.dirty}
type="submit"
>
{formik.isSubmitting ? "" : "Save"}
</Button> : ""}
</ModalFooter>
</Form>
)
}
</Formik>
)
}}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,113 @@
import React from "react";
import {addToast, Button, Chip, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {ListBox, ListBoxItem, useDragAndDrop} from "react-aria-components";
import {CaretUpDown} from "@phosphor-icons/react";
import {useListData} from "@react-stately/data";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
import {PluginEndpoint} from "Frontend/generated/endpoints";
interface PluginPrioritiesModalProps {
plugins: PluginDto[];
isOpen: boolean;
onOpenChange: () => void;
}
export default function PluginPrioritiesModal({plugins, isOpen, onOpenChange}: PluginPrioritiesModalProps) {
const sortedPlugins = useListData({
initialItems: plugins, // Already sorted in parent
getKey: (plugin) => plugin.id
});
let {dragAndDropHooks} = useDragAndDrop({
getItems: (keys) =>
[...keys].map((key) => ({'text/plain': sortedPlugins.getItem(key)!.name})),
onReorder(e) {
if (e.keys.has(e.target.key)) return;
if (e.target.dropPosition === 'before' || e.target.dropPosition === 'on') {
sortedPlugins.moveBefore(e.target.key, e.keys);
} else if (e.target.dropPosition === 'after') {
sortedPlugins.moveAfter(e.target.key, e.keys);
}
// Recalculate priority based on new position (reversed)
sortedPlugins.items.forEach((plugin, index) => {
const reversedPriority = sortedPlugins.items.length - index;
sortedPlugins.update(plugin.id, {...plugin, priority: reversedPriority});
});
}
});
function generatePrioritiesMap(): Record<string, number> {
let map: Record<string, number> = {};
const totalPlugins = sortedPlugins.items.length;
sortedPlugins.items.forEach((plugin, index) => {
map[plugin.id] = totalPlugins - index; // Reverse order
});
return map;
}
async function setPluginPriorities(onClose: () => void) {
try {
const prioritiesMap = generatePrioritiesMap();
await PluginEndpoint.setPluginPriorities(prioritiesMap);
addToast({
title: "Plugin order updated",
description: "Plugin order has been updated successfully.",
color: "success"
});
onClose();
} catch (e) {
addToast({
title: "Error",
description: "An error occurred while updating plugin order.",
color: "warning"
});
}
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<p>Edit plugin order</p>
<p className="text-small font-normal">Plugins higher on the list are preferred</p>
</ModalHeader>
<ModalBody>
<ListBox items={sortedPlugins.items}
dragAndDropHooks={dragAndDropHooks}
className="flex flex-col gap-2">
{(plugin: PluginDto) => (
<ListBoxItem
key={plugin.id}
className="flex flex-row p-2 rounded-lg justify-between items-center bg-foreground/5">
<div className="flex flex-row gap-2 items-center">
<Chip size="sm" color="primary">
{sortedPlugins.items.findIndex(p => p.id === plugin.id) + 1}
</Chip>
<p className="font-normal text-small">{plugin.name}</p>
</div>
<CaretUpDown/>
</ListBoxItem>
)}
</ListBox>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="success" onPress={() => setPluginPriorities(onClose)}>
Save
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,111 @@
import React from "react";
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {RegistrationEndpoint} from "Frontend/generated/endpoints";
import UserRegistrationDto from "Frontend/generated/org/gameyfin/app/users/dto/UserRegistrationDto";
import {Form, Formik} from "formik";
import * as Yup from "yup";
import Input from "Frontend/components/general/input/Input";
interface SignUpModalProps {
isOpen: boolean;
onOpenChange: () => void;
}
export default function SignUpModal({
isOpen,
onOpenChange
}: SignUpModalProps) {
async function signUp(registration: UserRegistrationDto, onClose: () => void) {
try {
await RegistrationEndpoint.registerUser({
username: registration.username,
password: registration.password,
email: registration.email
});
onClose();
addToast({
title: "Account created",
description: "You will receive an email with further instructions shortly.",
color: "success"
});
} catch (_) {
addToast({
title: "Registration failed",
description: "An error occurred while registering your account.",
color: "danger"
});
return;
}
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
<ModalContent>
{(onClose) => (
<Formik initialValues={{}}
onSubmit={async (values: any, {setFieldError}) => {
let usernameAvailable = await RegistrationEndpoint.isUsernameAvailable(values.username);
if (!usernameAvailable) {
setFieldError('username', 'Username already taken');
return;
} else {
await signUp(values, onClose);
}
}}
validationSchema={Yup.object({
username: Yup.string()
.required('Required'),
password: Yup.string()
.min(8, 'Password must be at least 8 characters long')
.required('Required'),
email: Yup.string()
.email()
.required('Required'),
passwordRepeat: Yup.string()
.equals([Yup.ref('password')], 'Passwords do not match')
.required('Required')
})}>
<Form>
<ModalHeader className="flex flex-col gap-1">Register a new account</ModalHeader>
<ModalBody>
<div className="flex flex-col">
<Input
label="Username"
name="username"
type="text"
/>
<Input
label="E-Mail"
name="email"
type="email"
/>
<Input
label="Password"
name="password"
type="password"
/>
<Input
label="Password (repeat)"
name="passwordRepeat"
type="password"
/>
</div>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary" type="submit">
Create account
</Button>
</ModalFooter>
</Form>
</Formik>
)}
</ModalContent>
</Modal>
);
}