mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-15 16:20:03 +00:00
Add "My Profile" Page
Make integer parsing in ConfigService more robust Add validation to config pages Implement Cron expression validator
This commit is contained in:
@@ -11,7 +11,7 @@ export default function ProfileMenu() {
|
||||
{
|
||||
label: "My Profile",
|
||||
icon: <User/>,
|
||||
onClick: () => navigate('/profile/')
|
||||
onClick: () => navigate('/settings/profile')
|
||||
},
|
||||
{
|
||||
label: "Administration",
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
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";
|
||||
import Input from "Frontend/components/general/Input";
|
||||
import CheckboxInput from "Frontend/components/general/CheckboxInput";
|
||||
|
||||
export default function ConfigFormField({configElement}: {
|
||||
configElement: ConfigEntryDto | undefined
|
||||
}) {
|
||||
export default function ConfigFormField({configElement, ...props}: any) {
|
||||
function inputElement(configElement: ConfigEntryDto) {
|
||||
switch (configElement.type) {
|
||||
case "Boolean":
|
||||
return (
|
||||
<CheckboxInput label={configElement.description} name={configElement.key}/>
|
||||
<CheckboxInput label={configElement.description} name={configElement.key} {...props}/>
|
||||
);
|
||||
case "String":
|
||||
return (
|
||||
<Input label={configElement.description} name={configElement.key} type="text"/>
|
||||
<Input label={configElement.description} name={configElement.key} type="text" {...props}/>
|
||||
);
|
||||
case "Int" || "Float":
|
||||
case "Float":
|
||||
return (
|
||||
<Input label={configElement.description} name={configElement.key} type="number"/>
|
||||
<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}/>
|
||||
);
|
||||
default:
|
||||
return <pre>Unsupported type: {configElement.type} for key {configElement.key}</pre>;
|
||||
|
||||
@@ -1,168 +1,41 @@
|
||||
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 React from "react";
|
||||
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
||||
import {Button, Divider, Skeleton} from "@nextui-org/react";
|
||||
import {toast} from "sonner";
|
||||
import {Check} from "@phosphor-icons/react";
|
||||
|
||||
type NestedConfig = {
|
||||
[field: string]: any;
|
||||
}
|
||||
|
||||
type ConfigValuePair = {
|
||||
key: string;
|
||||
value: string | number | boolean | null | undefined;
|
||||
}
|
||||
|
||||
export function LibraryManagement() {
|
||||
const isInitialized = useRef(false);
|
||||
const [configSaved, setConfigSaved] = useState(false);
|
||||
const [configDtos, setConfigDtos] = useState<ConfigEntryDto[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
ConfigController.getConfigs("library").then((response: any) => {
|
||||
setConfigDtos(response as ConfigEntryDto[]);
|
||||
isInitialized.current = true;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (configSaved) {
|
||||
setTimeout(() => setConfigSaved(false), 2000);
|
||||
}
|
||||
}, [configSaved])
|
||||
|
||||
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());
|
||||
}));
|
||||
|
||||
setConfigSaved(true);
|
||||
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 (
|
||||
[...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>
|
||||
)
|
||||
)
|
||||
}
|
||||
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
||||
import Section from "Frontend/components/general/Section";
|
||||
import * as Yup from 'yup';
|
||||
|
||||
function LibraryManagementLayout({getConfig, formik}: any) {
|
||||
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>
|
||||
<div className="flex flex-col">
|
||||
|
||||
<Button
|
||||
color="secondary"
|
||||
isLoading={formik.isSubmitting}
|
||||
disabled={formik.isSubmitting || configSaved}
|
||||
type="submit"
|
||||
>
|
||||
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-8 flex flex-col flex-grow">
|
||||
<Section title="Library"/>
|
||||
{/* TODO */}
|
||||
|
||||
<ConfigFormField configElement={getConfig("library.allow-public-access")}></ConfigFormField>
|
||||
<ConfigFormField
|
||||
configElement={getConfig("library.scan.enable-filesystem-watcher")}></ConfigFormField>
|
||||
<Section title="Permissions"/>
|
||||
<ConfigFormField configElement={getConfig("library.allow-public-access")}/>
|
||||
|
||||
<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>
|
||||
<Section title="Scanning"/>
|
||||
<ConfigFormField configElement={getConfig("library.scan.enable-filesystem-watcher")}/>
|
||||
|
||||
<ConfigFormField
|
||||
configElement={getConfig("library.display.games-per-page")}></ConfigFormField>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
<Section title="Metadata"/>
|
||||
<div className="flex flex-row">
|
||||
<ConfigFormField configElement={getConfig("library.metadata.update.enabled")}/>
|
||||
<ConfigFormField configElement={getConfig("library.metadata.update.schedule")}
|
||||
isDisabled={!formik.values.library.metadata.update.enabled}/>
|
||||
</div>
|
||||
</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", "library", validationSchema);
|
||||
@@ -0,0 +1,11 @@
|
||||
import Section from "Frontend/components/general/Section";
|
||||
|
||||
export default function ProfileManagement() {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-2xl font-bold">My Profile</h2>
|
||||
<Section title="Personal information"/>
|
||||
{/* TODO */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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";
|
||||
|
||||
function UserManagementLayout({getConfig, formik}: any) {
|
||||
return (
|
||||
<div className="flex flex-col flex-grow">
|
||||
|
||||
<Section title="Users"/>
|
||||
{/* TODO */}
|
||||
|
||||
<Section title="Sign-Ups"/>
|
||||
<div className="flex flex-row">
|
||||
<ConfigFormField configElement={getConfig("users.sign-ups.allow")}/>
|
||||
<ConfigFormField configElement={getConfig("users.sign-ups.confirm")}
|
||||
isDisabled={!formik.values.users["sign-ups"].allow}/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
library: Yup.object({
|
||||
metadata: Yup.object({
|
||||
update: Yup.object({
|
||||
// @ts-ignore
|
||||
schedule: Yup.string().cron()
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
export const UserManagement = withConfigPage(UserManagementLayout, "User Management", "users", validationSchema);
|
||||
@@ -0,0 +1,152 @@
|
||||
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 {Button, Skeleton} from "@nextui-org/react";
|
||||
import {Check} from "@phosphor-icons/react";
|
||||
|
||||
type NestedConfig = {
|
||||
[field: string]: any;
|
||||
}
|
||||
|
||||
type ConfigValuePair = {
|
||||
key: string;
|
||||
value: string | number | boolean | null | undefined;
|
||||
}
|
||||
|
||||
export default function withConfigPage(WrappedComponent: React.ComponentType<any>, title: String, configPrefix: string, validationSchema?: any) {
|
||||
return function ConfigPage(props: any) {
|
||||
const isInitialized = useRef(false);
|
||||
const [configSaved, setConfigSaved] = useState(false);
|
||||
const [configDtos, setConfigDtos] = useState<ConfigEntryDto[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
ConfigController.getConfigs(configPrefix).then((response: any) => {
|
||||
setConfigDtos(response as ConfigEntryDto[]);
|
||||
isInitialized.current = true;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (configSaved) {
|
||||
setTimeout(() => setConfigSaved(false), 2000);
|
||||
}
|
||||
}, [configSaved])
|
||||
|
||||
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());
|
||||
}));
|
||||
|
||||
setConfigSaved(true);
|
||||
}
|
||||
|
||||
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 (
|
||||
[...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>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={toNestedConfig(configDtos)}
|
||||
onSubmit={handleSubmit}
|
||||
validationSchema={validationSchema}
|
||||
>
|
||||
{(formik: { values: any; isSubmitting: any; }) => (
|
||||
<Form>
|
||||
<div className="flex flex-row flex-grow justify-between mb-8">
|
||||
<h1 className="text-2xl font-bold">{title}</h1>
|
||||
|
||||
<Button
|
||||
className="button-secondary"
|
||||
isLoading={formik.isSubmitting}
|
||||
disabled={formik.isSubmitting || configSaved}
|
||||
type="submit"
|
||||
>
|
||||
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<WrappedComponent {...props} getConfig={getConfig} formik={formik}/>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
@@ -10,6 +10,7 @@ const CheckboxInput = ({label, ...props}) => {
|
||||
<div className="flex flex-row flex-grow items-center gap-2 my-2">
|
||||
<Checkbox
|
||||
{...field}
|
||||
{...props}
|
||||
id={field.name}
|
||||
isSelected={field.value}
|
||||
>
|
||||
@@ -0,0 +1,10 @@
|
||||
import {Divider} from "@nextui-org/react";
|
||||
|
||||
export default function Section({title}: { title: string }) {
|
||||
return (
|
||||
<>
|
||||
<h2 className={"text-xl font-bold mt-8"}>{title}</h2>
|
||||
<Divider className="mb-4"/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user