diff --git a/package-lock.json b/package-lock.json index 05aae72..71f95b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "classnames": "^2.5.1", "clsx": "^2.1.1", "construct-style-sheets-polyfill": "3.1.0", + "cron-validator": "^1.3.1", "date-fns": "2.29.3", "formik": "^2.4.6", "framer-motion": "^11.3.28", @@ -9309,6 +9310,12 @@ "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": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -21730,6 +21737,11 @@ "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": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", diff --git a/package.json b/package.json index 88364cc..d804b35 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "classnames": "^2.5.1", "clsx": "^2.1.1", "construct-style-sheets-polyfill": "3.1.0", + "cron-validator": "^1.3.1", "date-fns": "2.29.3", "formik": "^2.4.6", "framer-motion": "^11.3.28", @@ -116,7 +117,8 @@ "@vaadin/hilla-react-signals": "$@vaadin/hilla-react-signals", "@vaadin/vaadin-themable-mixin": "$@vaadin/vaadin-themable-mixin", "@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": { "dependencies": { @@ -175,6 +177,6 @@ "workbox-core": "7.1.0", "workbox-precaching": "7.1.0" }, - "hash": "7e2c61ec341a02c9a4778f045cbbda29ac1393776eb453e98b41899df49d0cdb" + "hash": "4a2abb2b0dc544e07b47be9cd538fec640d2646604044d77bd9ce5feecf4c3b3" } } diff --git a/src/main/frontend/App.tsx b/src/main/frontend/App.tsx index 1aab1bc..748e882 100644 --- a/src/main/frontend/App.tsx +++ b/src/main/frontend/App.tsx @@ -1,5 +1,6 @@ import {Outlet, useNavigate} from 'react-router-dom'; import "./main.css"; +import "Frontend/util/custom-validators"; import {NextUIProvider} from "@nextui-org/react"; import {ThemeProvider as NextThemesProvider} from "next-themes"; import {themeNames} from "Frontend/theming/themes"; diff --git a/src/main/frontend/components/ProfileMenu.tsx b/src/main/frontend/components/ProfileMenu.tsx index bb29261..2c3934e 100644 --- a/src/main/frontend/components/ProfileMenu.tsx +++ b/src/main/frontend/components/ProfileMenu.tsx @@ -11,7 +11,7 @@ export default function ProfileMenu() { { label: "My Profile", icon: , - onClick: () => navigate('/profile/') + onClick: () => navigate('/settings/profile') }, { label: "Administration", diff --git a/src/main/frontend/components/administration/ConfigFormField.tsx b/src/main/frontend/components/administration/ConfigFormField.tsx index d6c21ae..d9d44f5 100644 --- a/src/main/frontend/components/administration/ConfigFormField.tsx +++ b/src/main/frontend/components/administration/ConfigFormField.tsx @@ -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 ( - + ); case "String": return ( - + ); - case "Int" || "Float": + case "Float": return ( - + + ); + case "Int": + return ( + ); default: return
Unsupported type: {configElement.type} for key {configElement.key}
; diff --git a/src/main/frontend/components/administration/LibraryManagement.tsx b/src/main/frontend/components/administration/LibraryManagement.tsx index 7204ddb..28ab650 100644 --- a/src/main/frontend/components/administration/LibraryManagement.tsx +++ b/src/main/frontend/components/administration/LibraryManagement.tsx @@ -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([]); - - 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) => -
- - -
- - -
-
- ) - ) - } +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: { values: any; isSubmitting: any; }) => ( -
-
-

Library Management

+
- -
-
+
+ {/* TODO */} - - +
+ -

Metadata

- -
- - -
+
+ - -
- - )} - +
+
+ + +
+
); -} \ No newline at end of file +} + +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); \ No newline at end of file diff --git a/src/main/frontend/components/administration/ProfileManagement.tsx b/src/main/frontend/components/administration/ProfileManagement.tsx new file mode 100644 index 0000000..e848319 --- /dev/null +++ b/src/main/frontend/components/administration/ProfileManagement.tsx @@ -0,0 +1,11 @@ +import Section from "Frontend/components/general/Section"; + +export default function ProfileManagement() { + return ( +
+

My Profile

+
+ {/* TODO */} +
+ ); +} \ No newline at end of file diff --git a/src/main/frontend/components/administration/UserManagement.tsx b/src/main/frontend/components/administration/UserManagement.tsx new file mode 100644 index 0000000..68d5964 --- /dev/null +++ b/src/main/frontend/components/administration/UserManagement.tsx @@ -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 ( +
+ +
+ {/* TODO */} + +
+
+ + +
+
+ ); +} + +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); \ No newline at end of file diff --git a/src/main/frontend/components/administration/withConfigPage.tsx b/src/main/frontend/components/administration/withConfigPage.tsx new file mode 100644 index 0000000..5ba41e6 --- /dev/null +++ b/src/main/frontend/components/administration/withConfigPage.tsx @@ -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, title: String, configPrefix: string, validationSchema?: any) { + return function ConfigPage(props: any) { + const isInitialized = useRef(false); + const [configSaved, setConfigSaved] = useState(false); + const [configDtos, setConfigDtos] = useState([]); + + 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) => +
+ + +
+ + +
+
+ ) + ) + } + + return ( + + {(formik: { values: any; isSubmitting: any; }) => ( +
+
+

{title}

+ + +
+ + + + )} +
+ ); + } +} \ No newline at end of file diff --git a/src/main/frontend/components/CheckboxInput.tsx b/src/main/frontend/components/general/CheckboxInput.tsx similarity index 95% rename from src/main/frontend/components/CheckboxInput.tsx rename to src/main/frontend/components/general/CheckboxInput.tsx index b4b92ea..34f1859 100644 --- a/src/main/frontend/components/CheckboxInput.tsx +++ b/src/main/frontend/components/general/CheckboxInput.tsx @@ -10,6 +10,7 @@ const CheckboxInput = ({label, ...props}) => {
diff --git a/src/main/frontend/components/Input.tsx b/src/main/frontend/components/general/Input.tsx similarity index 100% rename from src/main/frontend/components/Input.tsx rename to src/main/frontend/components/general/Input.tsx diff --git a/src/main/frontend/components/general/Section.tsx b/src/main/frontend/components/general/Section.tsx new file mode 100644 index 0000000..c11d5b2 --- /dev/null +++ b/src/main/frontend/components/general/Section.tsx @@ -0,0 +1,10 @@ +import {Divider} from "@nextui-org/react"; + +export default function Section({title}: { title: string }) { + return ( + <> +

{title}

+ + + ); +} \ No newline at end of file diff --git a/src/main/frontend/main.css b/src/main/frontend/main.css index ff8b74c..cf46448 100644 --- a/src/main/frontend/main.css +++ b/src/main/frontend/main.css @@ -2,8 +2,12 @@ @tailwind components; @tailwind utilities; -@layer components { +@layer utilities { .gradient-primary { @apply bg-gradient-to-br from-primary-400 to-primary-700; } + + .button-secondary { + @apply bg-primary-300 text-background/80; + } } \ No newline at end of file diff --git a/src/main/frontend/routes.tsx b/src/main/frontend/routes.tsx index 76dfc55..f00cdd0 100644 --- a/src/main/frontend/routes.tsx +++ b/src/main/frontend/routes.tsx @@ -9,6 +9,8 @@ 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"; +import {UserManagement} from "Frontend/components/administration/UserManagement"; +import ProfileManagement from "Frontend/components/administration/ProfileManagement"; export const routes = protectRoutes([ { @@ -23,9 +25,10 @@ export const routes = protectRoutes([ index: true, element: }, { - path: 'profile', + path: 'settings', element: , children: [ + {path: 'profile', element: }, {path: 'appearance', element: } ] }, @@ -34,6 +37,7 @@ export const routes = protectRoutes([ element: , children: [ {path: 'libraries', element: }, + {path: 'users', element: }, ] } ] diff --git a/src/main/frontend/util/custom-validators.ts b/src/main/frontend/util/custom-validators.ts new file mode 100644 index 0000000..d6963f6 --- /dev/null +++ b/src/main/frontend/util/custom-validators.ts @@ -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'}); + }); +}); \ No newline at end of file diff --git a/src/main/frontend/views/ProfileView.tsx b/src/main/frontend/views/ProfileView.tsx index edd6666..e8fc3d2 100644 --- a/src/main/frontend/views/ProfileView.tsx +++ b/src/main/frontend/views/ProfileView.tsx @@ -1,7 +1,6 @@ import {Listbox, ListboxItem} from "@nextui-org/react"; import {GearFine, Palette, User} from "@phosphor-icons/react"; import {Outlet, useNavigate} from "react-router-dom"; -import {useState} from "react"; export default function ProfileView() { const navigate = useNavigate(); @@ -11,7 +10,7 @@ export default function ProfileView() { title: "My Profile", key: "profile", icon: , - action: () => navigate('/profile') + action: () => navigate('profile') }, { title: "Appearance", @@ -38,7 +37,7 @@ export default function ProfileView() { ))}
-
+
diff --git a/src/main/frontend/views/SetupView.tsx b/src/main/frontend/views/SetupView.tsx index e100433..d204c9b 100644 --- a/src/main/frontend/views/SetupView.tsx +++ b/src/main/frontend/views/SetupView.tsx @@ -2,7 +2,7 @@ import React from 'react'; import * as Yup from 'yup'; import Wizard from "Frontend/components/wizard/Wizard"; 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 {Card} from "@nextui-org/react"; import {SetupEndpoint} from "Frontend/generated/endpoints"; @@ -95,9 +95,9 @@ function SetupView() { onSubmit={ async (values: any) => { await SetupEndpoint.registerSuperAdmin({ - username: values.username, - password: values.password, - email: values.email + username: values.username, + password: values.password, + email: values.email }); toast.success("Setup finished", {description: "Have fun with Gameyfin!"}); navigate('/login'); diff --git a/src/main/kotlin/de/grimsi/gameyfin/config/ConfigProperties.kt b/src/main/kotlin/de/grimsi/gameyfin/config/ConfigProperties.kt index fcde9c9..0cd3a9b 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/config/ConfigProperties.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/config/ConfigProperties.kt @@ -31,7 +31,7 @@ sealed class ConfigProperties( ConfigProperties( Boolean::class, "library.metadata.update.enabled", - "Enable periodic refresh of video game meta-data", + "Enable periodic refresh of video game metadata", true ) @@ -43,22 +43,6 @@ sealed class ConfigProperties( "0 0 * * 0" ) - data object LibraryGamesPerPage : - ConfigProperties( - Int::class, - "library.display.games-per-page", - "How many games should be displayed per page", - 25 - ) - - data object LibraryRatingCutoff : - ConfigProperties( - Float::class, - "library.display.rating-cutoff", - "Minimum rating for games to be displayed", - 4.5f - ) - /** User management */ data object UsersAllowNewSignUps : ConfigProperties( Boolean::class, @@ -71,7 +55,7 @@ sealed class ConfigProperties( ConfigProperties( Boolean::class, "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 ) diff --git a/src/main/kotlin/de/grimsi/gameyfin/config/ConfigService.kt b/src/main/kotlin/de/grimsi/gameyfin/config/ConfigService.kt index 505f582..b750227 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/config/ConfigService.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/config/ConfigService.kt @@ -141,7 +141,7 @@ class ConfigService( return when (configProperty.type) { String::class -> value 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 else -> { throw RuntimeException("Unknown config type ${configProperty.type}: '$value' for key ${configProperty.key}") diff --git a/src/main/kotlin/de/grimsi/gameyfin/meta/development/DelayInterceptor.kt b/src/main/kotlin/de/grimsi/gameyfin/meta/development/DelayInterceptor.kt index b282455..42064d5 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/meta/development/DelayInterceptor.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/meta/development/DelayInterceptor.kt @@ -8,7 +8,7 @@ import org.springframework.web.servlet.HandlerInterceptor @Component -@Profile("dev") +@Profile("delay") class DelayInterceptor : HandlerInterceptor { override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {