mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-17 08:15:44 +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:
Generated
+12
@@ -34,6 +34,7 @@
|
|||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"construct-style-sheets-polyfill": "3.1.0",
|
"construct-style-sheets-polyfill": "3.1.0",
|
||||||
|
"cron-validator": "^1.3.1",
|
||||||
"date-fns": "2.29.3",
|
"date-fns": "2.29.3",
|
||||||
"formik": "^2.4.6",
|
"formik": "^2.4.6",
|
||||||
"framer-motion": "^11.3.28",
|
"framer-motion": "^11.3.28",
|
||||||
@@ -9309,6 +9310,12 @@
|
|||||||
"url": "https://opencollective.com/core-js"
|
"url": "https://opencollective.com/core-js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cron-validator": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cron-validator/-/cron-validator-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-C1HsxuPCY/5opR55G5/WNzyEGDWFVG+6GLrA+fW/sCTcP6A6NTjUP2AK7B8n2PyFs90kDG2qzwm8LMheADku6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.3",
|
"version": "7.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||||
@@ -21730,6 +21737,11 @@
|
|||||||
"browserslist": "^4.23.3"
|
"browserslist": "^4.23.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"cron-validator": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cron-validator/-/cron-validator-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-C1HsxuPCY/5opR55G5/WNzyEGDWFVG+6GLrA+fW/sCTcP6A6NTjUP2AK7B8n2PyFs90kDG2qzwm8LMheADku6A=="
|
||||||
|
},
|
||||||
"cross-spawn": {
|
"cross-spawn": {
|
||||||
"version": "7.0.3",
|
"version": "7.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||||
|
|||||||
+4
-2
@@ -29,6 +29,7 @@
|
|||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"construct-style-sheets-polyfill": "3.1.0",
|
"construct-style-sheets-polyfill": "3.1.0",
|
||||||
|
"cron-validator": "^1.3.1",
|
||||||
"date-fns": "2.29.3",
|
"date-fns": "2.29.3",
|
||||||
"formik": "^2.4.6",
|
"formik": "^2.4.6",
|
||||||
"framer-motion": "^11.3.28",
|
"framer-motion": "^11.3.28",
|
||||||
@@ -116,7 +117,8 @@
|
|||||||
"@vaadin/hilla-react-signals": "$@vaadin/hilla-react-signals",
|
"@vaadin/hilla-react-signals": "$@vaadin/hilla-react-signals",
|
||||||
"@vaadin/vaadin-themable-mixin": "$@vaadin/vaadin-themable-mixin",
|
"@vaadin/vaadin-themable-mixin": "$@vaadin/vaadin-themable-mixin",
|
||||||
"@vaadin/vaadin-lumo-styles": "$@vaadin/vaadin-lumo-styles",
|
"@vaadin/vaadin-lumo-styles": "$@vaadin/vaadin-lumo-styles",
|
||||||
"@vaadin/vaadin-material-styles": "$@vaadin/vaadin-material-styles"
|
"@vaadin/vaadin-material-styles": "$@vaadin/vaadin-material-styles",
|
||||||
|
"cron-validator": "$cron-validator"
|
||||||
},
|
},
|
||||||
"vaadin": {
|
"vaadin": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -175,6 +177,6 @@
|
|||||||
"workbox-core": "7.1.0",
|
"workbox-core": "7.1.0",
|
||||||
"workbox-precaching": "7.1.0"
|
"workbox-precaching": "7.1.0"
|
||||||
},
|
},
|
||||||
"hash": "7e2c61ec341a02c9a4778f045cbbda29ac1393776eb453e98b41899df49d0cdb"
|
"hash": "4a2abb2b0dc544e07b47be9cd538fec640d2646604044d77bd9ce5feecf4c3b3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {Outlet, useNavigate} from 'react-router-dom';
|
import {Outlet, useNavigate} from 'react-router-dom';
|
||||||
import "./main.css";
|
import "./main.css";
|
||||||
|
import "Frontend/util/custom-validators";
|
||||||
import {NextUIProvider} from "@nextui-org/react";
|
import {NextUIProvider} from "@nextui-org/react";
|
||||||
import {ThemeProvider as NextThemesProvider} from "next-themes";
|
import {ThemeProvider as NextThemesProvider} from "next-themes";
|
||||||
import {themeNames} from "Frontend/theming/themes";
|
import {themeNames} from "Frontend/theming/themes";
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function ProfileMenu() {
|
|||||||
{
|
{
|
||||||
label: "My Profile",
|
label: "My Profile",
|
||||||
icon: <User/>,
|
icon: <User/>,
|
||||||
onClick: () => navigate('/profile/')
|
onClick: () => navigate('/settings/profile')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Administration",
|
label: "Administration",
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
import ConfigEntryDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigEntryDto";
|
import ConfigEntryDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigEntryDto";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import Input from "Frontend/components/Input";
|
import Input from "Frontend/components/general/Input";
|
||||||
import CheckboxInput from "Frontend/components/CheckboxInput";
|
import CheckboxInput from "Frontend/components/general/CheckboxInput";
|
||||||
|
|
||||||
export default function ConfigFormField({configElement}: {
|
export default function ConfigFormField({configElement, ...props}: any) {
|
||||||
configElement: ConfigEntryDto | undefined
|
|
||||||
}) {
|
|
||||||
function inputElement(configElement: ConfigEntryDto) {
|
function inputElement(configElement: ConfigEntryDto) {
|
||||||
switch (configElement.type) {
|
switch (configElement.type) {
|
||||||
case "Boolean":
|
case "Boolean":
|
||||||
return (
|
return (
|
||||||
<CheckboxInput label={configElement.description} name={configElement.key}/>
|
<CheckboxInput label={configElement.description} name={configElement.key} {...props}/>
|
||||||
);
|
);
|
||||||
case "String":
|
case "String":
|
||||||
return (
|
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 (
|
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:
|
default:
|
||||||
return <pre>Unsupported type: {configElement.type} for key {configElement.key}</pre>;
|
return <pre>Unsupported type: {configElement.type} for key {configElement.key}</pre>;
|
||||||
|
|||||||
@@ -1,168 +1,41 @@
|
|||||||
import React, {useEffect, useRef, useState} from "react";
|
import React 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 ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
||||||
import {Button, Divider, Skeleton} from "@nextui-org/react";
|
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
||||||
import {toast} from "sonner";
|
import Section from "Frontend/components/general/Section";
|
||||||
import {Check} from "@phosphor-icons/react";
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
type NestedConfig = {
|
function LibraryManagementLayout({getConfig, formik}: any) {
|
||||||
[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 (
|
return (
|
||||||
[...Array(4)].map((e, i) =>
|
<div className="flex flex-col">
|
||||||
<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 (
|
<Section title="Library"/>
|
||||||
<Formik
|
{/* TODO */}
|
||||||
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
|
<Section title="Permissions"/>
|
||||||
color="secondary"
|
<ConfigFormField configElement={getConfig("library.allow-public-access")}/>
|
||||||
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">
|
|
||||||
|
|
||||||
<ConfigFormField configElement={getConfig("library.allow-public-access")}></ConfigFormField>
|
<Section title="Scanning"/>
|
||||||
<ConfigFormField
|
<ConfigFormField configElement={getConfig("library.scan.enable-filesystem-watcher")}/>
|
||||||
configElement={getConfig("library.scan.enable-filesystem-watcher")}></ConfigFormField>
|
|
||||||
|
|
||||||
<h2 className="text-xl font-bold mt-4">Metadata</h2>
|
<Section title="Metadata"/>
|
||||||
<Divider/>
|
|
||||||
<div className="flex flex-row">
|
<div className="flex flex-row">
|
||||||
<ConfigFormField
|
<ConfigFormField configElement={getConfig("library.metadata.update.enabled")}/>
|
||||||
configElement={getConfig("library.metadata.update.enabled")}></ConfigFormField>
|
<ConfigFormField configElement={getConfig("library.metadata.update.schedule")}
|
||||||
<ConfigFormField
|
isDisabled={!formik.values.library.metadata.update.enabled}/>
|
||||||
configElement={getConfig("library.metadata.update.schedule")}></ConfigFormField>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfigFormField
|
|
||||||
configElement={getConfig("library.display.games-per-page")}></ConfigFormField>
|
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</Formik>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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">
|
<div className="flex flex-row flex-grow items-center gap-2 my-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
{...field}
|
{...field}
|
||||||
|
{...props}
|
||||||
id={field.name}
|
id={field.name}
|
||||||
isSelected={field.value}
|
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"/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,8 +2,12 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer components {
|
@layer utilities {
|
||||||
.gradient-primary {
|
.gradient-primary {
|
||||||
@apply bg-gradient-to-br from-primary-400 to-primary-700;
|
@apply bg-gradient-to-br from-primary-400 to-primary-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button-secondary {
|
||||||
|
@apply bg-primary-300 text-background/80;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,8 @@ import {ThemeSelector} from "Frontend/components/theming/ThemeSelector";
|
|||||||
import App from "Frontend/App";
|
import App from "Frontend/App";
|
||||||
import AdministrationView from "Frontend/views/AdministrationView";
|
import AdministrationView from "Frontend/views/AdministrationView";
|
||||||
import {LibraryManagement} from "Frontend/components/administration/LibraryManagement";
|
import {LibraryManagement} from "Frontend/components/administration/LibraryManagement";
|
||||||
|
import {UserManagement} from "Frontend/components/administration/UserManagement";
|
||||||
|
import ProfileManagement from "Frontend/components/administration/ProfileManagement";
|
||||||
|
|
||||||
export const routes = protectRoutes([
|
export const routes = protectRoutes([
|
||||||
{
|
{
|
||||||
@@ -23,9 +25,10 @@ export const routes = protectRoutes([
|
|||||||
index: true, element: <TestView/>
|
index: true, element: <TestView/>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'profile',
|
path: 'settings',
|
||||||
element: <ProfileView/>,
|
element: <ProfileView/>,
|
||||||
children: [
|
children: [
|
||||||
|
{path: 'profile', element: <ProfileManagement/>},
|
||||||
{path: 'appearance', element: <ThemeSelector/>}
|
{path: 'appearance', element: <ThemeSelector/>}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -34,6 +37,7 @@ export const routes = protectRoutes([
|
|||||||
element: <AdministrationView/>,
|
element: <AdministrationView/>,
|
||||||
children: [
|
children: [
|
||||||
{path: 'libraries', element: <LibraryManagement/>},
|
{path: 'libraries', element: <LibraryManagement/>},
|
||||||
|
{path: 'users', element: <UserManagement/>},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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'});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import {Listbox, ListboxItem} from "@nextui-org/react";
|
import {Listbox, ListboxItem} from "@nextui-org/react";
|
||||||
import {GearFine, Palette, User} from "@phosphor-icons/react";
|
import {GearFine, Palette, User} from "@phosphor-icons/react";
|
||||||
import {Outlet, useNavigate} from "react-router-dom";
|
import {Outlet, useNavigate} from "react-router-dom";
|
||||||
import {useState} from "react";
|
|
||||||
|
|
||||||
export default function ProfileView() {
|
export default function ProfileView() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -11,7 +10,7 @@ export default function ProfileView() {
|
|||||||
title: "My Profile",
|
title: "My Profile",
|
||||||
key: "profile",
|
key: "profile",
|
||||||
icon: <User/>,
|
icon: <User/>,
|
||||||
action: () => navigate('/profile')
|
action: () => navigate('profile')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Appearance",
|
title: "Appearance",
|
||||||
@@ -38,7 +37,7 @@ export default function ProfileView() {
|
|||||||
))}
|
))}
|
||||||
</Listbox>
|
</Listbox>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col flex-grow">
|
||||||
<Outlet/>
|
<Outlet/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
import Wizard from "Frontend/components/wizard/Wizard";
|
import Wizard from "Frontend/components/wizard/Wizard";
|
||||||
import WizardStep from "Frontend/components/wizard/WizardStep";
|
import WizardStep from "Frontend/components/wizard/WizardStep";
|
||||||
import Input from "Frontend/components/Input";
|
import Input from "Frontend/components/general/Input";
|
||||||
import {GearFine, HandWaving, Palette, User} from "@phosphor-icons/react";
|
import {GearFine, HandWaving, Palette, User} from "@phosphor-icons/react";
|
||||||
import {Card} from "@nextui-org/react";
|
import {Card} from "@nextui-org/react";
|
||||||
import {SetupEndpoint} from "Frontend/generated/endpoints";
|
import {SetupEndpoint} from "Frontend/generated/endpoints";
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ sealed class ConfigProperties<T : Serializable>(
|
|||||||
ConfigProperties<Boolean>(
|
ConfigProperties<Boolean>(
|
||||||
Boolean::class,
|
Boolean::class,
|
||||||
"library.metadata.update.enabled",
|
"library.metadata.update.enabled",
|
||||||
"Enable periodic refresh of video game meta-data",
|
"Enable periodic refresh of video game metadata",
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,22 +43,6 @@ sealed class ConfigProperties<T : Serializable>(
|
|||||||
"0 0 * * 0"
|
"0 0 * * 0"
|
||||||
)
|
)
|
||||||
|
|
||||||
data object LibraryGamesPerPage :
|
|
||||||
ConfigProperties<Int>(
|
|
||||||
Int::class,
|
|
||||||
"library.display.games-per-page",
|
|
||||||
"How many games should be displayed per page",
|
|
||||||
25
|
|
||||||
)
|
|
||||||
|
|
||||||
data object LibraryRatingCutoff :
|
|
||||||
ConfigProperties<Float>(
|
|
||||||
Float::class,
|
|
||||||
"library.display.rating-cutoff",
|
|
||||||
"Minimum rating for games to be displayed",
|
|
||||||
4.5f
|
|
||||||
)
|
|
||||||
|
|
||||||
/** User management */
|
/** User management */
|
||||||
data object UsersAllowNewSignUps : ConfigProperties<Boolean>(
|
data object UsersAllowNewSignUps : ConfigProperties<Boolean>(
|
||||||
Boolean::class,
|
Boolean::class,
|
||||||
@@ -71,7 +55,7 @@ sealed class ConfigProperties<T : Serializable>(
|
|||||||
ConfigProperties<Boolean>(
|
ConfigProperties<Boolean>(
|
||||||
Boolean::class,
|
Boolean::class,
|
||||||
"users.sign-ups.confirm",
|
"users.sign-ups.confirm",
|
||||||
"Admins need to confirm new sign-ups before they are allowed to log in",
|
"Admins need to confirm new users",
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ class ConfigService(
|
|||||||
return when (configProperty.type) {
|
return when (configProperty.type) {
|
||||||
String::class -> value as T
|
String::class -> value as T
|
||||||
Boolean::class -> value.toBoolean() as T
|
Boolean::class -> value.toBoolean() as T
|
||||||
Int::class -> value.toInt() as T
|
Int::class -> value.toFloat().toInt() as T
|
||||||
Float::class -> value.toFloat() as T
|
Float::class -> value.toFloat() as T
|
||||||
else -> {
|
else -> {
|
||||||
throw RuntimeException("Unknown config type ${configProperty.type}: '$value' for key ${configProperty.key}")
|
throw RuntimeException("Unknown config type ${configProperty.type}: '$value' for key ${configProperty.key}")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import org.springframework.web.servlet.HandlerInterceptor
|
|||||||
|
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@Profile("dev")
|
@Profile("delay")
|
||||||
class DelayInterceptor : HandlerInterceptor {
|
class DelayInterceptor : HandlerInterceptor {
|
||||||
|
|
||||||
override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
|
override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
|
||||||
|
|||||||
Reference in New Issue
Block a user