WIP: Implement config in Frontend

This commit is contained in:
Simon Grimme
2024-09-10 16:28:26 +02:00
parent 0a3245ddf9
commit 3b97b6bbfa
23 changed files with 464 additions and 15697 deletions
@@ -0,0 +1,20 @@
import {useField} from "formik";
import {Checkbox} from "@nextui-org/react";
// @ts-ignore
const CheckboxInput = ({label, ...props}) => {
// @ts-ignore
const [field] = useField(props);
return (
<div className="flex flex-row flex-grow items-center gap-2 my-2">
<Checkbox
{...field}
id={field.name}>
{label}
</Checkbox>
</div>
);
}
export default CheckboxInput;
+2 -2
View File
@@ -8,12 +8,12 @@ const Input = ({label, ...props}) => {
const [field, meta] = useField(props);
return (
<div className="grid w-full max-w-sm items-center gap-1.5">
<div className="grid w-full max-w-sm items-center gap-2 my-2">
<NextUiInput
{...props}
{...field}
id={label}
placeholder={label}
label={label}
isInvalid={meta.touched && !!meta.error}
errorMessage={
<small className="flex flex-row items-center gap-1 text-danger">
+2 -2
View File
@@ -11,12 +11,12 @@ export default function ProfileMenu() {
{
label: "My Profile",
icon: <User/>,
onClick: () => navigate('/profile')
onClick: () => navigate('/profile/')
},
{
label: "Administration",
icon: <GearFine/>,
onClick: () => alert("Administration"),
onClick: () => navigate("/administration/libraries"),
showIf: state.user?.roles?.some(a => a?.includes("ADMIN"))
},
{
@@ -0,0 +1,29 @@
import ConfigEntryDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigEntryDto";
import React from "react";
import Input from "Frontend/components/Input";
import CheckboxInput from "Frontend/components/CheckboxInput";
export default function ConfigFormField({configElement}: {
configElement: ConfigEntryDto | undefined
}) {
function inputElement(configElement: ConfigEntryDto) {
switch (configElement.type) {
case "Boolean":
return (
<CheckboxInput label={configElement.description} name={configElement.key}/>
);
case "String":
return (
<Input label={configElement.description} name={configElement.key} type="text"/>
);
case "Int" || "Float":
return (
<Input label={configElement.description} name={configElement.key} type="number"/>
);
default:
return <pre>Unsupported type: {configElement.type} for key {configElement.key}</pre>;
}
}
return (inputElement(configElement!));
}
@@ -0,0 +1,153 @@
import React, {useEffect, useRef, useState} from "react";
import {ConfigController} from "Frontend/generated/endpoints";
import ConfigEntryDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigEntryDto";
import {Form, Formik} from "formik";
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import {Button, Divider, Skeleton} from "@nextui-org/react";
import {toast} from "sonner";
type NestedConfig = {
[field: string]: any;
}
type ConfigValuePair = {
key: string;
value: string | number | boolean | null | undefined;
}
export function LibraryManagement() {
const isInitialized = useRef(false);
const [configDtos, setConfigDtos] = useState<ConfigEntryDto[]>([]);
useEffect(() => {
ConfigController.getConfigs("library").then((response: any) => {
setConfigDtos(response as ConfigEntryDto[]);
isInitialized.current = true;
});
}, []);
async function handleSubmit(values: NestedConfig) {
const configValues = toConfigValuePair(values);
await Promise.all(configValues.map(async (c: ConfigValuePair) => {
if (c.value === null || c.value === undefined) {
await ConfigController.deleteConfig(c.key);
return;
}
await ConfigController.setConfig(c.key, c.value.toString());
}));
toast.success("Configuration saved");
}
function getConfig(key: string) {
return configDtos.find((configDto: ConfigEntryDto) => configDto.key === key);
}
function toNestedConfig(configArray: ConfigEntryDto[]): NestedConfig {
const nestedConfig: NestedConfig = {};
configArray.forEach(item => {
const keys = item.key!.split('.');
let currentLevel = nestedConfig;
// Traverse the nested structure and create objects as needed
keys.forEach((key, index) => {
if (index === keys.length - 1) {
// Convert value to the appropriate type
let value: any;
switch (item.type) {
case 'Boolean':
value = item.value === 'true';
break;
case 'Int':
value = parseInt(item.value!);
break;
case 'Float':
value = parseFloat(item.value!);
break;
case 'String':
default:
value = item.value;
break;
}
currentLevel[key] = value;
} else {
if (!currentLevel[key]) {
currentLevel[key] = {};
}
currentLevel = currentLevel[key];
}
});
});
return nestedConfig;
}
function toConfigValuePair(obj: NestedConfig, parentKey: string = ''): ConfigValuePair[] {
let result: ConfigValuePair[] = [];
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const newKey = parentKey ? `${parentKey}.${key}` : key;
if (typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
result = result.concat(toConfigValuePair(obj[key], newKey));
} else {
result.push({key: newKey, value: obj[key]});
}
}
}
return result;
}
if (!isInitialized.current) {
return (
<>
<Skeleton className="h-3 w-3/5 rounded-md"/>
<Skeleton className="h-3 w-4/5 rounded-md"/>
</>
)
}
return (
<Formik
initialValues={toNestedConfig(configDtos)}
onSubmit={handleSubmit}
>
{(formik: { values: any; isSubmitting: any; }) => (
<Form>
<div className="flex flex-row flex-grow justify-between mb-8">
<h1 className="text-2xl font-bold">Library Management</h1>
<Button
color="secondary"
isLoading={formik.isSubmitting}
type="submit"
>
{formik.isSubmitting ? "" : "Save"}
</Button>
</div>
<div className="mb-8 flex flex-col flex-grow">
<ConfigFormField configElement={getConfig("library.allow-public-access")}></ConfigFormField>
<ConfigFormField
configElement={getConfig("library.scan.enable-filesystem-watcher")}></ConfigFormField>
<h2 className="text-xl font-bold mt-4">Metadata</h2>
<Divider/>
<div className="flex flex-row">
<ConfigFormField
configElement={getConfig("library.metadata.update.enabled")}></ConfigFormField>
<ConfigFormField
configElement={getConfig("library.metadata.update.schedule")}></ConfigFormField>
</div>
<ConfigFormField
configElement={getConfig("library.display.games-per-page")}></ConfigFormField>
</div>
<pre>{JSON.stringify(formik.values, null, 2)}</pre>
</Form>
)}
</Formik>
);
}
@@ -52,7 +52,6 @@ const Wizard = ({children, initialValues, onSubmit}: {
{(formik: { values: any; isSubmitting: any; }) => (
<Form className="flex flex-col h-full">
<div className="w-full mb-8">
{/*<p>Step {stepNumber + 1} of {steps.length}</p>*/}
<Stepper activeStep={stepNumber} activeLineClassName="bg-primary"
lineClassName="bg-foreground"
placeholder={undefined}
+9
View File
@@ -7,6 +7,8 @@ import SetupView from "Frontend/views/SetupView";
import ProfileView from "Frontend/views/ProfileView";
import {ThemeSelector} from "Frontend/components/theming/ThemeSelector";
import App from "Frontend/App";
import AdministrationView from "Frontend/views/AdministrationView";
import {LibraryManagement} from "Frontend/components/administration/LibraryManagement";
export const routes = protectRoutes([
{
@@ -26,6 +28,13 @@ export const routes = protectRoutes([
children: [
{path: 'appearance', element: <ThemeSelector/>}
]
},
{
path: 'administration',
element: <AdministrationView/>,
children: [
{path: 'libraries', element: <LibraryManagement/>},
]
}
]
},
@@ -0,0 +1,13 @@
import {useEffect, useRef} from "react";
export default function useUpdateEffect(effect: Function, dependencies?: [any]) {
const isInitialMount = useRef(true);
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
} else {
return effect();
}
}, dependencies);
}
@@ -0,0 +1,45 @@
import {Outlet, useNavigate} from "react-router-dom";
import {Envelope, GameController, Users} from "@phosphor-icons/react";
import {Listbox, ListboxItem} from "@nextui-org/react";
export default function AdministrationView() {
const navigate = useNavigate();
const menuItems = [
{
title: "Libraries",
key: "libraries",
icon: <GameController/>,
action: () => navigate('libraries')
},
{
title: "Users",
key: "users",
icon: <Users/>,
action: () => navigate('users')
},
{
title: "Notifications",
icon: <Envelope/>,
key: "notifications",
action: () => navigate('notifications')
}
]
return (
<div className="flex flex-row">
<div className="flex flex-col pr-8">
<Listbox className="min-w-60">
{menuItems.map((i) => (
<ListboxItem key={i.key} onPress={i.action} startContent={i.icon}>
{i.title}
</ListboxItem>
))}
</Listbox>
</div>
<div className="flex flex-col flex-grow">
<Outlet/>
</div>
</div>
);
}
-1
View File
@@ -5,7 +5,6 @@ import {useState} from "react";
export default function ProfileView() {
const navigate = useNavigate();
const [selectedKeys, setSelectedKeys] = useState(new Set(["profile"]));
const menuItems = [
{