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
Binary file not shown.
+64
View File
@@ -0,0 +1,64 @@
import {Outlet, useHref, useNavigate} from 'react-router';
import "./main.css";
import "Frontend/util/custom-validators";
import {HeroUIProvider} from "@heroui/react";
import {ThemeProvider as NextThemesProvider} from "next-themes";
import {themeNames} from "Frontend/theming/themes";
import {AuthProvider, useAuth} from "Frontend/util/auth";
import {IconContext, X} from "@phosphor-icons/react";
import client from "Frontend/generated/connect-client.default";
import {ErrorHandlingMiddleware} from "Frontend/util/middleware";
import {initializeLibraryState} from "Frontend/state/LibraryState";
import {initializeGameState} from "Frontend/state/GameState";
import {initializeScanState} from "Frontend/state/ScanState";
import {ToastProvider} from "@heroui/toast";
import {initializePluginState} from "Frontend/state/PluginState";
import {isAdmin} from "Frontend/util/utils";
export default function App() {
client.middlewares = [ErrorHandlingMiddleware];
const navigate = useNavigate();
return (
<HeroUIProvider className="size-full" navigate={navigate} useHref={useHref}>
<NextThemesProvider attribute="class" themes={themeNames()} defaultTheme="gameyfin-violet-dark">
<AuthProvider>
<ViewWithAuth/>
</AuthProvider>
</NextThemesProvider>
</HeroUIProvider>
);
}
function ViewWithAuth() {
const auth = useAuth();
initializeLibraryState();
initializeGameState();
if (isAdmin(auth)) {
initializeScanState();
initializePluginState();
}
return <>
<IconContext.Provider value={{size: 20}}>
<Outlet/>
<ToastProvider
toastProps={{
shouldShowTimeoutProgress: true,
radius: "sm",
variant: "flat",
hideIcon: true,
closeIcon: <X/>,
classNames: {
closeButton: "opacity-100 absolute right-4 top-1/2 -translate-y-1/2",
progressTrack: "h-1",
}
}}
toastOffset={64}
/>
</IconContext.Provider>
</>;
}
@@ -0,0 +1,85 @@
import {useAuth} from "Frontend/util/auth";
import {GearFine, Question, SignOut, User} from "@phosphor-icons/react";
import {Dropdown, DropdownItem, DropdownMenu, DropdownTrigger} from "@heroui/react";
import {useNavigate} from "react-router";
import {ConfigEndpoint} from "Frontend/generated/endpoints";
import Avatar from "Frontend/components/general/Avatar";
import {CollectionElement} from "@react-types/shared";
import {isAdmin} from "Frontend/util/utils";
export default function ProfileMenu() {
const auth = useAuth();
const navigate = useNavigate();
async function logout() {
if (auth.state.user?.managedBySso) {
window.location.href = (await ConfigEndpoint.getLogoutUrl()) || "/";
} else {
await auth.logout();
}
}
const profileMenuItems = [
{
label: "My Profile",
icon: <User/>,
onClick: () => navigate("/settings/profile")
},
{
label: "Administration",
icon: <GearFine/>,
onClick: () => navigate("/administration/libraries"),
showIf: isAdmin(auth)
},
{
label: "Help",
icon: <Question/>,
onClick: () => window.open("https://gameyfin.org", "_blank")
},
{
label: "Sign Out",
icon: <SignOut/>,
onClick: logout,
color: "primary"
},
];
// @ts-ignore
return (
<Dropdown placement="bottom-end">
<DropdownTrigger>
{/* div is necessary so dropdown menu will appear in the correct place */}
<div>
<Avatar radius="full"
as="button"
className="transition-transform size-8"
classNames={{
base: "gradient-primary",
icon: "text-background/80"
}}
/>
</div>
</DropdownTrigger>
<DropdownMenu disabledKeys={["username"]}>
<DropdownItem key="username" textValue={auth.state.user?.username}>
<p className="font-bold">Signed in as {auth.state.user?.username}</p>
</DropdownItem>
{profileMenuItems.filter(item => item.showIf !== false).map(({label, icon, onClick, color}) => {
return (
<DropdownItem
key={label}
onPress={onClick}
startContent={<div color={color}>{icon}</div>}
/* @ts-ignore */
color={color ? color : ""}
className={`text-${color} hover:bg-primary/20`}
textValue={label}
>
{label}
</DropdownItem>
);
}) as unknown as CollectionElement<object>}
</DropdownMenu>
</Dropdown>
);
}
@@ -0,0 +1,48 @@
import ConfigEntryDto from "Frontend/generated/org/gameyfin/app/config/dto/ConfigEntryDto";
import React from "react";
import Input from "Frontend/components/general/input/Input";
import CheckboxInput from "Frontend/components/general/input/CheckboxInput";
import SelectInput from "Frontend/components/general/input/SelectInput";
import ArrayInput from "Frontend/components/general/input/ArrayInput";
export default function ConfigFormField({configElement, ...props}: any) {
function inputElement(configElement: ConfigEntryDto) {
if (configElement.allowedValues != null && configElement.allowedValues.length > 0) {
return (
<SelectInput label={configElement.description} name={configElement.key}
values={configElement.allowedValues} {...props}/>
);
}
switch (configElement.type.toLowerCase()) {
case "boolean":
return (
<CheckboxInput label={configElement.description} name={configElement.key} {...props}/>
);
case "string":
return (
<Input label={configElement.description} name={configElement.key}
type={props.type && "text"} {...props}/>
);
case "float":
return (
<Input label={configElement.description} name={configElement.key} type="number"
step="0.1" {...props}/>
);
case "int":
return (
<Input label={configElement.description} name={configElement.key} type="number"
step="1" {...props}/>
);
case "array":
return (
<ArrayInput label={configElement.description} name={configElement.key} type="text" {...props}/>
);
default:
return <pre>Unsupported type: {configElement.type} for key {configElement.key}</pre>;
}
}
return inputElement(configElement!);
}
@@ -0,0 +1,102 @@
import React from "react";
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import Section from "Frontend/components/general/Section";
import * as Yup from 'yup';
import {addToast, Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
import {Plus} from "@phosphor-icons/react";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {LibraryOverviewCard} from "Frontend/components/general/cards/LibraryOverviewCard";
import LibraryCreationModal from "Frontend/components/general/modals/LibraryCreationModal";
import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import {useSnapshot} from "valtio/react";
import {libraryState} from "Frontend/state/LibraryState";
function LibraryManagementLayout({getConfig, formik}: any) {
const libraryCreationModal = useDisclosure();
const state = useSnapshot(libraryState);
async function updateLibrary(library: LibraryUpdateDto) {
await LibraryEndpoint.updateLibrary(library);
addToast({
title: "Library updated",
description: `Library ${library.name} has been updated.`,
color: "success"
})
}
async function removeLibrary(library: LibraryDto) {
await LibraryEndpoint.deleteLibrary(library.id);
addToast({
title: "Library removed",
description: `Library ${library.name} has been removed.`,
color: "success"
})
}
return (
<div className="flex flex-col">
<Section title="Permissions"/>
<ConfigFormField configElement={getConfig("library.allow-public-access")} isDisabled/>
<Section title="Scanning"/>
<div className="flex flex-col gap-4">
<ConfigFormField configElement={getConfig("library.scan.enable-filesystem-watcher")} isDisabled/>
<ConfigFormField configElement={getConfig("library.scan.scan-empty-directories")}/>
<div className="flex flex-row gap-4 items-baseline">
<ConfigFormField configElement={getConfig("library.scan.title-match-min-ratio")}/>
</div>
<ConfigFormField configElement={getConfig("library.scan.game-file-extensions")}/>
</div>
<Section title="Metadata"/>
<div className="flex flex-row items-baseline">
<ConfigFormField configElement={getConfig("library.metadata.update.enabled")} isDisabled/>
<ConfigFormField configElement={getConfig("library.metadata.update.schedule")}
isDisabled={!formik.values.library.metadata.update.enabled}/>
</div>
<div className="flex flex-row items-baseline justify-between">
<h2 className="text-xl font-bold mt-8 mb-1">Libraries</h2>
<Tooltip content="Add new library">
<Button isIconOnly variant="flat" onPress={libraryCreationModal.onOpen}>
<Plus/>
</Button>
</Tooltip>
</div>
<Divider className="mb-4"/>
{state.sorted.length > 0 ?
// Aspect ratio of cover = 12/17 -> 5 covers = 60/17 -> 353px * 100px
<div id="library-cards" className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(353px,1fr))]">
{state.sorted.map((library) =>
// @ts-ignore
<LibraryOverviewCard library={library} updateLibrary={updateLibrary}
removeLibrary={removeLibrary} key={library.name}/>
)}
</div> :
<p className="mt-4 text-center text-default-500">No libraries found</p>
}
<LibraryCreationModal
// @ts-ignore
libraries={state.sorted}
isOpen={libraryCreationModal.isOpen}
onOpenChange={libraryCreationModal.onOpenChange}
/>
</div>
);
}
const validationSchema = Yup.object({
library: Yup.object({
metadata: Yup.object({
update: Yup.object({
// @ts-ignore
schedule: Yup.string().cron()
})
})
})
});
export const LibraryManagement = withConfigPage(LibraryManagementLayout, "Library Management", validationSchema);
@@ -0,0 +1,97 @@
import React, {useEffect, useRef, useState} from "react";
import {LogEndpoint} from "Frontend/generated/endpoints";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import * as Yup from 'yup';
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import {addToast, Button, Code, Divider, Tooltip} from "@heroui/react";
import {ArrowUDownLeft, SortAscending} from "@phosphor-icons/react";
function LogManagementLayout({getConfig, formik}: any) {
const [logEntries, setLogEntries] = useState<string[]>([]);
const [autoScroll, setAutoScroll] = useState(true);
const [softWrap, setSoftWrap] = useState(false);
const logEndRef = useRef<null | HTMLDivElement>(null);
useEffect(() => {
const sub = LogEndpoint.getApplicationLogs().onNext((newEntry: string | undefined) =>
setLogEntries((currentEntries) => [...currentEntries, newEntry as string])
);
return () => sub.cancel();
}, []);
useEffect(() => {
if (formik.isSubmitting == false && formik.submitCount > 0) {
LogEndpoint.reloadLogConfig()
.catch(() => addToast({
title: "Error",
description: "Failed to apply log configuration",
color: "danger"
}));
}
}, [formik.isSubmitting]);
useEffect(() => {
if (autoScroll) {
scrollToBottom();
}
}, [logEntries, autoScroll, softWrap]);
function scrollToBottom() {
logEndRef.current?.scrollIntoView();
}
return (
<div className="flex flex-col mt-4">
<div className="flex flex-row gap-4">
<ConfigFormField configElement={getConfig("logs.folder")}/>
<ConfigFormField configElement={getConfig("logs.max-history-days")}/>
<ConfigFormField configElement={getConfig("logs.level.gameyfin")}/>
<ConfigFormField configElement={getConfig("logs.level.root")}/>
</div>
<div className="flex flex-col">
<div className="flex flex-row flex-grow justify-between items-baseline">
<h2 className={"text-xl font-bold mt-8 mb-1"}>Application logs</h2>
<div className="flex flex-row gap-1">
<Tooltip content="Soft-wrap" placement="bottom">
<Button isIconOnly
onPress={() => setSoftWrap(!softWrap)}
variant={softWrap ? "solid" : "ghost"}
>
<ArrowUDownLeft/>
</Button>
</Tooltip>
<Tooltip content="Auto-scroll" placement="bottom">
<Button isIconOnly
onPress={() => setAutoScroll(!autoScroll)}
variant={autoScroll ? "solid" : "ghost"}
>
<SortAscending/>
</Button>
</Tooltip>
</div>
</div>
<Divider className="mb-4"/>
</div>
<Code size="sm" radius="none"
className={`flex flex-col h-[50vh] max-h-[50vh] text-sm overflow-auto ${softWrap ? "whitespace-normal break-words" : "whitespace-nowrap"}`}>
{logEntries.map((entry, index) => <p key={index}>{entry}</p>)}
<div ref={logEndRef}/>
</Code>
</div>
);
}
const validationSchema = Yup.object({
logs: Yup.object({
folder: Yup.string().required("Required"),
"max-history-days": Yup.number().required("Required"),
level: Yup.object({
gameyfin: Yup.string().required("Required"),
root: Yup.string().required("Required")
})
})
});
export const LogManagement = withConfigPage(LogManagementLayout, "Logging", validationSchema);
@@ -0,0 +1,129 @@
import React, {useEffect, useState} from "react";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import Section from "Frontend/components/general/Section";
import {addToast, Button, Card, Tooltip, useDisclosure} from "@heroui/react";
import {MessageEndpoint, MessageTemplateEndpoint} from "Frontend/generated/endpoints";
import {PaperPlaneRight, Pencil} from "@phosphor-icons/react";
import MessageTemplateDto from "Frontend/generated/org/gameyfin/app/messages/templates/MessageTemplateDto";
import SendTestNotificationModal from "Frontend/components/administration/messages/SendTestNotificationModal";
import EditTemplateModal from "Frontend/components/administration/messages/EditTemplateModel";
function MessageManagementLayout({getConfig, formik}: any) {
const editorModal = useDisclosure();
const testNotificationModal = useDisclosure();
const [availableTemplates, setAvailableTemplates] = useState<MessageTemplateDto[]>([]);
const [selectedTemplate, setSelectedTemplate] = useState<MessageTemplateDto>();
useEffect(() => {
MessageTemplateEndpoint.getAll().then((response: any) => {
setAvailableTemplates(response as MessageTemplateDto[]);
});
}, []);
async function verifyCredentials(provider: string) {
const credentials: Record<string, any> = {
host: formik.values.messages.providers.email.host,
port: formik.values.messages.providers.email.port,
username: formik.values.messages.providers.email.username,
password: formik.values.messages.providers.email.password
}
const areCredentialsValid = await MessageEndpoint.verifyCredentials(provider, credentials);
if (areCredentialsValid) {
addToast({
title: "Credentials are valid",
color: "success"
});
} else {
addToast({
title: "Credentials are invalid",
color: "warning"
});
}
}
async function openEditor(template: MessageTemplateDto) {
setSelectedTemplate(template);
editorModal.onOpen();
}
function openTestNotification(template: MessageTemplateDto) {
setSelectedTemplate(template);
testNotificationModal.onOpen();
}
return (
<div className="flex flex-col">
<div className="flex flex-row">
<div className="flex flex-col flex-1">
<div className="flex flex-row gap-8">
<div className="flex flex-col flex-1 h-fit">
<Section title="E-Mail"/>
<ConfigFormField configElement={getConfig("messages.providers.email.enabled")}
className="mb-2"/>
<ConfigFormField configElement={getConfig("messages.providers.email.host")}
isDisabled={!formik.values.messages.providers.email.enabled}/>
<ConfigFormField configElement={getConfig("messages.providers.email.port")}
isDisabled={!formik.values.messages.providers.email.enabled}/>
<ConfigFormField configElement={getConfig("messages.providers.email.username")}
isDisabled={!formik.values.messages.providers.email.enabled}/>
<ConfigFormField configElement={getConfig("messages.providers.email.password")}
type="password"
isDisabled={!formik.values.messages.providers.email.enabled}/>
<Button onPress={() => verifyCredentials("email")}
isDisabled={!(
formik.values.messages.providers.email.enabled &&
formik.values.messages.providers.email.host &&
formik.values.messages.providers.email.port &&
formik.values.messages.providers.email.username)}>Test</Button>
</div>
<div className="flex flex-col flex-1 h-fit">
<Section title="Message Templates"/>
<div className="flex flex-col gap-4">
{availableTemplates.map((template: MessageTemplateDto) =>
<Card className="flex flex-row items-center gap-2 p-4" key={template.key}>
<Tooltip content="Edit template">
<Button isIconOnly
size="sm"
onPress={() => openEditor(template)}
>
<Pencil/>
</Button>
</Tooltip>
<Tooltip content="Send test notification">
<Button isIconOnly
size="sm"
onPress={() => openTestNotification(template)}
isDisabled={!formik.values.messages.providers.email.enabled}
>
<PaperPlaneRight/>
</Button>
</Tooltip>
<p className="text-lg">{template.description}</p>
</Card>
)}
</div>
</div>
</div>
</div>
</div>
<EditTemplateModal
isOpen={editorModal.isOpen}
onOpenChange={editorModal.onOpenChange}
selectedTemplate={selectedTemplate!!}
/>
<SendTestNotificationModal
isOpen={testNotificationModal.isOpen}
onOpenChange={testNotificationModal.onOpenChange}
selectedTemplate={selectedTemplate!!}
/>
</div>
);
}
export const MessageManagement = withConfigPage(MessageManagementLayout, "Messages", "messages");
@@ -0,0 +1,27 @@
import React from "react";
import {PluginManagementSection} from "Frontend/components/general/plugin/PluginManagementSection";
import {pluginState} from "Frontend/state/PluginState";
import {useSnapshot} from "valtio/react";
export default function PluginManagement() {
// Defined manually for now to control the layout (order of categories)
const pluginTypes = ["GameMetadataProvider", "DownloadProvider"];
const state = useSnapshot(pluginState);
return state.isLoaded && (
<div className="flex flex-col">
<div className="flex flex-row flex-grow justify-between mb-8">
<h2 className="text-2xl font-bold">Plugins</h2>
</div>
<div className="flex flex-col gap-8">
{pluginTypes.map(type =>
// @ts-ignore
<PluginManagementSection key={type} type={type} plugins={state.pluginsByType[type]}/>
)}
</div>
</div>
);
}
@@ -0,0 +1,173 @@
import Section from "Frontend/components/general/Section";
import Input from "Frontend/components/general/input/Input";
import {addToast, Button, Input as NextUiInput, Tooltip} from "@heroui/react";
import {Form, Formik} from "formik";
import {ArrowCounterClockwise, Check, Info, Trash} from "@phosphor-icons/react";
import React, {useEffect, useState} from "react";
import {useAuth} from "Frontend/util/auth";
import * as Yup from "yup";
import UserUpdateDto from "Frontend/generated/org/gameyfin/app/users/dto/UserUpdateDto";
import {EmailConfirmationEndpoint, MessageEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import {removeAvatar, uploadAvatar} from "Frontend/endpoints/AvatarEndpoint";
import Avatar from "Frontend/components/general/Avatar";
export default function ProfileManagement() {
const auth = useAuth();
const [avatar, setAvatar] = useState<any>();
const [configSaved, setConfigSaved] = useState(false);
const [messagesEnabled, setMessagesEnabled] = useState(false);
useEffect(() => {
MessageEndpoint.isEnabled().then(setMessagesEnabled);
}, []);
useEffect(() => {
if (configSaved) {
setTimeout(() => setConfigSaved(false), 2000);
}
}, [configSaved])
function onFileSelected(event: any) {
setAvatar(event.target.files[0]);
}
async function handleSubmit(values: any) {
const userUpdate: UserUpdateDto = {
username: values.username,
email: values.email
}
if (values.newPassword.length > 0) {
userUpdate.password = values.newPassword;
}
await UserEndpoint.updateUser(userUpdate);
setConfigSaved(true);
if (values.newPassword.length > 0) {
addToast({
title: "Password changed",
description: "Please log in again",
color: "success"
});
setTimeout(() => {
auth.logout();
}, 500);
}
}
return (
<>
<Formik
initialValues={{
username: auth.state.user?.username,
email: auth.state.user?.email,
newPassword: "",
passwordRepeat: ""
}}
onSubmit={handleSubmit}
validationSchema={Yup.object({
username: Yup.string()
.required('Required'),
newPassword: Yup.string()
.min(8, 'Password must be at least 8 characters long'),
email: Yup.string()
.email()
.required('Required'),
passwordRepeat: Yup.string()
.equals([Yup.ref('newPassword')], 'Passwords do not match')
})}
>
{(formik: { values: any; isSubmitting: any; dirty: boolean; }) => (
<Form>
<div className="flex flex-row flex-grow justify-between mb-8">
<h2 className="text-2xl font-bold">My Profile</h2>
{auth.state.user?.managedBySso &&
<p className="text-warning">Your account is managed externally.</p>}
<div className="flex flex-row items-center gap-4">
{formik.values.newPassword.length > 0 &&
<SmallInfoField icon={Info}
message="You will be logged out of all current sessions"
className="text-foreground/70"
/>
}
<Button
color="primary"
isLoading={formik.isSubmitting}
isDisabled={!formik.dirty || formik.isSubmitting || configSaved || auth.state.user?.managedBySso}
type="submit"
>
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
</Button>
</div>
</div>
<div className="flex flex-row flex-1 justify-between gap-16">
<div className="flex flex-col basis-1/4 mt-8 gap-4">
<div className="flex flex-row justify-center">
<Avatar className="size-40 m-4 flex flex-row"/>
</div>
<div className="flex flex-row gap-2">
<NextUiInput type="file" accept="image/*" onChange={onFileSelected}
isDisabled={auth.state.user?.managedBySso}/>
<Button onPress={() => uploadAvatar(avatar)} isDisabled={avatar == null}
color="success">Upload</Button>
<Tooltip content="Remove your current avatar">
<Button onPress={removeAvatar} isIconOnly color="danger"
isDisabled={auth.state.user?.managedBySso}><Trash/></Button>
</Tooltip>
</div>
</div>
<div className="flex flex-col flex-grow">
<Section title="Personal information"/>
<Input name="username" label="Username" type="text" autocomplete="username"
isDisabled={auth.state.user?.managedBySso}/>
<div className="flex flex-row gap-4">
<Input name="email" label="Email" type="email" autocomplete="email"
isDisabled={auth.state.user?.managedBySso || !messagesEnabled}/>
{(auth.state.user?.emailConfirmed === false && !auth.state.user.managedBySso) &&
<Tooltip content="Resend email confirmation message">
<Button isIconOnly
onPress={() => {
EmailConfirmationEndpoint.resendEmailConfirmation().then(
() => addToast({
title: "Email confirmation message sent",
description: "Please check your inbox",
color: "success"
})
)
}}
isDisabled={!messagesEnabled}
variant="ghost"
className="size-14"
>
<ArrowCounterClockwise size={26}/>
</Button>
</Tooltip>
}
</div>
{!messagesEnabled &&
<div className="flex flex-row gap-2 text-warning -mt-5">
<Info/>
<small>
Email services are disabled. Please contact your administrator.
</small>
</div>
}
<Section title="Security"/>
<Input name="newPassword" label="New Password" type="password"
autocomplete="new-password" isDisabled={auth.state.user?.managedBySso}/>
<Input name="passwordRepeat" label="Repeat password" type="password"
autocomplete="new-password" isDisabled={auth.state.user?.managedBySso}/>
</div>
</div>
</Form>
)}
</Formik>
</>
);
}
@@ -0,0 +1,124 @@
import React, {useEffect} from "react";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import * as Yup from 'yup';
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import Section from "Frontend/components/general/Section";
import {addToast, Button} from "@heroui/react";
import {MagicWand} from "@phosphor-icons/react";
function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
useEffect(() => {
if (formik.dirty) {
setSaveMessage("Gameyfin must be restarted for the changes to take effect");
} else {
setSaveMessage(null);
}
}, [formik.dirty]);
function isAutoPopulateDisabled() {
return !formik.values.sso.oidc.enabled || !formik.values.sso.oidc["issuer-url"];
}
async function autoPopulate() {
let issuerUrl: string = formik.values.sso.oidc["issuer-url"];
if (issuerUrl.endsWith("/")) issuerUrl = issuerUrl.slice(0, -1);
try {
const response = await fetch(issuerUrl + "/.well-known/openid-configuration");
const data = await response.json();
formik.setFieldValue("sso.oidc.authorize-url", data.authorization_endpoint);
formik.setFieldValue("sso.oidc.token-url", data.token_endpoint);
formik.setFieldValue("sso.oidc.userinfo-url", data.userinfo_endpoint);
formik.setFieldValue("sso.oidc.logout-url", data.end_session_endpoint);
formik.setFieldValue("sso.oidc.jwks-url", data.jwks_uri);
} catch (e) {
addToast({
title: "Failed to auto-populate SSO configuration",
color: "warning"
});
}
}
return (
<div className="flex flex-col">
<div className="flex flex-row">
<div className="flex flex-col flex-1">
<Section title="SSO configuration"/>
<ConfigFormField configElement={getConfig("sso.oidc.enabled")}/>
<Section title="SSO user handling"/>
<div className="flex flex-row items-baseline">
<ConfigFormField configElement={getConfig("sso.oidc.auto-register-new-users")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.match-existing-users-by")}
isDisabled={!formik.values.sso.oidc.enabled ||
!formik.values.sso.oidc["auto-register-new-users"]}/>
</div>
<Section title="SSO provider configuration"/>
<ConfigFormField configElement={getConfig("sso.oidc.client-id")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.client-secret")}
type="password"
isDisabled={!formik.values.sso.oidc.enabled}/>
<div className="flex flex-row gap-2">
<ConfigFormField configElement={getConfig("sso.oidc.issuer-url")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<Button
isDisabled={isAutoPopulateDisabled()}
onPress={autoPopulate}
className="h-14"><MagicWand className="min-w-5"/>Auto-populate</Button>
</div>
<ConfigFormField configElement={getConfig("sso.oidc.authorize-url")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.token-url")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.userinfo-url")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.logout-url")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.jwks-url")}
isDisabled={!formik.values.sso.oidc.enabled}/>
</div>
</div>
</div>
);
}
const validationSchema = Yup.object({
sso: Yup.object({
oidc: Yup.object({
enabled: Yup.boolean(),
"auto-register-new-users": Yup.boolean().required(),
"match-existing-users-by": Yup.string().required(),
"client-id": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("Client ID is required") : schema
),
"client-secret": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("Client Secret is required") : schema
),
"issuer-url": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("Issuer URL is required") : schema
),
"authorize-url": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("Authorize URL is required") : schema
),
"token-url": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("Token URL is required") : schema
),
"userinfo-url": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("Userinfo URL is required") : schema
),
"logout-url": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("Logout URL is required") : schema
),
"jwks-url": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("JWKS URL is required") : schema
)
})
})
});
export const SsoManagement = withConfigPage(SsoManagementLayout, "Single Sign-On", validationSchema);
@@ -0,0 +1,29 @@
import React, {useEffect} from "react";
import {SystemEndpoint} from "Frontend/generated/endpoints";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import {Button} from "@heroui/react";
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import Section from "Frontend/components/general/Section";
function SystemManagementLayout({getConfig, formik, setSaveMessage}: any) {
useEffect(() => {
if (formik.dirty && (formik.initialValues.system.cors["allowed-origins"] !== formik.values.system.cors["allowed-origins"])) {
setSaveMessage("Gameyfin must be restarted for the changes to take effect");
} else {
setSaveMessage(null);
}
}, [formik.dirty]);
return (
<div className="flex flex-col mt-4">
<Section title="Security configuration"/>
<ConfigFormField configElement={getConfig("system.cors.allowed-origins")}/>
<Section title="Restart Gameyfin"/>
<Button onPress={() => SystemEndpoint.restart()}>Restart</Button>
</div>
);
}
export const SystemManagement = withConfigPage(SystemManagementLayout, "System");
@@ -0,0 +1,54 @@
import React, {useEffect, useState} from "react";
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import Section from "Frontend/components/general/Section";
import {UserEndpoint} from "Frontend/generated/endpoints";
import UserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/UserInfoDto";
import {UserManagementCard} from "Frontend/components/general/cards/UserManagementCard";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import {Info, UserPlus} from "@phosphor-icons/react";
import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
import InviteUserModal from "Frontend/components/general/modals/InviteUserModal";
function UserManagementLayout({getConfig, formik}: any) {
const inviteUserModal = useDisclosure();
const [users, setUsers] = useState<UserInfoDto[]>([]);
useEffect(() => {
UserEndpoint.getAllUsers().then(
(response) => setUsers(response)
);
}, []);
return (
<div className="flex flex-col flex-grow">
<Section title="Sign-Ups"/>
<div className="flex flex-row">
<ConfigFormField configElement={getConfig("users.sign-ups.allow")}/>
<ConfigFormField configElement={getConfig("users.sign-ups.confirmation-required")}
isDisabled={!formik.values.users["sign-ups"].allow}/>
</div>
<div className="flex flex-row items-baseline justify-between">
<h2 className="text-xl font-bold mt-8 mb-1">Users</h2>
{!getConfig("sso.oidc.auto-register-new-users").value &&
<SmallInfoField className="mb-4 text-warning" icon={Info}
message="Automatic user registration for SSO users is disabled"/>
}
<Tooltip content="Invite new user">
<Button isIconOnly variant="flat" onPress={inviteUserModal.onOpen}>
<UserPlus/>
</Button>
</Tooltip>
</div>
<Divider className="mb-4"/>
<div className="grid grid-cols-300px gap-4">
{users.map((user) => <UserManagementCard user={user} key={user.username}/>)}
</div>
<InviteUserModal isOpen={inviteUserModal.isOpen} onOpenChange={inviteUserModal.onOpenChange}/>
</div>
);
}
export const UserManagement = withConfigPage(UserManagementLayout, "User Management");
@@ -0,0 +1,130 @@
import React, {useEffect, useState} from "react";
import {
addToast,
Button,
Chip,
Link,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea
} from "@heroui/react";
import {MessageTemplateEndpoint} from "Frontend/generated/endpoints";
import MessageTemplateDto from "Frontend/generated/org/gameyfin/app/messages/templates/MessageTemplateDto";
import TemplateType from "Frontend/generated/org/gameyfin/app/messages/templates/TemplateType";
interface EditTemplateModalProps {
isOpen: boolean;
onOpenChange: () => void;
selectedTemplate: MessageTemplateDto | null;
}
export default function EditTemplateModal({isOpen, onOpenChange, selectedTemplate}: EditTemplateModalProps) {
const [templateContent, setTemplateContent] = useState<string>("");
const [defaultPlaceholders, setDefaultPlaceholders] = useState<string[]>([]);
useEffect(() => {
if (!isOpen) return;
MessageTemplateEndpoint.read(selectedTemplate?.key as string, TemplateType.MJML).then((response: any) => {
setTemplateContent(response as string);
});
MessageTemplateEndpoint.getDefaultPlaceholders(TemplateType.MJML).then((response: any) => {
setDefaultPlaceholders(response as string[]);
});
}, [isOpen]);
async function saveTemplate(template: MessageTemplateDto) {
await MessageTemplateEndpoint.save(template.key, TemplateType.MJML, templateContent);
}
function templateContainsAllRequiredPlaceholders(): boolean {
if (!selectedTemplate || !selectedTemplate.availablePlaceholders) return false;
return selectedTemplate.availablePlaceholders
.every((p) => templateContent.includes(`{${p}}`))
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="5xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader
className="flex flex-col gap-1">Edit {selectedTemplate?.name} Template</ModalHeader>
<ModalBody>
<div className="flex flex-row justify-between items-end">
<table cellPadding="4rem">
<tbody>
<tr>
<td>Required placeholders:</td>
<td>
<div className="flex flex-row gap-2">
{selectedTemplate?.availablePlaceholders?.map((placeholder) =>
<Chip radius="sm"
key={placeholder}
color={templateContent.includes(`{${placeholder as string}}`) ? "success" : "danger"}
>{placeholder}</Chip>
)}
</div>
</td>
</tr>
<tr>
<td>Optional placeholders:</td>
<td>
<div className="flex flex-row gap-2">
{defaultPlaceholders.map((placeholder) =>
<Chip radius="sm"
key={placeholder}
color={templateContent.includes(`{${placeholder as string}}`) ? "success" : "default"}
>{placeholder}</Chip>
)}
</div>
</td>
</tr>
</tbody>
</table>
<small className="text-right">Powered by <Link href="https://documentation.mjml.io/"
target="_blank">mjml.io</Link></small>
</div>
<Textarea
size="lg"
autoFocus
disableAutosize
value={templateContent}
onChange={(e) => {
setTemplateContent(e.target.value)
}}
classNames={{
input: "resize-y min-h-[500px]"
}}
/>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary"
isDisabled={!templateContainsAllRequiredPlaceholders()}
onPress={async () => {
if (selectedTemplate) {
await saveTemplate(selectedTemplate);
addToast({
title: "Template saved",
description: "Template has been saved",
color: "success"
});
onClose();
}
}}>
Save
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,76 @@
import React from "react";
import {Form, Formik} from "formik";
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import Input from "Frontend/components/general/input/Input";
import {MessageEndpoint} from "Frontend/generated/endpoints";
import * as Yup from "yup";
import MessageTemplateDto from "Frontend/generated/org/gameyfin/app/messages/templates/MessageTemplateDto";
interface SendTestNotificationModalProps {
isOpen: boolean;
onOpenChange: () => void;
selectedTemplate: MessageTemplateDto;
}
export default function SendTestNotificationModal({
isOpen,
onOpenChange,
selectedTemplate
}: SendTestNotificationModalProps) {
function generateValidationSchema(placeholders: string[]) {
const shape: { [key: string]: Yup.StringSchema } = {};
placeholders.forEach(placeholder => {
shape[placeholder] = Yup.string().required(`Placeholder ${placeholder} is required`);
});
return Yup.object().shape(shape);
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="3xl">
<ModalContent>
{(onClose) => (
<>
<Formik
initialValues={{}}
isInitialValid={false}
onSubmit={async (values) => {
await MessageEndpoint.sendTestNotification(selectedTemplate.key, values);
addToast({
title: "Notification sent",
description: "Test notification to you has been sent",
color: "success"
});
onClose();
}}
validationSchema={generateValidationSchema(selectedTemplate.availablePlaceholders)}
>
{(formik) => (
<Form>
<ModalHeader className="flex flex-col gap-1">
Send {selectedTemplate?.name} Test Message
</ModalHeader>
<ModalBody>
<p className="text-ls font-semibold mb-4">Fill the placeholders of the
template</p>
{selectedTemplate.availablePlaceholders.map((placeholder) =>
<Input key={placeholder} label={placeholder} name={placeholder}/>
)}
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Close
</Button>
<Button color="primary" type="submit" isDisabled={!formik.isValid}>
Send
</Button>
</ModalFooter>
</Form>
)}
</Formik>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,136 @@
import React, {useEffect, useState} from "react";
import {ConfigEndpoint} from "Frontend/generated/endpoints";
import ConfigEntryDto from "Frontend/generated/org/gameyfin/app/config/dto/ConfigEntryDto";
import {Form, Formik} from "formik";
import {Button, Skeleton} from "@heroui/react";
import {Check, Info} from "@phosphor-icons/react";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import {configState, initializeConfigState, NestedConfig} from "Frontend/state/ConfigState";
import {useSnapshot} from "valtio/react";
export default function withConfigPage(WrappedComponent: React.ComponentType<any>, title: String, validationSchema?: any) {
return function ConfigPage(props: any) {
const [configSaved, setConfigSaved] = useState(false);
const [saveMessage, setSaveMessage] = useState<string>();
const state = useSnapshot(configState);
useEffect(() => {
initializeConfigState();
}, []);
useEffect(() => {
if (configSaved) {
setTimeout(() => setConfigSaved(false), 2000);
}
}, [configSaved])
async function handleSubmit(values: NestedConfig): Promise<void> {
const changed = getChangedValues(state.config, values);
await ConfigEndpoint.update({updates: changed});
setConfigSaved(true);
}
function getConfig(key: string): ConfigEntryDto | undefined {
// @ts-ignore
return state.state[key];
}
function getChangedValues(initial: NestedConfig, current: NestedConfig): Record<string, any> {
const flatten = (obj: NestedConfig, parentKey = ''): Record<string, any> => {
let result: Record<string, any> = {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const newKey = parentKey ? `${parentKey}.${key}` : key;
if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
Object.assign(result, flatten(obj[key], newKey));
} else {
result[newKey] = obj[key];
}
}
}
return result;
};
const arraysEqual = (a: any[], b: any[]): boolean => {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (Array.isArray(a[i]) && Array.isArray(b[i])) {
if (!arraysEqual(a[i], b[i])) return false;
} else if (a[i] !== b[i]) {
return false;
}
}
return true;
};
const flatInitial = flatten(initial);
const flatCurrent = flatten(current);
const changed: Record<string, any> = {};
for (const key in flatCurrent) {
const valA = flatCurrent[key];
const valB = flatInitial[key];
if (Array.isArray(valA) && Array.isArray(valB)) {
if (!arraysEqual(valA, valB)) {
changed[key] = valA;
}
} else if (valA !== valB) {
changed[key] = valA;
}
}
return changed;
}
return (
<>
{state.isLoaded ?
<Formik
initialValues={state.config}
onSubmit={handleSubmit}
validationSchema={validationSchema}
enableReinitialize={true}
>
{(formik) => (
<Form>
<div className="flex flex-row flex-grow justify-between">
<h1 className="text-2xl font-bold">{title}</h1>
<div className="flex flex-row items-center gap-4">
{saveMessage && <SmallInfoField icon={Info}
message={saveMessage}
className="text-warning"/>}
<Button
color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting || configSaved || !formik.dirty}
type="submit"
>
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
</Button>
</div>
</div>
<WrappedComponent {...props}
getConfig={getConfig}
formik={formik}
setSaveMessage={setSaveMessage}/>
</Form>
)}
</Formik> :
[...Array(4)].map((_e, i) =>
<div className="flex flex-col flex-grow gap-8 mb-12" key={i}>
<Skeleton className="h-10 w-full rounded-md"/>
<Skeleton className="h-12 flex w-1/3 rounded-md"/>
<div className="flex flex-row gap-8">
<Skeleton className="h-12 flex w-1/3 rounded-md"/>
<Skeleton className="h-12 flex w-1/3 rounded-md"/>
</div>
</div>
)
}
</>
);
}
}
@@ -0,0 +1,27 @@
import {useAuth} from "Frontend/util/auth";
import {Avatar as NextUiAvatar} from "@heroui/react";
// @ts-ignore
const Avatar = ({...props}) => {
const auth = useAuth();
const username = getUsername();
function getUsername() {
if (props.username === undefined || props.username === null || props.username == "") {
return auth.state.user?.username;
}
return props.username;
}
// TODO: Check if avatar can be loaded from SSO
return (
<NextUiAvatar
showFallback
src={`/images/avatar?username=${username}`}
{...props}
/>
);
}
export default Avatar;
@@ -0,0 +1,32 @@
import {
Alien,
CastleTurret,
GameController,
Ghost,
Joystick,
Lego,
Skull,
SoccerBall,
Strategy,
Sword,
TreasureChest,
Trophy
} from "@phosphor-icons/react";
import React from "react";
export default function IconBackgroundPattern() {
return <div className="absolute w-full h-full opacity-50">
<GameController size={32} className="absolute fill-primary top-[10%] left-[10%] rotate-[350deg]"/>
<SoccerBall size={34} className="absolute fill-primary top-[50%] left-[35%] rotate-[60deg]"/>
<Joystick size={40} className="absolute top-[30%] left-[50%] rotate-[90deg]"/>
<Strategy size={36} className="absolute fill-primary top-[50%] left-[70%] rotate-[30deg]"/>
<Sword size={28} className="absolute top-[70%] left-[10%] rotate-[60deg]"/>
<Alien size={34} className="absolute fill-primary top-[10%] left-[85%] rotate-[15deg]"/>
<CastleTurret size={30} className="absolute top-[5%] left-[40%] rotate-[320deg]"/>
<Ghost size={38} className="absolute fill-primary top-[40%] left-[5%] rotate-[300deg]"/>
<Skull size={32} className="absolute top-[80%] left-[30%] rotate-[90deg]"/>
<Trophy size={36} className="absolute fill-primary top-[10%] left-[60%] rotate-[45deg]"/>
<Lego size={28} className="absolute top-[30%] left-[20%] rotate-[30deg]"/>
<TreasureChest size={40} className="absolute top-[70%] left-[50%] rotate-[75deg]"/>
</div>
}
@@ -0,0 +1,10 @@
import {Chip} from "@heroui/react";
import {roleToColor, roleToRoleName} from "Frontend/util/utils";
export default function RoleChip({role}: { role: string }) {
return (
<Chip key={role} size="sm" radius="sm" className={`text-xs bg-${roleToColor(role)}-500`}>
{roleToRoleName(role)}
</Chip>
);
}
@@ -0,0 +1,111 @@
import {
Button,
Divider,
Link,
Popover,
PopoverContent,
PopoverTrigger,
Progress,
ScrollShadow,
Spinner
} from "@heroui/react";
import {useSnapshot} from "valtio/react";
import {scanState} from "Frontend/state/ScanState";
import LibraryScanProgress from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanProgress";
import {libraryState} from "Frontend/state/LibraryState";
import {Target} from "@phosphor-icons/react";
import {timeBetween, timeUntil} from "Frontend/util/utils";
import LibraryScanStatus from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanStatus";
import {useEffect, useState} from "react";
export default function ScanProgressPopover() {
const libraries = useSnapshot(libraryState).state;
const scans = useSnapshot(scanState).sortedByStartTime as LibraryScanProgress[];
const scanInProgress = useSnapshot(scanState).isScanning;
// Add state to track current time and force re-renders
const [currentTime, setCurrentTime] = useState(Date.now());
// Set up an interval to update the time every second
useEffect(() => {
const intervalId = setInterval(() => {
setCurrentTime(Date.now());
}, 1000);
// Clean up the interval when component unmounts
return () => clearInterval(intervalId);
}, []);
return (
<Popover placement="bottom-end" showArrow={true}>
<PopoverTrigger>
<Button isIconOnly variant="light">
{scanInProgress ?
<Spinner size="sm" color="default" variant="spinner"
classNames={{
spinnerBars: "bg-foreground-500",
}}/> :
<Target className="fill-foreground-500"/>
}
</Button>
</PopoverTrigger>
<PopoverContent>
<div className="flex flex-col gap-2 m-2 w-96">
{scans.length === 0 ?
<p className="flex h-12 items-center justify-center text-sm text-default-500">
No scans in progress or in history.
</p> :
<ScrollShadow hideScrollBar className="max-h-96">
{scans.map((scan, index) =>
<div className="flex flex-col">
<div
className="flex flex-row justify-between items-center text-default-500 mb-1">
<p>Scan for library&nbsp;
<Link underline="always"
color="foreground"
size="sm"
href={`/administration/libraries/library/${scan.libraryId}`}>
{libraries[scan.libraryId].name}
</Link>
</p>
{scan.finishedAt ?
<p className="text-default-500">
Finished {timeUntil(scan.finishedAt)}
</p> :
<p className="text-default-500">
Started {timeUntil(scan.startedAt)}
</p>
}
</div>
{scan.status === LibraryScanStatus.IN_PROGRESS ?
scan.currentStep.current && scan.currentStep.total ?
<div className="flex flex-col gap-1">
<p className="text-default-500">
{`${scan.currentStep.description} (${scan.currentStep.current}/${scan.currentStep.total})`}
</p>
<Progress
value={scan.currentStep.current / scan.currentStep.total * 100}
size="sm"/>
</div> :
<div className="flex flex-col gap-1">
<p className="text-default-500">{scan.currentStep.description}</p>
<Progress isIndeterminate size="sm"/>
</div>
:
<p>
{scan.result?.new} new /&nbsp;
{scan.result?.removed} removed /&nbsp;
{scan.result?.unmatched} unmatched&nbsp;
(in {timeBetween(scan.startedAt, scan.finishedAt!)})
</p>
}
{scans.length > 1 && index < (scans.length - 1) && <Divider className="my-2"/>}
</div>
)}
</ScrollShadow>
}
</div>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,62 @@
import {Autocomplete, AutocompleteItem} from "@heroui/react";
import {CaretRight, MagnifyingGlass} from "@phosphor-icons/react";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {useNavigate} from "react-router";
import {GameCover} from "Frontend/components/general/covers/GameCover";
export default function SearchBar() {
const navigate = useNavigate();
const state = useSnapshot(gameState);
const games = state.recentlyUpdated as GameDto[];
return <Autocomplete
aria-label="Search for games"
classNames={{
selectorButton: "text-default-500",
endContentWrapper: "display-none"
}}
defaultItems={games}
inputProps={{
classNames: {
input: "text-small w-96",
inputWrapper: "h-full font-normal text-default-500 bg-default-400/20 dark:bg-default-500/20"
},
}}
listboxProps={{
hideSelectedIcon: true,
itemClasses: {
base: [
"text-default-500",
"transition-opacity",
"data-[hover=true]:text-foreground",
"dark:data-[hover=true]:bg-default-50",
"data-[pressed=true]:opacity-70",
"data-[hover=true]:bg-default-200",
"data-[selectable=true]:focus:bg-default-100",
"data-[focus-visible=true]:ring-default-500",
],
},
}}
placeholder="Type to search..."
startContent={<MagnifyingGlass/>}
isVirtualized={true}
maxListboxHeight={300}
itemHeight={91} // 75px (cover) + 16px (margin top/bottom) = 91px
>
{(item) => (
<AutocompleteItem key={item.id} textValue={item.title} onPress={() => navigate("/game/" + item.id)}>
<div className="flex flex-row gap-4 items-center">
<GameCover game={item} size={75}/>
<div className="flex flex-col flex-1 gap-2">
<p><b>{item.title}</b> ({item.release && new Date(item.release).getFullYear()})</p>
<p className="text-default-500">{item.developers && [...item.developers].sort().join(" / ")}</p>
</div>
<CaretRight/>
</div>
</AutocompleteItem>
)}
</Autocomplete>
}
@@ -0,0 +1,10 @@
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>
<Divider className="mb-4"/>
</>
);
}
@@ -0,0 +1,12 @@
import React from 'react';
// @ts-ignore
export function SmallInfoField({icon: IconComponent, message, ...props}) {
return (
<div {...props}>
<small className="flex flex-row items-center gap-1">
<IconComponent weight="fill" size={14}/> {message}
</small>
</div>
);
}
@@ -0,0 +1,76 @@
import {Button, Card, Chip, Tooltip} from "@heroui/react";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import React from "react";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {GameCover} from "Frontend/components/general/covers/GameCover";
import {MagnifyingGlass, SlidersHorizontal} from "@phosphor-icons/react";
import ScanType from "Frontend/generated/org/gameyfin/app/libraries/enums/ScanType";
import {useNavigate} from "react-router";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
interface LibraryOverviewCardProps {
library: LibraryDto;
}
export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
const MAX_COVER_COUNT = 5;
const navigate = useNavigate();
const state = useSnapshot(gameState);
const randomGames = getRandomGames();
function getRandomGames() {
const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[];
if (!games) return [];
return games.slice(0, MAX_COVER_COUNT);
}
async function triggerScan() {
await LibraryEndpoint.triggerScan(ScanType.QUICK, [library]);
}
return (
<Card className="flex flex-col justify-between w-[353px]">
<div className="flex flex-1 justify-center items-center">
<div className="flex flex-1 opacity-10 min-h-[100px]">
<IconBackgroundPattern/>
{randomGames.length > 0 &&
<div className="absolute flex flex-row">
{randomGames.map((game) => (
<GameCover game={game} size={100} radius="none" key={game.coverId}/>
))}
</div>
}
</div>
<p className="absolute text-2xl font-bold">{library.name}</p>
<div className="absolute right-0 top-0 flex flex-row">
<Tooltip content="Scan library" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={triggerScan}>
<MagnifyingGlass/>
</Button>
</Tooltip>
<Tooltip content="Configuration" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={() => navigate('library/' + library.id)}>
<SlidersHorizontal/>
</Button>
</Tooltip>
</div>
</div>
{library.stats &&
<div className="grid grid-rows-2 grid-cols-3 justify-items-center items-center p-2 pt-4">
<p>Games</p>
<p>Downloads</p>
<p>Platforms</p>
<p className="font-bold">{library.stats.gamesCount}</p>
<p className="font-bold">{library.stats.downloadedGamesCount}</p>
<Chip size="sm">PC</Chip>
</div>
}
</Card>
);
}
@@ -0,0 +1,180 @@
import {Button, Card, Chip, Tooltip, useDisclosure} from "@heroui/react";
import {
CheckCircle,
IconContext,
PauseCircle,
PlayCircle,
Power,
Question,
QuestionMark,
SealCheck,
SealQuestion,
SealWarning,
SlidersHorizontal,
StopCircle,
WarningCircle,
XCircle
} from "@phosphor-icons/react";
import PluginState from "Frontend/generated/org/pf4j/PluginState";
import React, {ReactNode} from "react";
import PluginDetailsModal from "Frontend/components/general/modals/PluginDetailsModal";
import PluginLogo from "Frontend/components/general/plugin/PluginLogo";
import PluginTrustLevel from "Frontend/generated/org/gameyfin/app/core/plugins/management/PluginTrustLevel";
import {PluginEndpoint} from "Frontend/generated/endpoints";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
import PluginConfigValidationResult
from "Frontend/generated/org/gameyfin/pluginapi/core/config/PluginConfigValidationResult";
import PluginConfigValidationResultType
from "Frontend/generated/org/gameyfin/pluginapi/core/config/PluginConfigValidationResultType";
export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
const pluginDetailsModal = useDisclosure();
function borderColor(state: PluginState | undefined, trustLevel: PluginTrustLevel | undefined): "success" | "warning" | "danger" | "default" {
if (trustLevel === PluginTrustLevel.UNTRUSTED) return "danger";
if (isDisabled(state)) return "warning";
return stateToColor(state);
}
function stateToColor(state: PluginState | undefined): "success" | "warning" | "danger" | "default" {
switch (state) {
case PluginState.STARTED:
return "success";
case PluginState.DISABLED:
return "warning";
case PluginState.FAILED:
case PluginState.STOPPED:
return "danger";
default:
return "default";
}
}
function stateToIcon(state: PluginState | undefined): ReactNode {
switch (state) {
case PluginState.STARTED:
return <PlayCircle/>;
case PluginState.DISABLED:
return <PauseCircle/>;
case PluginState.STOPPED:
case PluginState.FAILED:
return <StopCircle/>;
case PluginState.UNLOADED:
case PluginState.RESOLVED:
return <XCircle/>;
default:
return <QuestionMark/>;
}
}
function configValidationResultToChip(validationResult: PluginConfigValidationResult | undefined): ReactNode {
switch (validationResult?.result) {
case PluginConfigValidationResultType.VALID:
return <Tooltip content="Config valid" placement="bottom" color="foreground">
<Chip size="sm" radius="sm" className="text-xs" color="success">
<CheckCircle/>
</Chip>
</Tooltip>
case PluginConfigValidationResultType.INVALID:
return <Tooltip content="Config invalid" placement="bottom" color="foreground">
<Chip size="sm" radius="sm" className="text-xs" color="danger">
<WarningCircle/>
</Chip>
</Tooltip>;
default:
return <Tooltip content="Config could not be validated" placement="bottom" color="foreground">
<Chip size="sm" radius="sm" className="text-xs">
<Question/>
</Chip>
</Tooltip>
}
}
function trustLevelToBadge(trustLevel: PluginTrustLevel | undefined): React.ReactNode {
switch (trustLevel) {
case PluginTrustLevel.OFFICIAL:
return <Tooltip color="foreground" placement="bottom" content="Official plugin">
<SealCheck className="fill-success"/>
</Tooltip>;
case PluginTrustLevel.BUNDLED:
return <Tooltip color="foreground" placement="bottom" content="Bundled plugin">
<SealCheck/>
</Tooltip>;
case PluginTrustLevel.THIRD_PARTY:
return <Tooltip color="foreground" placement="bottom" content="3rd party plugin">
<SealWarning/>
</Tooltip>;
case PluginTrustLevel.UNTRUSTED:
return <Tooltip color="foreground" placement="bottom" content="Invalid plugin signature">
<SealWarning className="fill-danger"/>
</Tooltip>;
default:
return <Tooltip color="foreground" placement="bottom" content="Unkown verification status">
<SealQuestion/>
</Tooltip>;
}
}
function isDisabled(state: PluginState | undefined): boolean {
return state === PluginState.DISABLED;
}
function togglePluginEnabled() {
if (isDisabled(plugin.state)) {
PluginEndpoint.enablePlugin(plugin.id);
} else {
PluginEndpoint.disablePlugin(plugin.id);
}
}
// @ts-ignore
return (
<>
<Card
className={`flex flex-row justify-between p-2 border-2 border-${borderColor(plugin.state, plugin.trustLevel)}`}>
<div className="absolute right-0 top-0 flex flex-row">
<Tooltip content={`${isDisabled(plugin.state) ? "Enable" : "Disable"} plugin`} placement="bottom"
color="foreground">
<Button isIconOnly
variant="light"
onPress={() => togglePluginEnabled()}
isDisabled={plugin.state == PluginState.UNLOADED || plugin.state == PluginState.RESOLVED}
>
<Power/>
</Button>
</Tooltip>
<Tooltip content="Configuration" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={pluginDetailsModal.onOpen}>
<SlidersHorizontal/>
</Button>
</Tooltip>
</div>
<div className="flex flex-1 flex-col items-center gap-2">
<PluginLogo plugin={plugin}/>
<p className="flex flex-row items-center gap-1 font-semibold">
{plugin.name}
<IconContext.Provider value={{size: 18, weight: "fill"}}>
{trustLevelToBadge(plugin.trustLevel)}
</IconContext.Provider>
</p>
<div className="flex flex-row gap-2">
<Chip size="sm" radius="sm" className="text-xs">{plugin.version}</Chip>
<Chip size="sm" radius="sm" className="text-xs" color={stateToColor(plugin.state)}>
<Tooltip content={`Plugin ${plugin.state?.toLowerCase()}`} placement="bottom"
color="foreground">
{stateToIcon(plugin.state)}
</Tooltip>
</Chip>
{configValidationResultToChip(plugin.configValidation)}
</div>
</div>
</Card>
<PluginDetailsModal plugin={plugin}
isOpen={pluginDetailsModal.isOpen}
onOpenChange={pluginDetailsModal.onOpenChange}
/>
</>
)
}
@@ -0,0 +1,158 @@
import {Card, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosure} from "@heroui/react";
import {DotsThreeVertical} from "@phosphor-icons/react";
import {useAuth} from "Frontend/util/auth";
import {useEffect, useState} from "react";
import {MessageEndpoint, PasswordResetEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
import {AvatarEndpoint} from "Frontend/endpoints/endpoints";
import Avatar from "Frontend/components/general/Avatar";
import ConfirmUserDeletionModal from "Frontend/components/general/modals/ConfirmUserDeletionModal";
import PasswordResetTokenModal from "Frontend/components/general/modals/PasswortResetTokenModal";
import TokenDto from "Frontend/generated/org/gameyfin/app/shared/token/TokenDto";
import UserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/UserInfoDto";
import RoleChip from "Frontend/components/general/RoleChip";
import AssignRolesModal from "Frontend/components/general/modals/AssignRolesModal";
export function UserManagementCard({user}: { user: UserInfoDto }) {
const userDeletionConfirmationModal = useDisclosure();
const passwordResetTokenModal = useDisclosure();
const roleAssignmentModal = useDisclosure();
const [userEnabled, setUserEnabled] = useState(true);
const [disabledKeys, setDisabledKeys] = useState<string[]>([]);
const [dropdownItems, setDropdownItems] = useState<any[]>([]);
const [passwordResetToken, setPasswordResetToken] = useState<TokenDto>();
const auth = useAuth();
useEffect(() => {
setUserEnabled(user.enabled);
let keysToBeDisabled: string[] = [];
MessageEndpoint.isEnabled().then((isEnabled) => {
if (isEnabled) keysToBeDisabled.push("resetPassword");
if (!user.hasAvatar) keysToBeDisabled.push("removeAvatar");
setDisabledKeys(keysToBeDisabled);
});
UserEndpoint.canCurrentUserManage(user.username).then((canManage) => {
if (!canManage) keysToBeDisabled.push("assignRole", "disableUser", "delete");
setDisabledKeys(keysToBeDisabled);
});
}, []);
useEffect(() => {
setDropdownItems(getDropdownItems());
}, [userEnabled]);
async function resetPassword() {
let token = await PasswordResetEndpoint.createPasswordResetTokenForUser(user.username);
if (token === undefined) return;
setPasswordResetToken(token);
passwordResetTokenModal.onOpen();
}
function getDropdownItems() {
let items = [];
if (!user.managedBySso) {
if (!userEnabled) {
items.push(
{
key: "enableUser",
onPress: () => {
UserEndpoint.setUserEnabled(user.username, true).then(() => {
setUserEnabled(true);
})
},
label: "Enable user"
}
);
} else {
items.push(
{
key: "disableUser",
onPress: () => {
UserEndpoint.setUserEnabled(user.username, false).then(() => {
setUserEnabled(false);
})
},
label: "Disable user"
}
);
}
items.push(
{
key: "removeAvatar",
onPress: () => AvatarEndpoint.removeAvatarByName(user.username!),
label: "Remove avatar"
},
{
key: "assignRole",
onPress: roleAssignmentModal.onOpen,
label: "Assign role"
},
{
key: "resetPassword",
onPress: resetPassword,
label: "Reset password"
}
);
}
items.push({
key: "delete",
onPress: userDeletionConfirmationModal.onOpen,
label: "Delete user"
}
);
return items;
}
return (
<>
<Card
className={`flex flex-row justify-between p-2 ${userEnabled ? "" : "bg-warning/25"} ${user.managedBySso ? "text-foreground/50" : ""}`}>
<div className="flex flex-row items-center gap-4">
<Avatar username={user.username}
name={user.username?.charAt(0)}
classNames={{
base: "gradient-primary size-20",
icon: "text-background/80",
name: "text-background/80 text-5xl",
}}/>
<div className="flex flex-col gap-1">
<p className="font-semibold">{user.username}</p>
<p className="text-sm">{user.email}</p>
{user.roles?.map((role) => (
<RoleChip role={role as string}/>
))}
</div>
</div>
<Dropdown placement="bottom-end" size="sm" backdrop="opaque">
<DropdownTrigger>
<DotsThreeVertical cursor="pointer"/>
</DropdownTrigger>
<DropdownMenu aria-label="Static Actions" items={dropdownItems} disabledKeys={disabledKeys}>
{(item) => (
<DropdownItem
key={item.key}
onPress={item.onPress}
color={item.key === "delete" ? "danger" : "default"}
className={item.key === "delete" ? "text-danger" : ""}
>
{item.label}
</DropdownItem>
)}
</DropdownMenu>
</Dropdown>
</Card>
<ConfirmUserDeletionModal isOpen={userDeletionConfirmationModal.isOpen}
onOpenChange={userDeletionConfirmationModal.onOpenChange}
user={user}/>
<PasswordResetTokenModal isOpen={passwordResetTokenModal.isOpen}
onOpenChange={passwordResetTokenModal.onOpenChange}
token={passwordResetToken as TokenDto}/>
<AssignRolesModal isOpen={roleAssignmentModal.isOpen} onOpenChange={roleAssignmentModal.onOpenChange}
user={user}/>
</>
)
}
@@ -0,0 +1,16 @@
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {GameCover} from "Frontend/components/general/covers/GameCover";
interface CoverGridProps {
games: GameDto[];
}
export default function CoverGrid({games}: CoverGridProps) {
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,212px))] gap-4 justify-center">
{games.map((game) => (
<GameCover key={game.id} game={game} interactive={true}/>
))}
</div>
);
}
@@ -0,0 +1,70 @@
import React, {useEffect, useRef, useState} from "react";
import {GameCover} from "Frontend/components/general/covers/GameCover";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {ArrowRight} from "@phosphor-icons/react";
import {useNavigate} from "react-router";
interface CoverRowProps {
games: GameDto[];
title: string;
onPressShowMore: () => void;
}
const aspectRatio = 12 / 17; // aspect ratio of the game cover
const defaultImageHeight = 300; // default height for the image
const defaultImageWidth = aspectRatio * defaultImageHeight; // default width for the image
export function CoverRow({games, title, onPressShowMore}: CoverRowProps) {
const navigate = useNavigate();
const containerRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(games.length);
useEffect(() => {
const calculateVisible = () => {
if (containerRef.current) {
const containerWidth = containerRef.current.offsetWidth;
const maxFit = Math.floor((containerWidth - defaultImageWidth) / defaultImageWidth) + 1;
setVisibleCount(maxFit < games.length ? maxFit : games.length);
}
};
const resizeObserver = new ResizeObserver(calculateVisible);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
calculateVisible(); // initial calculation
return () => resizeObserver.disconnect();
}, [games.length]);
const showMore = visibleCount < games.length;
return (
<div className="flex flex-col mb-4">
<p className="text-2xl font-bold mb-4">{title}</p>
<div className="w-full relative">
<div ref={containerRef} className="flex flex-row gap-2 rounded-md bg-transparent">
{games.slice(0, visibleCount).map((game, index) => (
<GameCover key={index} game={game} radius="sm" interactive={true}/>
))}
</div>
{showMore && (
<div className="flex flex-row items-center justify-end cursor-pointer"
onClick={onPressShowMore}>
<div className="absolute h-full w-1/4 right-0 bottom-0
bg-gradient-to-r from-transparent to-background
transition-all duration-300 ease-in-out hover:opacity-80"/>
<div
className="absolute h-full right-0 bottom-0 flex flex-row items-center gap-2 pointer-events-none">
<p className="text-xl font-semibold">Show more</p>
<ArrowRight weight="bold"/>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,33 @@
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {Image} from "@heroui/react";
import {GameCoverFallback} from "Frontend/components/general/covers/GameCoverFallback";
interface GameCoverProps {
game: GameDto;
size?: number;
radius?: "none" | "sm" | "md" | "lg";
interactive?: boolean;
}
export function GameCover({game, size = 300, radius = "sm", interactive = false}: GameCoverProps) {
const coverContent = Number.isInteger(game.coverId) ? (
<div className={`${interactive ? "rounded-md scale-95 hover:scale-100 shine transition-all" : ""}`}>
<Image
alt={game.title}
className="z-0 object-cover aspect-[12/17]"
src={`images/cover/${game.coverId}`}
radius={radius}
height={size}
fallbackSrc={<GameCoverFallback title={game.title} size={size} radius={radius}/>}
/>
</div>
) : (
<GameCoverFallback title={game.title} size={size} radius={radius} hover={interactive}/>
);
return interactive ? (
<a href={`/game/${game.id}`}>
{coverContent}
</a>
) : coverContent;
}
@@ -0,0 +1,20 @@
import {Card} from "@heroui/react";
interface GameCoverFallbackProps {
title: string;
size?: number;
radius?: "none" | "sm" | "md" | "lg";
hover?: boolean;
}
export function GameCoverFallback({title, size = 300, radius = "sm", hover = false}: GameCoverFallbackProps) {
return (
<Card style={{aspectRatio: "12 /17", height: size, borderRadius: radius}}
radius={radius}
className={hover ? "scale-95 hover:scale-100" : ""}>
<div className="flex flex-col items-center justify-center h-full">
{title}
</div>
</Card>
);
}
@@ -0,0 +1,152 @@
import {Autoplay, Navigation, Pagination} from 'swiper/modules';
import {Swiper, SwiperSlide} from "swiper/react";
import {Card, Image, Modal, ModalContent, useDisclosure} from "@heroui/react";
import ReactPlayer from 'react-player';
import "swiper/css";
import "swiper/css/navigation";
import "swiper/css/pagination";
import "swiper/css/autoplay";
import {useEffect, useState} from "react";
import {CaretLeft, CaretRight, IconContext, Play} from "@phosphor-icons/react";
interface ImageCarouselProps {
imageUrls?: string[];
videosUrls?: string[];
className?: string;
}
interface SlideData {
isActive: boolean;
isVisible: boolean;
isPrev: boolean;
isNext: boolean;
}
export default function ImageCarousel({imageUrls, videosUrls, className}: ImageCarouselProps) {
interface CarouselElement {
type: "image" | "video";
url: string;
}
const DEFAULT_SLIDES_PER_VIEW = 3;
const [elements, setElements] = useState<CarouselElement[]>();
const [selectedImageUrl, setSelectedImageUrl] = useState<string>();
const imagePopup = useDisclosure();
useEffect(() => {
const images = imageUrls?.map((imageUrl) => ({
type: "image" as const,
url: imageUrl
})) || [];
const videos = videosUrls?.map((videoUrl) => ({
type: "video" as const,
url: videoUrl
})) || [];
setElements([...images, ...videos]);
}, [imageUrls, videosUrls])
function showImagePopup(imageUrl: string) {
setSelectedImageUrl(imageUrl);
imagePopup.onOpen();
}
return (
<div className={className}>
{elements && elements.length > 0 &&
<div className="w-full flex flex-col gap-2 items-center">
<div className="w-full flex flex-row items-center">
<IconContext.Provider value={{size: 50}}>
<CaretLeft className="swiper-custom-button-prev cursor-pointer fill-primary"/>
<Swiper
modules={[Pagination, Navigation, Autoplay]}
slidesPerView={DEFAULT_SLIDES_PER_VIEW > elements.length ? elements.length : DEFAULT_SLIDES_PER_VIEW}
pagination={{
clickable: true,
el: ".swiper-custom-pagination"
}}
navigation={{
prevEl: ".swiper-custom-button-prev",
nextEl: ".swiper-custom-button-next"
}}
centeredSlides={true}
loop={true}
spaceBetween={0}
autoplay={{
delay: 10000,
disableOnInteraction: true
}}
className="w-full"
>
{elements && elements.map((e, index) => (
<SwiperSlide key={index} virtualIndex={index}>
{({isActive}: SlideData) => {
if (e.type === "image") {
return (
<Image
src={e.url}
alt={`Game screenshot slide ${index}`}
className={`w-full h-full object-cover aspect-[16/9] cursor-zoom-in ${!isActive ? "scale-90" : ""}`}
onClick={() => showImagePopup(e.url)}
/>
)
}
return (
<Card
className={`w-full h-full aspect-[16/9] ${!isActive ? "scale-90" : ""}`}>
<ReactPlayer
url={e.url}
width="100%"
height="100%"
light={true}
controls={true}
playing={isActive}
playIcon={<Play weight="fill"/>}
/>
</Card>
)
}}
</SwiperSlide>
))}
<ImagePopup imageUrl={selectedImageUrl} isOpen={imagePopup.isOpen}
onOpenChange={imagePopup.onOpenChange}/>
</Swiper>
<CaretRight className="swiper-custom-button-next cursor-pointer fill-primary"/>
</IconContext.Provider>
</div>
<div>
{/* Wrap the pagination in a div because it gets replaced at runtime be SwiperJS and loses all styling */}
<div className="swiper-custom-pagination"/>
</div>
</div>
}
</div>
);
}
function ImagePopup({imageUrl, isOpen, onOpenChange}: {
imageUrl?: string,
isOpen: boolean,
onOpenChange: (isOpen: boolean) => void
}) {
return (imageUrl &&
<Modal isOpen={isOpen} onOpenChange={onOpenChange} hideCloseButton size="full" backdrop="blur">
<ModalContent className="bg-transparent">
{(onClose) => (
<div className="flex flex-grow items-center justify-center cursor-zoom-out"
onClick={onClose}>
<Image
src={imageUrl}
alt="Game screenshot"
className="max-w-[80vw] max-h-[80vh] object-contain"
/>
</div>
)}
</ModalContent>
</Modal>
)
}
@@ -0,0 +1,50 @@
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import React from "react";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
import {Card} from "@heroui/react";
interface LibraryHeaderProps {
library: LibraryDto;
className?: string;
}
export default function LibraryHeader({library, className}: LibraryHeaderProps) {
const MAX_COVER_COUNT = 5;
const state = useSnapshot(gameState);
const randomGames = getRandomGames();
function getRandomGames() {
const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[];
if (!games) return [];
return games.slice(0, MAX_COVER_COUNT);
}
return (
<Card className={`overflow-hidden rounded-lg relative pointer-events-none select-none ${className}`}>
<IconBackgroundPattern/>
<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% / ${MAX_COVER_COUNT - 2})`,
clipPath: 'polygon(15% 0, 100% 0, 85% 100%, 0% 100%)',
}}
>
<img
src={`/images/screenshot/${game.imageIds![0]}`}
alt={`Image ${idx}`}
/>
</div>
))}
</div>
<div className="absolute inset-0 flex items-center justify-center">
<h2 className="text-white text-3xl font-bold">{library.name}</h2>
</div>
</Card>
);
}
@@ -0,0 +1,70 @@
import {FieldArray, useField} from "formik";
import {Button, Chip, Input, Popover, PopoverContent, PopoverTrigger} from "@heroui/react";
import {KeyboardEvent, useState} from "react";
import {Plus} from "@phosphor-icons/react";
// @ts-ignore
const ArrayInput = ({label, ...props}) => {
// @ts-ignore
const [field, meta] = useField(props);
const [newElementValue, setNewElementValue] = useState<string>("");
return (
<FieldArray name={field.name}
render={arrayHelpers => {
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === "Enter" || event.key == "Tab" || event.key === ",") {
event.preventDefault();
newElementValue
.split(",")
.map((value) => value.trim())
.filter((value) => value !== "")
.forEach((value) => arrayHelpers.push(value));
setNewElementValue("");
}
}
return (
<div className="flex flex-col flex-1 gap-2">
<div className="flex flex-row justify-between">
<p>{label}</p>
<small>{field.value.length} {field.value.length == 1 ? "element" : "elements"}</small>
</div>
<div className="flex flex-row flex-wrap gap-2 items-center">
{field.value.map((element: any, index: number) => (
<Chip key={index} onClose={() => arrayHelpers.remove(index)}>
{element}
</Chip>
))}
<Popover placement="bottom" showArrow={true}>
<PopoverTrigger>
<Button isIconOnly size="sm" variant="light" radius="full"><Plus/></Button>
</PopoverTrigger>
<PopoverContent>
<Input
value={newElementValue}
onChange={(e) => setNewElementValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="New element..."
variant="bordered"
/>
</PopoverContent>
</Popover>
</div>
<div className="min-h-6 text-danger">
{meta.touched && meta.error && meta.error.trim().length > 0 && (
meta.error
)}
</div>
</div>
);
}}
/>
);
}
export default ArrayInput;
@@ -0,0 +1,29 @@
import {useField} from "formik";
import {Checkbox, CheckboxGroup} from "@heroui/react";
// @ts-ignore
const CheckboxInput = ({label, ...props}) => {
// @ts-ignore
const [field, meta] = useField(props);
return (
<CheckboxGroup
className="flex flex-row flex-1 items-baseline gap-2"
isInvalid={!!meta.error}
errorMessage={meta.initialError || meta.error}
value={field.value ? [field.name] : []}
>
<Checkbox
className="items-baseline"
{...field}
{...props}
// @ts-ignore
value={field.name}
>
{label}
</Checkbox>
</CheckboxGroup>
);
}
export default CheckboxInput;
@@ -0,0 +1,85 @@
import {useEffect, useState} from "react";
import {
Button,
ButtonGroup,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownTrigger,
SharedSelection
} from "@heroui/react";
import {CaretDown} from "@phosphor-icons/react";
import {UserPreferenceService} from "Frontend/util/user-preference-service";
export interface ComboButtonOption {
label: string;
description: string;
action: () => void;
isDisabled?: boolean;
}
export interface ComboButtonProps {
description?: string;
options: Record<string, ComboButtonOption>;
preferredOptionKey?: string;
}
export default function ComboButton({options, preferredOptionKey, description}: ComboButtonProps) {
const [selectedOption, setSelectedOption] = useState(new Set([Object.keys(options)[0]]));
const selectedOptionValue = Array.from(selectedOption)[0];
useEffect(() => {
if (!preferredOptionKey) return;
UserPreferenceService.get(preferredOptionKey).then((key) => {
if (key && options[key]) {
setSelectedOption(new Set([key]));
} else {
setSelectedOption(new Set([Object.keys(options)[0]]));
}
})
}, []);
async function onSelectionChange(keys: SharedSelection) {
if (!keys.currentKey) return;
if (preferredOptionKey) {
await UserPreferenceService.set(preferredOptionKey, keys.currentKey);
}
setSelectedOption(new Set([keys.currentKey]));
}
return options[selectedOptionValue] && (
<ButtonGroup className="gap-[1px]">
<Button color="primary" className="w-52"
onPress={options[selectedOptionValue].action}>
<div className="flex flex-col items-center">
<p className="font-semibold">{options[selectedOptionValue].label}</p>
<p className="text-xs font-normal opacity-70 ">{description}</p>
</div>
</Button>
<Dropdown placement="bottom-end">
<DropdownTrigger>
<Button isIconOnly color="primary">
<CaretDown/>
</Button>
</DropdownTrigger>
<DropdownMenu
disallowEmptySelection
aria-label="Merge options"
selectedKeys={selectedOption}
selectionMode="single"
onSelectionChange={onSelectionChange}
className="w-60"
>
{Object.entries(options).map(([key, option]) => (
<DropdownItem key={key} description={option.description}>
{option.label}
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
</ButtonGroup>
);
}
@@ -0,0 +1,35 @@
import {useField} from "formik";
import {DatePicker, DateValue} from "@heroui/react";
import {parseDate} from "@internationalized/date";
import {useState} from "react";
// @ts-ignore
export default function DatePickerInput({label, showErrorUntouched = false, ...props}) {
// @ts-ignore
const [field, meta] = useField(props);
const [value, setValue] = useState<DateValue | null>(field.value ? parseDate(field.value) : null);
return (
<DatePicker
className="min-h-20 flex-grow"
showMonthAndYearPickers
fullWidth={false}
{...props}
{...field}
value={value}
onChange={(date) => {
setValue(date);
field.onChange({
target: {
name: field.name,
value: date ? date.toString() : ''
}
});
}}
id={label}
label={label}
isInvalid={(meta.touched || showErrorUntouched) && !!meta.error}
errorMessage={meta.initialError || meta.error}
/>
);
}
@@ -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/org/gameyfin/app/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>
);
}
@@ -0,0 +1,154 @@
import TreeView, {flattenTree, INode, NodeId} from "react-accessible-treeview";
import {File, Folder, FolderOpen, IconContext} from "@phosphor-icons/react";
import {useEffect, useState} from "react";
import {FilesystemEndpoint} from "Frontend/generated/endpoints";
import FileDto from "Frontend/generated/org/gameyfin/app/core/filesystem/FileDto";
import FileType from "Frontend/generated/org/gameyfin/app/core/filesystem/FileType";
import {IFlatMetadata} from "react-accessible-treeview/dist/TreeView/utils";
import OperatingSystemType from "Frontend/generated/org/gameyfin/app/core/filesystem/OperatingSystemType";
interface ITreeNode<M extends IFlatMetadata = IFlatMetadata> {
id?: NodeId;
name: string;
isBranch?: boolean;
children?: ITreeNode<M>[];
metadata?: M;
}
export default function FileTreeView({onPathChange}: { onPathChange: (file: string) => void }) {
const rootNode: INode = {
id: "root",
name: "",
children: [],
parent: null
}
const [hostOSType, setHostOSType] = useState<OperatingSystemType>();
const [fileTree, setFileTree] = useState<ITreeNode>();
const [flattenedFileTree, setFlattenedFileTree] = useState<INode[]>([rootNode]);
useEffect(() => {
FilesystemEndpoint.getHostOperatingSystem().then((response) => {
setHostOSType(response);
})
FilesystemEndpoint.listSubDirectories("").then(
result => {
if (result === undefined) return;
const nodes = fileDtosToTree(result as FileDto[]);
const tree = flattenTree(nodes);
setFileTree(nodes);
setFlattenedFileTree(tree);
}
)
}, []);
function getAbsolutePath(node: INode, path: string = ""): string {
let pathSeparator = "/";
if (hostOSType === OperatingSystemType.WINDOWS) {
pathSeparator = "\\";
if (path.startsWith(pathSeparator)) path = path.substring(1);
}
path = path.replace(`${pathSeparator}${pathSeparator}`, pathSeparator);
if (node.parent === null) {
if (hostOSType === OperatingSystemType.WINDOWS) return path;
return `${pathSeparator}${path}`;
}
const parentNode = flattenedFileTree.find(n => n.id === node.parent);
if (!parentNode) {
throw new Error(`Parent node with id ${node.parent} not found`);
}
return getAbsolutePath(parentNode, `${node.name}${pathSeparator}${path}`);
}
async function onLoadData({element}: { element: INode }) {
const absolutePath = getAbsolutePath(element);
let subDirectories = await FilesystemEndpoint.listSubDirectories(absolutePath);
if (subDirectories === undefined) return;
const newNodes = fileDtosToNodes(subDirectories as FileDto[]);
const updatedTree = updateTreeWithNewNodes(fileTree!!, element.id, newNodes);
setFileTree(updatedTree);
setFlattenedFileTree(flattenTree(updatedTree));
onPathChange(absolutePath);
}
function updateTreeWithNewNodes(tree: ITreeNode, nodeId: NodeId, newNodes: ITreeNode[]): ITreeNode {
if (tree.id === nodeId) {
return {...tree, children: newNodes};
}
if (tree.children) {
return {
...tree,
children: tree.children.map(child => updateTreeWithNewNodes(child, nodeId, newNodes))
};
}
return tree;
}
function fileDtosToTree(fileDtos: FileDto[], parent: (INode | null) = null): ITreeNode {
const nodes = fileDtosToNodes(fileDtos);
if (parent === null) {
return {...rootNode, children: nodes};
}
return {...parent, children: nodes};
}
function fileDtosToNodes(fileDtos: FileDto[]): ITreeNode[] {
return fileDtos.map(fileDto => ({
id: fileDto.hash,
name: fileDto.name || "",
isBranch: fileDto.type === FileType.DIRECTORY,
children: []
}));
}
return (
<div className="flex flex-col flex-1 w-full gap-4 overflow-hidden">
<TreeView
data={flattenedFileTree}
aria-label="directory tree"
onLoadData={onLoadData}
nodeRenderer={({
element,
isBranch,
isExpanded,
isSelected,
getNodeProps,
level,
}) => (
<IconContext.Provider value={{size: 32, weight: "regular"}}>
<div {...getNodeProps()}
className={`
flex flex-row items-center gap-2 w-full
rounded-md cursor-pointer
${isSelected ? 'bg-primary' : 'hover:bg-primary/20'}`
}
style={{paddingLeft: 10 * (level - 1)}}>
{isBranch ? <FolderIcon isOpen={isExpanded}/> : <FileIcon fileName={element.name}/>}
{element.name}
</div>
</IconContext.Provider>
)}
/>
</div>
);
}
function FolderIcon({isOpen}: { isOpen: boolean }) {
return isOpen ? <FolderOpen/> : <Folder/>;
}
function FileIcon({fileName}: { fileName: string }) {
return <File/>;
}
@@ -0,0 +1,45 @@
import {Image, useDisclosure} from "@heroui/react";
import {GameCoverFallback} from "Frontend/components/general/covers/GameCoverFallback";
import React from "react";
import {useField} from "formik";
import {GameCoverPickerModal} from "Frontend/components/general/modals/GameCoverPickerModal";
import {Pencil} from "@phosphor-icons/react";
// @ts-ignore
export default function GameCoverPicker({game, label, showErrorUntouched = false, ...props}) {
// @ts-ignore
const [field] = useField(props);
const gameCoverPickerModal = useDisclosure();
return (<>
<div className="relative group w-fit h-fit cursor-pointer"
onClick={gameCoverPickerModal.onOpenChange}>
<Image
alt={game.title}
className="z-0 object-cover aspect-[12/17] group-hover:brightness-50"
src={field.value ? field.value : `images/cover/${game.coverId}`}
{...props}
{...field}
radius="none"
height={216}
fallbackSrc={<GameCoverFallback title={game.title}
size={216}
radius="none"/>}
/>
<div
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
>
<Pencil size={46}/>
</div>
</div>
<GameCoverPickerModal
game={game}
isOpen={gameCoverPickerModal.isOpen}
onOpenChange={gameCoverPickerModal.onOpenChange}
setCoverUrl={(coverUrl) => field.onChange({target: {name: field.name, value: coverUrl}})}
/>
</>);
}
@@ -0,0 +1,23 @@
import {useField} from "formik";
import {Input as NextUiInput} from "@heroui/react";
// @ts-ignore
const Input = ({label, showErrorUntouched = false, ...props}) => {
// @ts-ignore
const [field, meta] = useField(props);
return (
<NextUiInput
className="min-h-20 flex-grow"
fullWidth={false}
{...props}
{...field}
id={label}
label={label}
isInvalid={(meta.touched || showErrorUntouched) && !!meta.error}
errorMessage={meta.initialError || meta.error}
/>
);
}
export default Input;
@@ -0,0 +1,30 @@
import {useField} from "formik";
import {Select, SelectItem} from "@heroui/react";
// @ts-ignore
const SelectInput = ({label, values, ...props}) => {
// @ts-ignore
const [field, meta] = useField(props);
const items = values.map((v: string) => ({key: v, label: v}));
return (
<div className="min-h-20 flex-grow">
<Select
fullWidth={true}
{...field}
{...props}
label={label}
items={items}
selectedKeys={[field.value]}
isInvalid={!!meta.error}
errorMessage={meta.initialError || meta.error}
disallowEmptySelection
>
{(item: { key: string, label: string }) => <SelectItem>{item.label}</SelectItem>}
</Select>
</div>
);
}
export default SelectInput;
@@ -0,0 +1,21 @@
import {useField} from "formik";
import {Textarea} from "@heroui/react";
// @ts-ignore
export default function TextAreaInput({label, showErrorUntouched = false, ...props}) {
// @ts-ignore
const [field, meta] = useField(props);
return (
<Textarea
className={`flex-grow ${meta.initialError || meta.error ? "" : "mb-6"}`}
fullWidth={false}
{...props}
{...field}
id={label}
label={label}
isInvalid={(meta.touched || showErrorUntouched) && !!meta.error}
errorMessage={meta.initialError || meta.error}
/>
);
}
@@ -0,0 +1,92 @@
import LibraryDto from "Frontend/generated/org/gameyfin/app/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/org/gameyfin/app/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.deleteLibrary(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>;
}
@@ -0,0 +1,233 @@
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {
Button,
Input,
Link,
Pagination,
Select,
SelectItem,
SortDescriptor,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
Tooltip,
useDisclosure
} from "@heroui/react";
import {CheckCircle, MagnifyingGlass, Pencil, Trash} from "@phosphor-icons/react";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import {GameEndpoint} from "Frontend/generated/endpoints";
import GameUpdateDto from "Frontend/generated/org/gameyfin/app/games/dto/GameUpdateDto";
import {useMemo, useState} from "react";
import EditGameMetadataModal from "Frontend/components/general/modals/EditGameMetadataModal";
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
interface LibraryManagementGamesProps {
library: LibraryDto;
}
export default function LibraryManagementGames({library}: LibraryManagementGamesProps) {
const rowsPerPage = 25;
const state = useSnapshot(gameState);
const games = state.gamesByLibraryId[library.id] ? state.gamesByLibraryId[library.id] as GameDto[] : [];
const [searchTerm, setSearchTerm] = useState("");
const [filter, setFilter] = useState<"all" | "confirmed" | "nonConfirmed">("all");
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "title", direction: "ascending"});
const [selectedGame, setSelectedGame] = useState<GameDto>(games[0]);
const editGameModal = useDisclosure();
const matchGameModal = useDisclosure();
const [page, setPage] = useState(1);
const pages = useMemo(() => {
return Math.ceil(getFilteredGames().length / rowsPerPage);
}, [games, filter]);
const filteredItems = useMemo(() => {
return getFilteredGames();
}, [games, filter, searchTerm]);
const sortedItems = useMemo(() => {
return filteredItems.slice().sort((a, b) => {
let cmp: number;
switch (sortDescriptor.column) {
case "title":
cmp = a.title.localeCompare(b.title);
break;
case "addedToLibrary":
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
break;
case "downloadCount":
cmp = a.metadata.downloadCount - b.metadata.downloadCount;
break;
default:
return 0; // No sorting if the column is not recognized
}
if (sortDescriptor.direction === "descending") {
cmp *= -1; // Reverse the comparison if sorting in descending order
}
return cmp;
});
}, [filteredItems, sortDescriptor]);
const pagedItems = useMemo(() => {
const start = (page - 1) * rowsPerPage;
const end = start + rowsPerPage;
return sortedItems.slice(start, end);
}, [page, sortedItems]);
function getFilteredGames() {
let filteredGames = games.filter((game) =>
game.metadata.path!!.toLowerCase().includes(searchTerm.toLowerCase()) ||
game.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
game.publishers?.some(publisher => publisher.toLowerCase().includes(searchTerm.toLowerCase())) ||
game.developers?.some(developer => developer.toLowerCase().includes(searchTerm.toLowerCase()))
)
if (filter === "confirmed") {
return filteredGames.filter(g => g.metadata.matchConfirmed);
}
if (filter === "nonConfirmed") {
return filteredGames.filter(g => !g.metadata.matchConfirmed);
}
return filteredGames;
}
async function toggleMatchConfirmed(game: GameDto) {
await GameEndpoint.updateGame(
{
id: game.id,
metadata: {matchConfirmed: !game.metadata.matchConfirmed}
} as GameUpdateDto
)
}
async function deleteGame(game: GameDto) {
await GameEndpoint.deleteGame(game.id);
}
return selectedGame && <div className="flex flex-col gap-4">
<h1 className="text-2xl font-bold">Manage games in library</h1>
<div className="flex flex-row gap-2 justify-between">
<Input
className="w-96"
isClearable
placeholder="Search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onClear={() => setSearchTerm("")}
/>
<Select
selectedKeys={[filter]}
disallowEmptySelection
onSelectionChange={keys => setFilter(Array.from(keys)[0] as any)}
className="w-64"
>
<SelectItem key="all">Show all</SelectItem>
<SelectItem key="confirmed">Show only confirmed</SelectItem>
<SelectItem key="nonConfirmed">Show only non confirmed</SelectItem>
</Select>
</div>
<Table removeWrapper isStriped
sortDescriptor={sortDescriptor}
onSortChange={setSortDescriptor}
bottomContent={
<div className="flex w-full justify-center sticky">
{pagedItems.length > 0 &&
<Pagination
isCompact
showControls
showShadow
color="primary"
page={page}
total={pages}
onChange={(page) => setPage(page)}
/>}
</div>
}>
<TableHeader>
<TableColumn key="title" allowsSorting>Game</TableColumn>
<TableColumn key="addedToLibrary" allowsSorting>Added to library</TableColumn>
<TableColumn key="downloadCount" allowsSorting>Download count</TableColumn>
<TableColumn>Path</TableColumn>
{/* width={1} keeps the column as far to the right as possible*/}
<TableColumn width={1}>Actions</TableColumn>
</TableHeader>
<TableBody emptyContent="Your filter did not match any games." items={pagedItems}>
{(item) => (
<TableRow key={item.id}>
<TableCell>
<Link href={`/game/${item.id}`}
color="foreground"
className="text-sm"
underline="hover">{item.title} ({item.release !== undefined ? new Date(item.release).getFullYear() : "unknown"})
</Link>
</TableCell>
<TableCell>
{new Date(item.createdAt).toLocaleString()}
</TableCell>
<TableCell>
{item.metadata.downloadCount}
</TableCell>
<TableCell>
{item.metadata.path}
</TableCell>
<TableCell>
<div className="flex flex-row gap-2">
<Button isIconOnly size="sm" onPress={() => toggleMatchConfirmed(item)}>
{item.metadata.matchConfirmed ?
<Tooltip content="Unconfirm match">
<CheckCircle weight="fill" className="fill-success"/>
</Tooltip> :
<Tooltip content="Confirm match">
<CheckCircle/>
</Tooltip>}
</Button>
<Button isIconOnly size="sm" onPress={() => {
setSelectedGame(item);
editGameModal.onOpenChange();
}}>
<Tooltip content="Edit metadata">
<Pencil/>
</Tooltip>
</Button>
<Button isIconOnly size="sm" onPress={() => {
setSelectedGame(item);
matchGameModal.onOpenChange();
}}>
<Tooltip content="Match game">
<MagnifyingGlass/>
</Tooltip>
</Button>
<Button isIconOnly size="sm" color="danger"
onPress={() => deleteGame(item)}>
<Tooltip content="Remove from library">
<Trash/>
</Tooltip>
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<EditGameMetadataModal game={selectedGame}
isOpen={editGameModal.isOpen}
onOpenChange={editGameModal.onOpenChange}/>
<MatchGameModal path={selectedGame.metadata.path!!}
libraryId={library.id}
replaceGameId={selectedGame.id}
initialSearchTerm={selectedGame.title}
isOpen={matchGameModal.isOpen}
onOpenChange={matchGameModal.onOpenChange}/>
</div>;
}
@@ -0,0 +1,148 @@
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import {
Button,
Input,
Pagination,
SortDescriptor,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
Tooltip,
useDisclosure
} from "@heroui/react";
import {MagnifyingGlass, Trash} from "@phosphor-icons/react";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {useMemo, useState} from "react";
import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto";
import {fileNameFromPath, hashCode} from "Frontend/util/utils";
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
interface LibraryManagementUnmatchedPathsProps {
library: LibraryDto;
}
export default function LibraryManagementUnmatchedPaths({library}: LibraryManagementUnmatchedPathsProps) {
const matchGameModal = useDisclosure();
const [page, setPage] = useState(1);
const rowsPerPage = 25;
const [searchTerm, setSearchTerm] = useState("");
const [selectedPath, setSelectedPath] = useState(library.unmatchedPaths ? library.unmatchedPaths[0] : null);
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "path", direction: "ascending"});
const pages = useMemo(() => {
return Math.ceil(getFilteredPaths().length / rowsPerPage);
}, [library.unmatchedPaths, searchTerm]);
const filteredPaths = useMemo(() => {
return library.unmatchedPaths!
.filter((path) => path.toLowerCase().includes(searchTerm.toLowerCase()))
.map((path) => ({key: hashCode(path), path}));
}, [library, searchTerm]);
const sortedPaths = useMemo(() => {
return filteredPaths.slice().sort((a, b) => {
let cmp: number;
switch (sortDescriptor.column) {
case "path":
cmp = a.path.localeCompare(b.path);
break;
default:
cmp = 0;
}
if (sortDescriptor.direction === "descending") {
cmp *= -1;
}
return cmp;
});
}, [filteredPaths, sortDescriptor]);
const pagedPaths = useMemo(() => {
const start = (page - 1) * rowsPerPage;
const end = start + rowsPerPage;
return sortedPaths.slice(start, end);
}, [page, sortedPaths]);
async function deleteUnmatchedPath(unmatchedPath: string) {
const libraryUpdateDto: LibraryUpdateDto = {
id: library.id,
unmatchedPaths: library.unmatchedPaths!.filter((path) => path !== unmatchedPath)
}
await LibraryEndpoint.updateLibrary(libraryUpdateDto);
}
function getFilteredPaths() {
return library.unmatchedPaths!!.filter((path) =>
path.toLowerCase().includes(searchTerm.toLowerCase())
)
}
return <div className="flex flex-col gap-4">
<h1 className="text-2xl font-bold">Manage unmatched paths</h1>
<Input
className="w-96"
isClearable
placeholder="Search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onClear={() => setSearchTerm("")}
/>
<Table removeWrapper isStriped isHeaderSticky
sortDescriptor={sortDescriptor}
onSortChange={setSortDescriptor}
bottomContent={
<div className="flex w-full justify-center">
{pagedPaths.length > 0 &&
<Pagination
isCompact
showControls
showShadow
color="primary"
page={page}
total={pages}
onChange={(page) => setPage(page)}
/>}
</div>
}>
<TableHeader>
<TableColumn key="path" allowsSorting>Path</TableColumn>
<TableColumn width={1}>Actions</TableColumn>
</TableHeader>
<TableBody emptyContent="This library has no unmatched paths." items={pagedPaths}>
{(item) => (
<TableRow key={item.key}>
<TableCell>
{item.path}
</TableCell>
<TableCell>
<div className="flex flex-row gap-2">
<Tooltip content="Match game">
<Button isIconOnly size="sm" onPress={() => {
setSelectedPath(item.path);
matchGameModal.onOpenChange();
}}>
<MagnifyingGlass/>
</Button>
</Tooltip>
<Tooltip content="Remove entry from list">
<Button isIconOnly size="sm" color="danger"
onPress={() => deleteUnmatchedPath(item.path)}><Trash/>
</Button>
</Tooltip>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{selectedPath && <MatchGameModal path={selectedPath}
libraryId={library.id}
initialSearchTerm={fileNameFromPath(selectedPath, false)}
isOpen={matchGameModal.isOpen}
onOpenChange={matchGameModal.onOpenChange}/>
}
</div>;
}
@@ -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>
);
}
@@ -0,0 +1,59 @@
import SelectInput from "Frontend/components/general/input/SelectInput";
import CheckboxInput from "Frontend/components/general/input/CheckboxInput";
import Input from "Frontend/components/general/input/Input";
import React from "react";
import PluginConfigMetadataDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginConfigMetadataDto";
export default function PluginConfigFormField({pluginConfigMetadata, ...props}: any) {
function inputElement(metadata: PluginConfigMetadataDto) {
if (metadata.allowedValues != null && metadata.allowedValues.length > 0) {
return (
<SelectInput label={metadata.label}
name={metadata.key}
values={metadata.allowedValues}
isRequired={metadata.required}
{...props}/>
);
}
switch (metadata.type.toLowerCase()) {
case "boolean":
return (
<CheckboxInput label={metadata.label}
name={metadata.key}
{...props}/>
);
case "string":
return (
<Input label={metadata.label}
name={metadata.key}
type={metadata.secret ? "password" : "text"}
isRequired={metadata.required}
{...props}/>
);
case "float":
return (
<Input label={metadata.label}
name={metadata.key}
type="number"
isRequired={metadata.required}
step="0.1"
{...props}/>
);
case "int":
return (
<Input label={metadata.label}
name={metadata.key}
type="number"
isRequired={metadata.required}
step="1"
{...props}/>
);
default:
return <pre>Unsupported type: {metadata.type} for key {metadata.key}</pre>;
}
}
return inputElement(pluginConfigMetadata!);
}
@@ -0,0 +1,21 @@
import {Image, Tooltip} from "@heroui/react";
import {Plug} from "@phosphor-icons/react";
import {pluginState} from "Frontend/state/PluginState";
import {useSnapshot} from "valtio/react";
interface PluginLogoProps {
pluginId: string;
}
export default function PluginIcon({pluginId}: PluginLogoProps) {
const state = useSnapshot(pluginState);
return state.isLoaded && (
<Tooltip content={state.state[pluginId].name}>
{state.state[pluginId].hasLogo ?
<Image src={`/images/plugins/${state.state[pluginId].id}/logo`} width={16} height={16} radius="none"/> :
<Plug size={16} weight="fill"/>
}
</Tooltip>
)
}
@@ -0,0 +1,19 @@
import {Plug} from "@phosphor-icons/react";
import React from "react";
import {Image} from "@heroui/react";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
interface PluginLogoProps {
plugin: PluginDto;
}
export default function PluginLogo({plugin}: PluginLogoProps) {
return (
<>
{plugin.hasLogo ?
<Image isBlurred src={`/images/plugins/${plugin.id}/logo`} width={64} height={64} radius="none"/> :
<Plug size={64} weight="fill"/>
}
</>
);
}
@@ -0,0 +1,42 @@
import {Button, Tooltip, useDisclosure} from "@heroui/react";
import {ListNumbers} from "@phosphor-icons/react";
import {PluginManagementCard} from "Frontend/components/general/cards/PluginManagementCard";
import React from "react";
import PluginPrioritiesModal from "Frontend/components/general/modals/PluginPrioritiesModal";
import {camelCaseToTitle} from "Frontend/util/utils";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
interface PluginManagementSectionProps {
type: string;
plugins: PluginDto[];
}
export function PluginManagementSection({type, plugins}: PluginManagementSectionProps) {
const pluginPrioritiesModal = useDisclosure();
return (
<div className="flex flex-col gap-2">
<div className="flex flex-row flex-grow justify-between">
<h2 className="text-xl font-bold">{camelCaseToTitle(type)}</h2>
<Tooltip color="foreground" placement="left" content="Change plugin order">
<Button isIconOnly variant="flat" onPress={pluginPrioritiesModal.onOpen}>
<ListNumbers/>
</Button>
</Tooltip>
</div>
<div className="grid grid-cols-300px gap-4">
{plugins.map((plugin) =>
<PluginManagementCard plugin={plugin} key={plugin.id}/>
)}
</div>
<PluginPrioritiesModal
key={plugins.map(p => p.id + p.priority).join(',')} // force re-mount if plugin order changes
plugins={[...plugins].sort((a, b) => b.priority - a.priority)}
isOpen={pluginPrioritiesModal.isOpen}
onOpenChange={pluginPrioritiesModal.onOpenChange}
/>
</div>);
}
@@ -0,0 +1,62 @@
import {Outlet} from "react-router";
import {Icon} from "@phosphor-icons/react";
import {Listbox, ListboxItem} from "@heroui/react";
import {ReactElement, useState} from "react";
export type MenuItem = {
title: string,
url: string,
icon: ReactElement<Icon>
}
export default function withSideMenu(baseUrl: string, menuItems: MenuItem[]) {
return function PageWithSideMenu() {
const [selectedItem, setSelectedItem] = useState<string>(initialSelected)
/**
* Remove a "/" at the start if it exists
*/
function key(k: string): string {
return k.replace(/^(\/)/, "");
}
/**
* 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 baseUrl + l;
return baseUrl + "/" + l;
}
/**
* Match the initially selected item by current URL path
*/
function initialSelected(): string {
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="w-60 fixed" color="primary">
{menuItems.map((i) => (
<ListboxItem key={key(i.url)} startContent={i.icon} href={link(i.url)}
onPress={() => setSelectedItem(i.url)}
className={`h-12 ${key(i.url) === selectedItem ? "bg-primary" : ""}`}>
<p>{i.title}</p>
</ListboxItem>
))}
</Listbox>
</div>
<div className="ml-60 flex-1 overflow-auto">
<Outlet/>
</div>
</div>
);
};
}
@@ -0,0 +1,16 @@
export default function GameyfinLogo({className}: {
className?: string
}) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 365.58 336.34" className={className}>
<polygon points="190.1 49.13 190.1 69.24 207.98 44.13 190.1 49.13"/>
<polygon points="365.58 0 263.22 28.66 205.64 95.97 365.58 51.18 365.58 0"/>
<polygon
points="190.1 283.11 248.6 266.73 248.6 149.74 365.58 116.99 365.58 73.12 190.1 122.25 190.1 283.11"/>
<polygon
points="58.49 144.48 155.98 117.18 175.48 89.79 175.48 53.23 0 102.36 0 336.34 58.49 254.15 58.49 144.48"/>
<polygon
points="116.99 199.59 116.99 245.09 65.81 259.42 0 336.34 175.48 287.2 175.48 170.22 131.61 182.5 116.99 199.59"/>
</svg>
);
}
@@ -0,0 +1,23 @@
import {Theme} from "Frontend/theming/theme";
import {Tooltip} from "@heroui/react";
export default function ThemePreview({theme, isSelected}: {
theme: Theme,
isSelected?: boolean
}) {
return (
<Tooltip content={<p className="capitalize">{theme.name?.replace("-", " ")}</p>} placement="bottom">
<div className={`flex flex-col flex-grow aspect-square border-2 rounded-large overflow-hidden
${theme.name}-dark
${isSelected ? "border-foreground" : "border-foreground-200 hover:border-focus"}`}>
<div className="flex-1 bg-primary"/>
<div className="basis-1/4 flex flex-row">
<div className="flex-1 bg-secondary"/>
<div className="flex-1 bg-success"/>
<div className="flex-1 bg-warning"/>
<div className="flex-1 bg-danger"/>
</div>
</div>
</Tooltip>
);
}
@@ -0,0 +1,82 @@
import {useTheme} from "next-themes";
import React, {useEffect, useState} from "react";
import {Button, Card, Divider, Select, Selection, SelectItem} from "@heroui/react";
import {themes} from "Frontend/theming/themes";
import {Theme} from "Frontend/theming/theme";
import ThemePreview from "Frontend/components/theming/ThemePreview";
import {toTitleCase} from "Frontend/util/utils";
import {UserPreferenceService} from "Frontend/util/user-preference-service";
export function ThemeSelector() {
const {theme, setTheme} = useTheme();
const [selectedTheme, setSelectedTheme] = useState(theme?.substring(0, theme?.lastIndexOf("-")));
const [selectedMode, setSelectedMode] = useState<Selection>();
useEffect(() => {
if (!selectedMode)
setSelectedMode(new Set([theme?.split('-').pop() ?? "dark"]));
}, [theme]);
useEffect(updateTheme, [selectedTheme, selectedMode]);
function updateTheme() {
if (selectedMode instanceof Set) {
let theme = `${selectedTheme}-${selectedMode.values().next().value}`;
setTheme(theme);
UserPreferenceService.set("preferred-theme", theme).catch(console.error);
}
}
return (
<div className="flex flex-col items-center gap-8">
<Select label="Theme mode" className="max-w-xs"
disallowEmptySelection
selectionMode={"single"}
defaultSelectedKeys={selectedMode}
onSelectionChange={setSelectedMode}
selectedKeys={selectedMode}>
<SelectItem key="light">
Light
</SelectItem>
<SelectItem key="dark">
Dark
</SelectItem>
</Select>
<div className="grid grid-flow-row grid-cols-8 gap-8">
{
//min-w-[468px]
themes.map(((t: Theme) => (
<div className="size-[10vh] min-h-[50px] min-w-[50px]"
key={t.name}
onClick={() => setSelectedTheme(t.name)}>
<ThemePreview
theme={t}
isSelected={selectedTheme === t.name}/>
</div>
)))
}
</div>
<p className="text-2xl font-semibold mt-8">Preview for theme
"{toTitleCase(theme!.replaceAll("-", " "))}"
</p>
<Divider/>
<div className="flex flex-row gap-8 items-baseline">
<div className="flex flex-row gap-4">
<Button color="primary">Primary</Button>
<Button color="secondary">Secondary</Button>
<Button color="success">Success</Button>
<Button color="warning">Warning</Button>
<Button color="danger">Danger</Button>
</div>
<Card className="flex flex-row gap-4 p-4">
<Button color="primary">Primary</Button>
<Button color="secondary">Secondary</Button>
<Button color="success">Success</Button>
<Button color="warning">Warning</Button>
<Button color="danger">Danger</Button>
</Card>
</div>
</div>
)
}
@@ -0,0 +1,101 @@
import React, {ReactNode, useState} from "react";
import {Form, Formik, FormikBag, FormikHelpers} from "formik";
import {ArrowLeft, ArrowRight, Check} from "@phosphor-icons/react";
import {Button} from "@heroui/react";
import {Step, Stepper} from "@material-tailwind/react";
const Wizard = ({children, initialValues, onSubmit}: {
children: ReactNode,
initialValues: any,
onSubmit: (values: any, bag: FormikHelpers<any> | FormikBag<any, any>) => Promise<any>
}) => {
const [stepNumber, setStepNumber] = useState(0);
const steps = React.Children.toArray(children);
const [snapshot, setSnapshot] = useState(initialValues);
const step = steps[stepNumber];
const totalSteps = steps.length;
const isFirstStep = stepNumber === 0;
const isLastStep = stepNumber === totalSteps - 1;
const next = (values: any) => {
setSnapshot(values);
setStepNumber(Math.min(stepNumber + 1, totalSteps - 1));
};
const previous = (values: any) => {
setSnapshot(values);
setStepNumber(Math.max(stepNumber - 1, 0));
};
const handleSubmit = async (values: any, bag: FormikBag<any, any> | FormikHelpers<any>) => {
/*// @ts-ignore*/
if (step.props.onSubmit) {
/*// @ts-ignore*/
await step.props.onSubmit(values, bag);
}
if (isLastStep) {
return onSubmit(values, bag);
} else {
await bag.setTouched({});
next(values);
}
};
return (
<Formik
initialValues={snapshot}
onSubmit={handleSubmit}
/*// @ts-ignore*/
validationSchema={step.props.validationSchema}
>
{(formik) => (
<Form className="flex flex-col h-full">
<div className="w-full mb-8">
<Stepper activeStep={stepNumber} activeLineClassName="bg-primary"
lineClassName="bg-foreground"
placeholder={undefined}
onPointerEnterCapture={undefined}
onPointerLeaveCapture={undefined}
onResize={undefined}
onResizeCapture={undefined}>
{steps.map((child, index) => (
<Step key={index}
className="bg-foreground text-background"
activeClassName="bg-primary"
completedClassName="bg-primary"
placeholder={undefined}
onPointerEnterCapture={undefined}
onPointerLeaveCapture={undefined}
onResize={undefined}
onResizeCapture={undefined}>
{/*@ts-ignore*/}
{child.props.icon}
</Step>
))}
</Stepper>
</div>
<div className="flex grow">
{step}
</div>
<div className="left-8 right-8 absolute bottom-8 -z-1">
<div className="flex justify-between">
<Button color="primary" onClick={() => previous(formik.values)} isDisabled={isFirstStep}>
<ArrowLeft/>
</Button>
<Button
color="primary"
isLoading={formik.isSubmitting}
type="submit"
>
{formik.isSubmitting ? "" : isLastStep ? <Check/> : <ArrowRight/>}
</Button>
</div>
</div>
</Form>
)}
</Formik>
);
};
export default Wizard;
@@ -0,0 +1,11 @@
import {JSX, ReactNode} from "react";
import * as Yup from 'yup';
export default function WizardStep({children, icon, validationSchema}: {
children: ReactNode,
icon: JSX.Element,
validationSchema?: Yup.Schema,
onSubmit?: (...values: any) => Promise<void>
}) {
return children;
}
@@ -0,0 +1,53 @@
import {fetchWithAuth} from "Frontend/util/utils";
import {addToast} from "@heroui/react";
export async function uploadAvatar(avatar: any) {
const formData = new FormData();
formData.append("file", avatar);
const response = await fetchWithAuth("images/avatar/upload", formData);
const result = await response.text();
if (response.ok) {
window.location.reload();
} else {
addToast({
title: "Error uploading avatar",
description: result,
color: "danger"
});
}
}
export async function removeAvatar() {
const response = await fetchWithAuth("images/avatar/delete")
const result = await response.text();
if (response.ok) {
window.location.reload();
} else {
addToast({
title: "Error removing avatar",
description: result,
color: "danger"
});
}
}
export async function removeAvatarByName(name: string) {
const response = await fetchWithAuth("images/avatar/deleteByName?" + new URLSearchParams({name: name}))
const result = await response.text();
if (response.ok) {
window.location.reload();
} else {
addToast({
title: "Error removing avatar",
description: result,
color: "danger"
});
}
}
@@ -0,0 +1,3 @@
export function downloadGame(gameId: number, provider: string) {
window.open(`/download/${gameId}?provider=${provider}`, '_top');
}
@@ -0,0 +1,4 @@
import * as AvatarEndpoint from './AvatarEndpoint'
import * as DownloadEndpoint from './DownloadEndpoint'
export {AvatarEndpoint, DownloadEndpoint}
+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Gameyfin</title>
<style>
body {
margin: 0;
width: 100vw;
height: 100vh;
}
#outlet {
height: 100%;
width: 100%;
}
</style>
<!-- index.ts is included here automatically (either by the dev server or during the build) -->
</head>
<body>
<div id="outlet"></div>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
import {createRoot} from 'react-dom/client';
import {StrictMode} from "react";
import {RouterProvider} from "react-router";
import {router} from './routes';
const container = document.getElementById('outlet')!;
const root = createRoot(container);
root.render(
<StrictMode>
<RouterProvider router={router}/>
</StrictMode>
);
+74
View File
@@ -0,0 +1,74 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.gradient-primary {
@apply bg-gradient-to-br from-primary-400 to-primary-700;
}
.button-secondary {
@apply bg-primary-300 text-background/80;
}
}
/* Custom CSS */
:root {
/* Overwrite default Hilla styles (e.g. loading indicator) */
--lumo-primary-color: theme(colors.primary);
/* Overwrite SwiperJS styles */
--swiper-navigation-color: theme(colors.primary);
--swiper-pagination-color: theme(colors.primary);
.swiper-pagination-bullet {
background-color: theme(colors.primary);
}
}
/* List box drag & drop */
.react-aria-ListBoxItem {
&[data-dragging] {
opacity: 0.6;
}
}
.react-aria-DropIndicator[data-drop-target] {
outline: 1px solid theme(colors.primary);
}
.shine {
position: relative;
overflow: hidden;
}
.shine::before {
background: linear-gradient(
to right,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.7) 100%
);
content: "";
display: block;
height: 100%;
left: -100%;
position: absolute;
top: 0;
transform: skewX(-25deg);
width: 50%;
z-index: 2;
pointer-events: none;
}
.shine:hover::before,
.shine:focus::before {
animation: shine 0.85s;
}
@keyframes shine {
100% {
left: 125%;
}
}
+105
View File
@@ -0,0 +1,105 @@
import LoginView from "Frontend/views/LoginView";
import MainLayout from "Frontend/views/MainLayout";
import HomeView from "Frontend/views/HomeView";
import SetupView from "Frontend/views/SetupView";
import {ThemeSelector} from "Frontend/components/theming/ThemeSelector";
import App from "Frontend/App";
import {LibraryManagement} from "Frontend/components/administration/LibraryManagement";
import {UserManagement} from "Frontend/components/administration/UserManagement";
import ProfileManagement from "Frontend/components/administration/ProfileManagement";
import {SsoManagement} from "Frontend/components/administration/SsoManagement";
import {AdministrationView} from "Frontend/views/AdministrationView";
import {ProfileView} from "Frontend/views/ProfileView";
import {MessageManagement} from "Frontend/components/administration/MessageManagement";
import {LogManagement} from "Frontend/components/administration/LogManagement";
import PasswordResetView from "Frontend/views/PasswordResetView";
import EmailConfirmationView from "Frontend/views/EmailConfirmationView";
import InvitationRegistrationView from "Frontend/views/InvitationRegistrationView";
import PluginManagement from "Frontend/components/administration/PluginManagement";
import {SystemManagement} from "Frontend/components/administration/SystemManagement";
import GameView from "Frontend/views/GameView";
import LibraryManagementView from "Frontend/views/LibraryManagementView";
import SearchView from "Frontend/views/SearchView";
import RecentlyAddedView from "Frontend/views/RecentlyAddedView";
import LibraryView from "Frontend/views/LibraryView";
import {RouterConfigurationBuilder} from "@vaadin/hilla-file-router/runtime.js";
export const {router, routes} = new RouterConfigurationBuilder()
.withReactRoutes([
{
element: <App/>,
handle: {requiresLogin: false},
children: [
{
element: <MainLayout/>,
handle: {requiresLogin: true},
children: [
{
index: true, element: <HomeView/>
},
{
path: 'search',
element: <SearchView/>
},
{
path: 'recently-added',
element: <RecentlyAddedView/>
},
{
path: 'library/:libraryId',
element: <LibraryView/>
},
{
path: 'game/:gameId',
element: <GameView/>
},
{
path: 'settings',
element: <ProfileView/>,
children: [
{path: 'profile', element: <ProfileManagement/>},
{path: 'appearance', element: <ThemeSelector/>}
]
},
{
path: 'administration',
element: <AdministrationView/>,
children: [
{
path: 'libraries',
element: <LibraryManagement/>
},
{
path: 'libraries/library/:libraryId',
element: <LibraryManagementView/>
},
{path: 'users', element: <UserManagement/>},
{path: 'sso', element: <SsoManagement/>},
{path: 'messages', element: <MessageManagement/>},
{path: 'plugins', element: <PluginManagement/>},
{path: 'logs', element: <LogManagement/>},
{path: 'system', element: <SystemManagement/>}
]
}
]
},
{
path: 'login', element: <LoginView/>, handle: {requiresLogin: false}
},
{
path: 'setup', element: <SetupView/>, handle: {requiresLogin: false}
},
{
path: 'accept-invitation', element: <InvitationRegistrationView/>, handle: {requiresLogin: false}
},
{
path: 'reset-password', element: <PasswordResetView/>, handle: {requiresLogin: false}
},
{
path: 'confirm-email', element: <EmailConfirmationView/>, handle: {requiresLogin: true}
},
]
}
])
.protect("/login")
.build();
@@ -0,0 +1,72 @@
import {proxy} from 'valtio';
import ConfigEntryDto from "Frontend/generated/org/gameyfin/app/config/dto/ConfigEntryDto";
import {ConfigEndpoint} from "Frontend/generated/endpoints";
import ConfigUpdateDto from "Frontend/generated/org/gameyfin/app/config/dto/ConfigUpdateDto";
import {Subscription} from "@vaadin/hilla-frontend";
type ConfigState = {
subscription?: Subscription<ConfigUpdateDto[]>;
isLoaded: boolean;
state: Record<string, ConfigEntryDto>;
config: NestedConfig;
};
export const configState = proxy<ConfigState>({
get isLoaded() {
return this.subscription != null;
},
state: {},
get config() {
return toNestedConfig(Object.values(this.state));
}
});
/** Subscribe to and process state updates from backend **/
export async function initializeConfigState() {
if (configState.isLoaded) return;
// Fetch initial configuration data
const initialEntries = await ConfigEndpoint.getAll();
initialEntries.forEach((entry) => {
configState.state[entry.key] = entry;
});
// Subscribe to real-time updates
configState.subscription = ConfigEndpoint.subscribe().onNext((updateDtos: ConfigUpdateDto[]) => {
updateDtos.forEach((updateDto: ConfigUpdateDto) => {
Object.entries(updateDto.updates).forEach(([key, value]) => {
if (configState.state[key]) {
configState.state[key].value = value;
}
});
})
});
}
/** Computed properties **/
export type NestedConfig = {
[field: string]: any;
}
function toNestedConfig(entries: ConfigEntryDto[]): NestedConfig {
const result: Record<string, any> = {};
for (const entry of entries) {
const keys = entry.key.split('.');
let current = result;
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (i === keys.length - 1) {
current[key] = entry.value;
} else {
current[key] = current[key] || {};
current = current[key];
}
}
}
return result;
}
+145
View File
@@ -0,0 +1,145 @@
import {Subscription} from "@vaadin/hilla-frontend";
import {proxy} from "valtio/index";
import {GameEndpoint} from "Frontend/generated/endpoints";
import GameEvent from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryEvent";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import Rand from "rand-seed";
type GameState = {
subscription?: Subscription<GameEvent[]>;
isLoaded: boolean;
state: Record<number, GameDto>;
games: GameDto[];
gamesByLibraryId: Record<number, GameDto[]>;
sortedAlphabetically: GameDto[];
recentlyAdded: GameDto[];
recentlyUpdated: GameDto[];
randomlyOrderedGamesByLibraryId: Record<number, GameDto[]>;
knownPublishers: Set<string>;
knownDevelopers: Set<string>;
knownGenres: Set<string>;
knownThemes: Set<string>;
knownKeywords: Set<string>;
knownFeatures: Set<string>;
knownPerspectives: Set<string>;
};
export const gameState = proxy<GameState>({
get isLoaded() {
return this.subscription != null;
},
state: {},
get games() {
return Object.values<GameDto>(this.state);
},
get gamesByLibraryId() {
return this.sortedAlphabetically.reduce((acc: Record<number, GameDto[]>, game: GameDto) => {
(acc[game.libraryId] ||= []).push(game);
return acc;
}, {});
},
get sortedAlphabetically() {
return this.games
.sort((a: GameDto, b: GameDto) => a.title.localeCompare(b.title, undefined, {sensitivity: 'base'}));
},
get recentlyAdded() {
return this.games
.sort((a: GameDto, b: GameDto) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 25);
},
get recentlyUpdated() {
return this.games
.sort((a: GameDto, b: GameDto) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
.slice(0, 25);
},
get randomlyOrderedGamesByLibraryId() {
const result: Record<number, GameDto[]> = {};
for (const libraryId in this.gamesByLibraryId) {
const rand = new Rand(libraryId.toString());
result[libraryId] = this.gamesByLibraryId[libraryId]
.filter((g: GameDto) => g.coverId && g.imageIds && g.imageIds.length > 0)
.sort((a: GameDto, b: GameDto) => a.id - b.id)
.sort(() => rand.next() - 0.5);
}
return result;
},
get knownPublishers() {
return new Set<string>(
this.games
.flatMap((game: GameDto) => game.publishers ? game.publishers : [])
.sort()
);
},
get knownDevelopers() {
return new Set<string>(
this.games
.flatMap((game: GameDto) => game.developers ? game.developers : [])
.sort()
);
},
get knownGenres() {
return new Set<string>(
this.games
.flatMap((game: GameDto) => game.genres ? game.genres : [])
.sort()
);
},
get knownThemes() {
return new Set<string>(
this.games
.flatMap((game: GameDto) => game.themes ? game.themes : [])
.sort()
);
},
get knownKeywords() {
return new Set<string>(
this.games
.flatMap((game: GameDto) => game.keywords ? game.keywords : [])
.sort()
);
},
get knownFeatures() {
return new Set<string>(
this.games
.flatMap((game: GameDto) => game.features ? game.features : [])
.sort()
);
},
get knownPerspectives() {
return new Set<string>(
this.games
.flatMap((game: GameDto) => game.perspectives ? game.perspectives : [])
.sort()
);
}
});
/** Subscribe to and process state updates from backend **/
export async function initializeGameState() {
if (gameState.isLoaded) return gameState;
// Fetch initial library list
const initialEntries = await GameEndpoint.getAll();
initialEntries.forEach((game: GameDto) => {
gameState.state[game.id] = game;
});
// Subscribe to real-time updates
gameState.subscription = GameEndpoint.subscribe().onNext((gameEvents: GameEvent[]) => {
gameEvents.forEach((gameEvent: GameEvent) => {
switch (gameEvent.type) {
case "created":
case "updated":
//@ts-ignore
gameState.state[gameEvent.game.id] = gameEvent.game;
break;
case "deleted":
//@ts-ignore
delete gameState.state[gameEvent.gameId];
break;
}
})
});
return gameState;
}
@@ -0,0 +1,62 @@
import {Subscription} from "@vaadin/hilla-frontend";
import {proxy} from "valtio/index";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import LibraryEvent from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryEvent";
import {handleLibraryDeletion} from "./ScanState";
type LibraryState = {
subscription?: Subscription<LibraryEvent[]>;
isLoaded: boolean;
state: Record<number, LibraryDto>;
libraries: LibraryDto[];
sorted: LibraryDto[];
};
export const libraryState = proxy<LibraryState>({
get isLoaded() {
return this.subscription != null;
},
state: {},
get libraries() {
return Object.values<LibraryDto>(this.state);
},
get sorted() {
return Object.values<LibraryDto>(this.state).sort((a, b) => {
if (a.name === undefined || b.name === undefined) return 0;
return a.name.localeCompare(b.name);
});
}
});
/** Subscribe to and process state updates from backend **/
export async function initializeLibraryState() {
if (libraryState.isLoaded) return libraryState;
// Fetch initial library list
const initialEntries = await LibraryEndpoint.getAll();
initialEntries.forEach((library: LibraryDto) => {
libraryState.state[library.id] = library;
});
// Subscribe to real-time updates
libraryState.subscription = LibraryEndpoint.subscribeToLibraryEvents().onNext((libraryEvents: LibraryEvent[]) => {
libraryEvents.forEach((libraryEvent: LibraryEvent) => {
switch (libraryEvent.type) {
case "created":
case "updated":
//@ts-ignore
libraryState.state[libraryEvent.library.id] = libraryEvent.library;
break;
case "deleted":
//@ts-ignore
handleLibraryDeletion(libraryEvent.libraryId);
//@ts-ignore
delete libraryState.state[libraryEvent.libraryId];
break;
}
})
});
return libraryState;
}
@@ -0,0 +1,76 @@
import {Subscription} from "@vaadin/hilla-frontend";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
import PluginUpdateDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginUpdateDto";
import {proxy} from "valtio/index";
import {PluginEndpoint} from "Frontend/generated/endpoints";
type PluginState = {
subscription?: Subscription<PluginUpdateDto[]>;
isLoaded: boolean;
state: Record<string, PluginDto>;
plugins: PluginDto[];
pluginsByType: Record<string, PluginDto[]>;
};
export const pluginState = proxy<PluginState>({
get isLoaded() {
return this.subscription != null;
},
state: {},
get plugins() {
return Object.values<PluginDto>(this.state);
},
get pluginsByType() {
return groupPluginsByType(this.state);
}
});
/** Subscribe to and process state updates from backend **/
export async function initializePluginState() {
if (pluginState.isLoaded) return;
// Fetch initial plugin list
const initialEntries = await PluginEndpoint.getAll();
initialEntries.forEach((plugin: PluginDto) => {
pluginState.state[plugin.id] = plugin;
});
// Subscribe to real-time updates
pluginState.subscription = PluginEndpoint.subscribe().onNext((updateDtos: PluginUpdateDto[]) => {
updateDtos.forEach((updateDto: PluginUpdateDto) => {
// Make sure the plugin exists in the state
if (pluginState.state[updateDto.id]) {
// Update the existing plugin by merging the new data using destructuring
pluginState.state[updateDto.id] = {
...pluginState.state[updateDto.id],
...updateDto
};
}
})
});
}
/** Computed **/
function groupPluginsByType(pluginsMap: Record<string, PluginDto>): Record<string, PluginDto[]> {
const pluginsByType: Record<string, PluginDto[]> = {};
// Convert map to array of plugins
const plugins = Object.values(pluginsMap);
// Iterate through each plugin
for (const plugin of plugins) {
// Each plugin can have multiple types
for (const type of plugin.types) {
// Initialize array for this type if it doesn't exist
if (!pluginsByType[type]) {
pluginsByType[type] = [];
}
// Add plugin to the appropriate type array
pluginsByType[type].push(plugin);
}
}
return pluginsByType;
}
+53
View File
@@ -0,0 +1,53 @@
import {proxy} from 'valtio';
import type LibraryScanProgress from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanProgress";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {Subscription} from "@vaadin/hilla-frontend";
import LibraryScanStatus from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanStatus";
import {libraryState} from "Frontend/state/LibraryState";
type ScanState = {
subscription?: Subscription<LibraryScanProgress[]>;
state: Record<string, LibraryScanProgress>;
hasContent: boolean,
isScanning: boolean,
sortedByStartTime: LibraryScanProgress[];
};
export const scanState = proxy<ScanState>({
state: {},
get hasContent(): boolean {
return Object.values(this.state).length > 0;
},
get isScanning(): boolean {
return Object.values(this.state)
.some((scanProgress: LibraryScanProgress) => scanProgress.status === LibraryScanStatus.IN_PROGRESS);
},
get sortedByStartTime(): LibraryScanProgress[] {
return Object.values(this.state).sort((a: LibraryScanProgress, b: LibraryScanProgress) => {
return new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime();
});
}
});
/** Subscribe to and process state updates from backend **/
export function initializeScanState() {
if (scanState.subscription) return;
// Subscribe to real-time updates
scanState.subscription = LibraryEndpoint.subscribeToScanProgressEvents().onNext((scanProgresses: LibraryScanProgress[]) => {
scanProgresses.forEach((scanProgress: LibraryScanProgress) => {
// Filter out scans for libraries that are not in the current state
if (!libraryState.state[scanProgress.libraryId]) return;
scanState.state[scanProgress.scanId] = scanProgress;
})
});
}
export function handleLibraryDeletion(libraryId: number) {
for (const scanId in scanState.state) {
if (scanState.state[scanId].libraryId === libraryId) {
delete scanState.state[scanId];
}
}
}
+26
View File
@@ -0,0 +1,26 @@
type ColorPalette = {
100: string,
200: string,
300: string,
400: string,
500: string,
600: string,
700: string,
800: string,
900: string,
DEFAULT: string
}
export type Theme = {
name?: string,
colors: {
background?: string,
foreground?: string,
primary: ColorPalette,
secondary?: ColorPalette,
success?: ColorPalette,
warning?: ColorPalette,
danger?: ColorPalette,
focus?: string,
}
}
+47
View File
@@ -0,0 +1,47 @@
import {GameyfinClassic} from "./themes/gameyfin-classic";
import {GameyfinBlue} from "./themes/gameyfin-blue";
import {GameyfinViolet} from "./themes/gameyfin-violet";
import {Purple} from "./themes/purple";
import {Neutral} from "./themes/neutral";
import {Slate} from "./themes/slate";
import {Red} from "./themes/red";
import {Rose} from "./themes/rose";
import {Blue} from "./themes/blue";
import {Yellow} from "./themes/yellow";
import {Violet} from "./themes/violet";
import {Orange} from "./themes/orange";
import {Colorblind} from "./themes/colorblind";
import {Theme} from "./theme";
import {ConfigTheme, ConfigThemes} from "@heroui/react";
function light(c: Theme): ConfigTheme {
let t: Theme = structuredClone(c);
delete t.name;
(t as ConfigTheme).extend = "light";
return t;
}
function dark(c: Theme): ConfigTheme {
let t: Theme = structuredClone(c);
delete t.name;
(t as ConfigTheme).extend = "dark";
return t;
}
export function compileThemes(themes: Theme[]): ConfigThemes {
let compiledThemes: any = {};
themes.forEach((c: Theme) => {
compiledThemes[`${c.name}-light`] = light(c);
compiledThemes[`${c.name}-dark`] = dark(c);
})
return compiledThemes;
}
export function themeNames(): string[] {
return Object.keys(compileThemes(themes));
}
export const themes: Theme[] = [GameyfinBlue, GameyfinViolet, GameyfinClassic, Neutral, Slate, Red, Rose, Orange, Purple, Blue, Yellow, Violet, Colorblind];
@@ -0,0 +1,31 @@
import {Theme} from '../theme';
export const Blue: Theme = {
name: 'blue',
colors: {
primary: {
DEFAULT: '#2563EB',
100: '#b6cdfe',
200: '#88abf7',
300: '#5b8af1',
400: '#2d69ec',
500: '#134fd2',
600: '#0b3da4',
700: '#052c77',
800: '#001a4a',
900: '#00091e'
},
secondary: {
DEFAULT: '#d29613',
100: '#faebcc',
200: '#f5d898',
300: '#f1c465',
400: '#ecb132',
500: '#d29613',
600: '#a87810',
700: '#7e5a0c',
800: '#543c08',
900: '#2a1e04'
}
}
};
@@ -0,0 +1,67 @@
import {Theme} from '../theme';
export const Colorblind: Theme = {
name: 'colorblind',
colors: {
primary: {
DEFAULT: '#cc79a7',
900: '#2f1222',
800: '#5f2444',
700: '#8e3666',
600: '#bb4b88',
500: '#cc79a7',
400: '#d795b9',
300: '#e1afca',
200: '#ebcadc',
100: '#f5e4ed'
},
secondary: {
DEFAULT: '#0072b2',
900: '#001724',
800: '#002d47',
700: '#00446b',
600: '#005a8f',
500: '#0072b2',
400: '#009bf5',
300: '#38b6ff',
200: '#7aceff',
100: '#bde7ff'
},
success: {
DEFAULT: '#009e73',
900: '#002017',
800: '#003f2e',
700: '#005f46',
600: '#007e5d',
500: '#009e73',
400: '#00e4a8',
300: '#2cffc7',
200: '#72ffd9',
100: '#b9ffec'
},
warning: {
DEFAULT: '#e69f00',
900: '#2e1f00',
800: '#5c3f00',
700: '#8a5e00',
600: '#b87d00',
500: '#e69f00',
400: '#ffb81f',
300: '#ffca57',
200: '#ffdb8f',
100: '#ffedc7'
},
danger: {
DEFAULT: '#d55e00',
900: '#2b1300',
800: '#562500',
700: '#813800',
600: '#ab4a00',
500: '#d55e00',
400: '#ff7912',
300: '#ff9a4e',
200: '#ffbc89',
100: '#ffddc4'
}
}
};
@@ -0,0 +1,31 @@
import {Theme} from '../theme';
export const GameyfinBlue: Theme = {
name: 'gameyfin-blue',
colors: {
primary: {
DEFAULT: '#2332c8',
100: '#bdc3f9',
200: '#919bee',
300: '#6672e5',
400: '#3c4add',
500: '#2231c3',
600: '#1a2699',
700: '#101b6f',
800: '#070f45',
900: '#02041d'
},
secondary: {
DEFAULT: '#c3b422',
100: '#f7f3cf',
200: '#eee6a0',
300: '#e6da70',
400: '#ddce40',
500: '#c3b422',
600: '#9c8f1c',
700: '#756b15',
800: '#4e480e',
900: '#272407',
}
}
};
@@ -0,0 +1,31 @@
import {Theme} from '../theme';
export const GameyfinClassic: Theme = {
name: 'gameyfin-classic',
colors: {
primary: {
DEFAULT: '#16A34A',
100: '#b8f7cf',
200: '#8ef0b2',
300: '#62ea94',
400: '#38e476',
500: '#20ca5d',
600: '#159d47',
700: '#0b7032',
800: '#02431d',
900: '#001804'
},
secondary: {
DEFAULT: '#ca208d',
100: '#f8cfe9',
200: '#f0a0d3',
300: '#e970bc',
400: '#e140a6',
500: '#ca208d',
600: '#a21970',
700: '#7a1354',
800: '#510d38',
900: '#29061c'
}
}
};
@@ -0,0 +1,31 @@
import {Theme} from '../theme';
export const GameyfinViolet: Theme = {
name: 'gameyfin-violet',
colors: {
primary: {
DEFAULT: '#6441a5',
100: '#d5c7ed',
200: '#b7a4dd',
300: '#9a7fce',
400: '#7d5abe',
500: '#6441a5',
600: '#4e3281',
700: '#37235d',
800: '#21153a',
900: '#0d0519'
},
secondary: {
DEFAULT: '#82a541',
100: '#e7efd7',
200: '#cedfaf',
300: '#b6cf87',
400: '#9dbf5f',
500: '#82a541',
600: '#688334',
700: '#4e6227',
800: '#34421a',
900: '#1a210d'
}
}
};
@@ -0,0 +1,31 @@
import {Theme} from '../theme';
export const Neutral: Theme = {
name: 'neutral',
colors: {
primary: {
DEFAULT: '#525252',
100: '#ddd7d9',
200: '#c1bfbf',
300: '#a6a6a6',
400: '#8c8c8c',
500: '#737373',
600: '#595959',
700: '#413f40',
800: '#292526',
900: '#16090d'
},
secondary: {
DEFAULT: '#8d8d8d',
100: '#e8e8e8',
200: '#d1d1d1',
300: '#bababa',
400: '#a3a3a3',
500: '#8d8d8d',
600: '#707070',
700: '#545454',
800: '#383838',
900: '#1c1c1c'
}
}
};
@@ -0,0 +1,31 @@
import {Theme} from '../theme';
export const Orange: Theme = {
name: 'orange',
colors: {
primary: {
DEFAULT: '#EA580C',
100: '#ffcdb2',
200: '#fbac84',
300: '#f78c54',
400: '#f46c25',
500: '#da520b',
600: '#ab3f07',
700: '#7a2d03',
800: '#4b1900',
900: '#1f0600'
},
secondary: {
DEFAULT: '#0b93da',
100: '#caebfc',
200: '#94d6f9',
300: '#5fc2f7',
400: '#2aadf4',
500: '#0b93da',
600: '#0975ae',
700: '#075783',
800: '#053a57',
900: '#021d2c'
}
}
};
@@ -0,0 +1,31 @@
import {Theme} from "../theme";
export const Purple: Theme = {
name: 'purple',
colors: {
primary: {
DEFAULT: '#DD62ED',
100: '#f2b9f9',
200: '#e78df3',
300: '#dc5fed',
400: '#d132e6',
500: '#b91acd',
600: '#9012a0',
700: '#670b73',
800: '#3f0547',
900: '#19001b'
},
secondary: {
DEFAULT: '#2ecd1a',
100: '#d2f9cd',
200: '#a6f29c',
300: '#79ec6a',
400: '#4de538',
500: '#2ecd1a',
600: '#26a215',
700: '#1c7a10',
800: '#13510b',
900: '#092905'
}
}
};
@@ -0,0 +1,31 @@
import {Theme} from '../theme';
export const Red: Theme = {
name: 'red',
colors: {
primary: {
DEFAULT: '#DC2626',
100: '#f9bbbb',
200: '#ef9090',
300: '#e76464',
400: '#df3939',
500: '#c62020',
600: '#9b1718',
700: '#6f0f11',
800: '#450708',
900: '#1e0000'
},
secondary: {
DEFAULT: '#20c6c6',
100: '#cff7f7',
200: '#9fefef',
300: '#6ee7e7',
400: '#3ee0e0',
500: '#20c6c6',
600: '#1a9e9e',
700: '#137676',
800: '#0d4f4f',
900: '#062727'
}
}
};
@@ -0,0 +1,31 @@
import {Theme} from "../theme";
export const Rose: Theme = {
name: 'rose',
colors: {
primary: {
DEFAULT: '#E11D48',
100: '#fbb9c8',
200: '#f28da4',
300: '#ec607f',
400: '#e5345b',
500: '#cb1a41',
600: '#9f1233',
700: '#730b23',
800: '#470415',
900: '#1e0006'
},
secondary: {
DEFAULT: '#1acba4',
100: '#cdf9ef',
200: '#9cf2df',
300: '#6aecd0',
400: '#38e5c0',
500: '#1acba4',
600: '#15a284',
700: '#107a63',
800: '#0b5142',
900: '#052921'
}
}
};
@@ -0,0 +1,31 @@
import {Theme} from "../theme";
export const Slate: Theme = {
name: 'slate',
colors: {
primary: {
DEFAULT: '#475569',
100: '#cfd7e4',
200: '#b2bdcd',
300: '#95a3b7',
400: '#7788a1',
500: '#5e6f88',
600: '#48566a',
700: '#323e4d',
800: '#1d2531',
900: '#040d17'
},
secondary: {
DEFAULT: '#88775e',
100: '#e8e4de',
200: '#d1c9bd',
300: '#baae9c',
400: '#a3937b',
500: '#88775e',
600: '#6c5f4b',
700: '#514738',
800: '#363026',
900: '#1b1813'
}
}
};
@@ -0,0 +1,31 @@
import {Theme} from '../theme';
export const Violet: Theme = {
name: 'violet',
colors: {
primary: {
DEFAULT: '#6D28D9',
100: '#d4bdf8',
200: '#b592ee',
300: '#9867e5',
400: '#7b3cdd',
500: '#6122c3',
600: '#4b1a99',
700: '#36126e',
800: '#200944',
900: '#0d021c'
},
secondary: {
DEFAULT: '#84c322',
100: '#e8f7cf',
200: '#d0eea0',
300: '#b9e670',
400: '#a1dd40',
500: '#84c322',
600: '#6b9c1c',
700: '#507515',
800: '#354e0e',
900: '#1b2707'
}
}
};
@@ -0,0 +1,31 @@
import {Theme} from '../theme';
export const Yellow: Theme = {
name: 'yellow',
colors: {
primary: {
DEFAULT: '#FACC15',
100: '#feefae',
200: '#fce47f',
300: '#fbd94e',
400: '#face1e',
500: '#e1b505',
600: '#af8c00',
700: '#7d6400',
800: '#4c3c00',
900: '#1c1400'
},
secondary: {
DEFAULT: '#0531e1',
100: '#c8d3fe',
200: '#91a7fd',
300: '#5a7afc',
400: '#234efb',
500: '#0531e1',
600: '#0427b4',
700: '#031d87',
800: '#02135a',
900: '#010a2d'
}
}
};
+16
View File
@@ -0,0 +1,16 @@
import {configureAuth} from '@vaadin/hilla-react-auth';
import {UserEndpoint} from 'Frontend/generated/endpoints';
// Configure auth to use `UserInfoService.getUserInfo`
const auth = configureAuth(UserEndpoint.getUserInfo);
// Export auth provider and useAuth hook, which are automatically
// typed to the result of `UserInfoService.getUserInfo`
export const useAuth = auth.useAuth;
export const AuthProvider = auth.AuthProvider;
export function getCsrfToken() {
const token = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
return token || '';
}
@@ -0,0 +1,10 @@
import * as Yup from "yup";
import {isValidCron} from "cron-validator";
// Custom validator for cron expressions
Yup.addMethod(Yup.string, 'cron', function (message) {
return this.test('cron', message, function (value) {
const {path, createError} = this;
return isValidCron(value as string) || createError({path, message: message || 'Invalid cron expression'});
});
});
+39
View File
@@ -0,0 +1,39 @@
import {Middleware, MiddlewareContext, MiddlewareNext} from '@vaadin/hilla-frontend';
import {addToast} from "@heroui/react";
import {getReasonPhrase} from "http-status-codes";
export const ErrorHandlingMiddleware: Middleware = async function (
context: MiddlewareContext,
next: MiddlewareNext
) {
const {endpoint, method} = context;
let originalResponse = (await next(context));
if (!originalResponse.ok) {
// .clone() is necessary because response.json() is one-time only and Hilla accesses it in its internal error handler
// @see https://developer.mozilla.org/en-US/docs/Web/API/Response/clone
let response: Response = originalResponse.clone();
//Ignore calls to UserEndpoint.getUserInfo since they are managed by Hilla and called on initial load
if (endpoint == "UserEndpoint" && method == "getUserInfo") return originalResponse;
let json: any = await response.json();
if (json.type == "dev.hilla.exception.EndpointException") {
addToast({
title: getReasonPhrase(response.status),
description: json.message,
color: "danger"
})
} else {
addToast({
title: getReasonPhrase(response.status),
description: `${endpoint}.${method}`,
color: "danger"
})
}
}
return originalResponse;
}
+15
View File
@@ -0,0 +1,15 @@
import {useMatches} from 'react-router';
type RouteMetadata = {
[key: string]: any;
};
/**
* Returns the `handle` object containing the metadata for the current route,
* or undefined if the route does not have defined a handle.
*/
export function useRouteMetadata(): RouteMetadata | undefined {
const matches = useMatches();
const match = matches[matches.length - 1];
return match?.handle as RouteMetadata | undefined;
}
@@ -0,0 +1,39 @@
import {UserPreferencesEndpoint} from "Frontend/generated/endpoints";
export class UserPreferenceService {
static LOCAL_STORAGE_PREFIX = "gameyfin.";
static async sync(): Promise<void> {
let keys = Object.keys(localStorage);
for (let key of keys) {
if (!key.startsWith(`${this.LOCAL_STORAGE_PREFIX}`)) {
continue;
}
let value = await UserPreferencesEndpoint.get(key.replace(this.LOCAL_STORAGE_PREFIX, ""));
if (value) {
localStorage.setItem(key, value);
}
}
}
static async get(key: string): Promise<string | undefined> {
let localPreference = localStorage.getItem(`${this.LOCAL_STORAGE_PREFIX}${key}`);
if (localPreference) {
return localPreference;
} else {
let syncedPreference = await UserPreferencesEndpoint.get(key);
if (syncedPreference) {
localStorage.setItem(`${this.LOCAL_STORAGE_PREFIX}${key}`, syncedPreference);
return syncedPreference;
}
}
return undefined;
}
static async set(key: string, value: string) {
await UserPreferencesEndpoint.set(key, value);
localStorage.setItem(`${this.LOCAL_STORAGE_PREFIX}${key}`, value);
}
}
+210
View File
@@ -0,0 +1,210 @@
import {getCsrfToken} from "Frontend/util/auth";
import moment from 'moment-timezone';
export function isAdmin(auth: any): boolean {
return auth.state.user?.roles?.some((a: string) => a?.includes("ADMIN"));
}
export function roleToRoleName(role: string) {
role = role.replace("ROLE_", "").toLowerCase();
return role.charAt(0).toUpperCase() + role.slice(1);
}
export function toTitleCase(str: string) {
return str.replaceAll("_", " ").toLowerCase().split(' ').map((word: string) => {
return (word.charAt(0).toUpperCase() + word.slice(1));
}).join(' ');
}
export function camelCaseToTitle(text: string): string {
return text
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/^./, str => str.toUpperCase());
}
export function hashCode(string: string) {
let hash = 0, i, chr;
if (string.length === 0) return hash;
for (i = 0; i < string.length; i++) {
chr = string.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0; // Convert to 32bit integer
}
return hash;
}
export function roleToColor(role: string) {
switch (role) {
case "ROLE_SUPERADMIN":
return "red";
case "ROLE_ADMIN":
return "orange";
case "ROLE_USER":
return "blue";
default:
return "gray";
}
}
export async function fetchWithAuth(url: string, body: any = null, method = "POST"): Promise<Response> {
return await fetch(url, {
headers: {
"X-CSRF-Token": getCsrfToken()
},
credentials: "same-origin",
method: method,
body: body
});
}
/**
* Calculate the time difference between a given Instant and the current time in the user's timezone.
* @param {string} instantString - The Instant string returned by the backend.
* @param {string} timeZone - The user's timezone.
* @returns {string} - The time difference in a human-readable format.
*/
export function timeUntil(instantString: string, timeZone: string = moment.tz.guess()): string {
const givenDate = moment.tz(instantString, timeZone);
const now = moment.tz(timeZone);
const diffInSeconds = givenDate.diff(now, 'seconds');
const units = [
{name: "year", seconds: 31536000},
{name: "month", seconds: 2592000},
{name: "day", seconds: 86400},
{name: "hour", seconds: 3600},
{name: "minute", seconds: 60},
{name: "second", seconds: 1}
];
const isPast = diffInSeconds < 0;
const absDiffInSeconds = Math.abs(diffInSeconds);
for (const unit of units) {
const value = Math.floor(absDiffInSeconds / unit.seconds);
if (value >= 1) {
return `${isPast ? '' : 'in'} ${value} ${unit.name}${value > 1 ? 's' : ''} ${isPast ? 'ago' : ''}`;
}
}
return "just now";
}
export function timeBetween(start: string, end: string, timeZone: string = moment.tz.guess()): string {
const startDate = moment.tz(start, timeZone);
const endDate = moment.tz(end, timeZone);
const diffInSeconds = startDate.diff(endDate, 'seconds');
const units = [
{name: "year", seconds: 31536000},
{name: "month", seconds: 2592000},
{name: "day", seconds: 86400},
{name: "hour", seconds: 3600},
{name: "minute", seconds: 60},
{name: "second", seconds: 1}
];
const absDiffInSeconds = Math.abs(diffInSeconds);
for (const unit of units) {
const value = Math.floor(absDiffInSeconds / unit.seconds);
if (value >= 1) {
return `${value} ${unit.name}${value > 1 ? 's' : ''}`;
}
}
return "under a second";
}
/**
* Format bytes as human-readable text.
*
* @param bytes Number of bytes.
* @param si True to use metric (SI) units, aka powers of 1000. False to use
* binary (IEC), aka powers of 1024.
* @param dp Number of decimal places to display.
*
* @return Formatted string.
*/
export function humanFileSize(bytes: number, si: boolean = false, dp: number = 1) {
const thresh = si ? 1000 : 1024;
if (Math.abs(bytes) < thresh) {
return bytes + ' B';
}
const units = si
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
let u = -1;
const r = 10 ** dp;
do {
bytes /= thresh;
++u;
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
return bytes.toFixed(dp) + ' ' + units[u];
}
/**
* 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> {
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 || {};
}
/**
* Extract the file name from a given path.
* Supports both Windows and Unix-style paths.
* @param path
* @param includeExtension
*/
export function fileNameFromPath(path: string, includeExtension: boolean = true): string {
let fileName = (path.split('\\').pop() ?? '').split('/').pop() ?? '';
if (includeExtension) {
return fileName;
}
const dotIndex = fileName.lastIndexOf('.');
return dotIndex < 0 ? fileName : fileName.substring(0, dotIndex);
}

Some files were not shown because too many files have changed in this diff Show More