mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-15 08:15:37 +00:00
Move package "de.grimsi.gameyfin" to "org.gameyfin"
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
+76
@@ -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
|
||||
<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 /
|
||||
{scan.result?.removed} removed /
|
||||
{scan.result?.unmatched} unmatched
|
||||
(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=" "
|
||||
value={formik.values.internalPath}
|
||||
isDisabled
|
||||
required
|
||||
/>
|
||||
<ArrowRight className="mb-8"/>
|
||||
<Input
|
||||
name="externalPath"
|
||||
label="External path (optional)"
|
||||
placeholder=" "
|
||||
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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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'});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {Envelope, GameController, LockKey, Log, Plug, Users, Wrench} from "@phosphor-icons/react";
|
||||
import withSideMenu, {MenuItem} from "Frontend/components/general/withSideMenu";
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
title: "Libraries",
|
||||
url: "libraries",
|
||||
icon: <GameController/>
|
||||
},
|
||||
{
|
||||
title: "Users",
|
||||
url: "users",
|
||||
icon: <Users/>
|
||||
},
|
||||
{
|
||||
title: "SSO",
|
||||
url: "sso",
|
||||
icon: <LockKey/>
|
||||
},
|
||||
{
|
||||
title: "Messages",
|
||||
url: "messages",
|
||||
icon: <Envelope/>
|
||||
},
|
||||
{
|
||||
title: "Plugins",
|
||||
url: "plugins",
|
||||
icon: <Plug/>
|
||||
},
|
||||
{
|
||||
title: "Logs",
|
||||
url: "logs",
|
||||
icon: <Log/>
|
||||
},
|
||||
{
|
||||
title: "System",
|
||||
url: "system",
|
||||
icon: <Wrench/>
|
||||
}
|
||||
]
|
||||
|
||||
export const AdministrationView = withSideMenu("/administration", menuItems);
|
||||
export default AdministrationView;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user