From 03d635a997591387b05c48492da479cb34c725d1 Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Sat, 17 May 2025 18:55:17 +0200 Subject: [PATCH] Enable real-time UI starting with config pages Implement client side state caching and updating via websocket Simplify config REST endpoints --- gameyfin/package-lock.json | 31 +++ gameyfin/package.json | 6 +- .../administration/LibraryManagement.tsx | 2 +- .../administration/LogManagement.tsx | 2 +- .../administration/MessageManagement.tsx | 2 +- .../administration/SsoManagement.tsx | 2 +- .../administration/SystemManagement.tsx | 2 +- .../administration/UserManagement.tsx | 12 +- .../administration/withConfigPage.tsx | 236 +++++++----------- .../components/general/input/SelectInput.tsx | 2 +- .../src/main/frontend/state/ConfigState.ts | 110 ++++++++ .../grimsi/gameyfin/config/ConfigEndpoint.kt | 34 +-- .../grimsi/gameyfin/config/ConfigService.kt | 15 +- ...nfigValuePairDto.kt => ConfigUpdateDto.kt} | 8 +- 14 files changed, 276 insertions(+), 188 deletions(-) create mode 100644 gameyfin/src/main/frontend/state/ConfigState.ts rename gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/dto/{ConfigValuePairDto.kt => ConfigUpdateDto.kt} (57%) diff --git a/gameyfin/package-lock.json b/gameyfin/package-lock.json index 39c47f8..c35e260 100644 --- a/gameyfin/package-lock.json +++ b/gameyfin/package-lock.json @@ -53,6 +53,7 @@ "react-router": "7.5.2", "remark-breaks": "^4.0.0", "swiper": "^11.2.6", + "valtio": "^2.1.5", "yup": "^1.6.1" }, "devDependencies": { @@ -15816,6 +15817,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/proxy-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.1.tgz", + "integrity": "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==", + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", @@ -18159,6 +18166,30 @@ "node": ">= 0.10" } }, + "node_modules/valtio": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/valtio/-/valtio-2.1.5.tgz", + "integrity": "sha512-vsh1Ixu5mT0pJFZm+Jspvhga5GzHUTYv0/+Th203pLfh3/wbHwxhu/Z2OkZDXIgHfjnjBns7SN9HNcbDvPmaGw==", + "license": "MIT", + "dependencies": { + "proxy-compare": "^3.0.1" + }, + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "react": ">=18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/gameyfin/package.json b/gameyfin/package.json index 8e93ee6..d776053 100644 --- a/gameyfin/package.json +++ b/gameyfin/package.json @@ -48,6 +48,7 @@ "react-router": "7.5.2", "remark-breaks": "^4.0.0", "swiper": "^11.2.6", + "valtio": "^2.1.5", "yup": "^1.6.1" }, "devDependencies": { @@ -136,7 +137,8 @@ "swiper": "$swiper", "react-player": "$react-player", "react-markdown": "$react-markdown", - "remark-breaks": "$remark-breaks" + "remark-breaks": "$remark-breaks", + "valtio": "$valtio" }, "vaadin": { "dependencies": { @@ -197,6 +199,6 @@ "workbox-core": "7.3.0", "workbox-precaching": "7.3.0" }, - "hash": "f66bddf01ef8dd09a431102050ddd561b4587fdd43bcbff127b93872febbee92" + "hash": "c57af53043f6c6a9f0b03c75c28c3fde0bbfd828f9ce0e179263959c61ec888d" } } \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/administration/LibraryManagement.tsx b/gameyfin/src/main/frontend/components/administration/LibraryManagement.tsx index 5f67a4e..2d9da93 100644 --- a/gameyfin/src/main/frontend/components/administration/LibraryManagement.tsx +++ b/gameyfin/src/main/frontend/components/administration/LibraryManagement.tsx @@ -119,4 +119,4 @@ const validationSchema = Yup.object({ }) }); -export const LibraryManagement = withConfigPage(LibraryManagementLayout, "Library Management", "library", validationSchema); \ No newline at end of file +export const LibraryManagement = withConfigPage(LibraryManagementLayout, "Library Management", validationSchema); \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/administration/LogManagement.tsx b/gameyfin/src/main/frontend/components/administration/LogManagement.tsx index 3172f67..f7dedc2 100644 --- a/gameyfin/src/main/frontend/components/administration/LogManagement.tsx +++ b/gameyfin/src/main/frontend/components/administration/LogManagement.tsx @@ -94,4 +94,4 @@ const validationSchema = Yup.object({ }) }); -export const LogManagement = withConfigPage(LogManagementLayout, "Logging", "logs", validationSchema); \ No newline at end of file +export const LogManagement = withConfigPage(LogManagementLayout, "Logging", validationSchema); \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/administration/MessageManagement.tsx b/gameyfin/src/main/frontend/components/administration/MessageManagement.tsx index 36a57ae..d30dcd2 100644 --- a/gameyfin/src/main/frontend/components/administration/MessageManagement.tsx +++ b/gameyfin/src/main/frontend/components/administration/MessageManagement.tsx @@ -9,7 +9,7 @@ import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/messages/t import SendTestNotificationModal from "Frontend/components/administration/messages/SendTestNotificationModal"; import EditTemplateModal from "Frontend/components/administration/messages/EditTemplateModel"; -function MessageManagementLayout({getConfig, getConfigs, formik}: any) { +function MessageManagementLayout({getConfig, formik}: any) { const editorModal = useDisclosure(); const testNotificationModal = useDisclosure(); diff --git a/gameyfin/src/main/frontend/components/administration/SsoManagement.tsx b/gameyfin/src/main/frontend/components/administration/SsoManagement.tsx index 3d361dc..0a29bad 100644 --- a/gameyfin/src/main/frontend/components/administration/SsoManagement.tsx +++ b/gameyfin/src/main/frontend/components/administration/SsoManagement.tsx @@ -121,4 +121,4 @@ const validationSchema = Yup.object({ }) }); -export const SsoManagement = withConfigPage(SsoManagementLayout, "Single Sign-On", "sso", validationSchema); \ No newline at end of file +export const SsoManagement = withConfigPage(SsoManagementLayout, "Single Sign-On", validationSchema); \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/administration/SystemManagement.tsx b/gameyfin/src/main/frontend/components/administration/SystemManagement.tsx index 07ebc50..40da042 100644 --- a/gameyfin/src/main/frontend/components/administration/SystemManagement.tsx +++ b/gameyfin/src/main/frontend/components/administration/SystemManagement.tsx @@ -26,4 +26,4 @@ function SystemManagementLayout({getConfig, formik, setSaveMessage}: any) { ); } -export const SystemManagement = withConfigPage(SystemManagementLayout, "System", "system", null); \ No newline at end of file +export const SystemManagement = withConfigPage(SystemManagementLayout, "System"); \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/administration/UserManagement.tsx b/gameyfin/src/main/frontend/components/administration/UserManagement.tsx index 6d2beca..14a7619 100644 --- a/gameyfin/src/main/frontend/components/administration/UserManagement.tsx +++ b/gameyfin/src/main/frontend/components/administration/UserManagement.tsx @@ -2,27 +2,25 @@ 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 {ConfigEndpoint, UserEndpoint} from "Frontend/generated/endpoints"; +import {UserEndpoint} from "Frontend/generated/endpoints"; import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/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"; +import {useSnapshot} from "valtio/react"; +import {configState} from "Frontend/state/ConfigState"; function UserManagementLayout({getConfig, formik}: any) { const inviteUserModal = useDisclosure(); const [users, setUsers] = useState([]); - const [autoRegisterNewUsers, setAutoRegisterNewUsers] = useState(true); + const autoRegisterNewUsers = useSnapshot(configState); useEffect(() => { UserEndpoint.getAllUsers().then( (response) => setUsers(response) ); - - ConfigEndpoint.get("sso.oidc.auto-register-new-users").then( - (response) => setAutoRegisterNewUsers(response as boolean) - ); }, []); return ( @@ -56,4 +54,4 @@ function UserManagementLayout({getConfig, formik}: any) { ); } -export const UserManagement = withConfigPage(UserManagementLayout, "User Management", "users"); \ No newline at end of file +export const UserManagement = withConfigPage(UserManagementLayout, "User Management"); \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/administration/withConfigPage.tsx b/gameyfin/src/main/frontend/components/administration/withConfigPage.tsx index addb458..5bf9f48 100644 --- a/gameyfin/src/main/frontend/components/administration/withConfigPage.tsx +++ b/gameyfin/src/main/frontend/components/administration/withConfigPage.tsx @@ -1,30 +1,22 @@ -import React, {useEffect, useRef, useState} from "react"; +import React, {useEffect, useState} from "react"; import {ConfigEndpoint} from "Frontend/generated/endpoints"; import ConfigEntryDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigEntryDto"; import {Form, Formik} from "formik"; import {Button, Skeleton} from "@heroui/react"; import {Check, Info} from "@phosphor-icons/react"; -import ConfigValuePairDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigValuePairDto"; import {SmallInfoField} from "Frontend/components/general/SmallInfoField"; +import {configState, initializeConfig, NestedConfig} from "Frontend/state/ConfigState"; +import {useSnapshot} from "valtio/react"; -type NestedConfig = { - [field: string]: any; -} - -export default function withConfigPage(WrappedComponent: React.ComponentType, title: String, configPrefix: string, validationSchema?: any) { +export default function withConfigPage(WrappedComponent: React.ComponentType, title: String, validationSchema?: any) { return function ConfigPage(props: any) { - const isInitialized = useRef(false); const [configSaved, setConfigSaved] = useState(false); - const [configDtos, setConfigDtos] = useState([]); - const [nestedConfigDtos, setNestedConfigDtos] = useState({}); const [saveMessage, setSaveMessage] = useState(); + const state = useSnapshot(configState); + useEffect(() => { - ConfigEndpoint.getAll(configPrefix).then((response: any) => { - setConfigDtos(response as ConfigEntryDto[]); - setNestedConfigDtos(toNestedConfig(response as ConfigEntryDto[])); - isInitialized.current = true; - }); + initializeConfig(); }, []); useEffect(() => { @@ -34,149 +26,111 @@ export default function withConfigPage(WrappedComponent: React.ComponentType { - const configValues = toConfigValuePair(values); - await ConfigEndpoint.setAll(configValues); - setNestedConfigDtos(values); + const changed = getChangedValues(state.configNested, values); + await ConfigEndpoint.update({updates: changed}); setConfigSaved(true); } function getConfig(key: string): ConfigEntryDto | undefined { - return configDtos.find((configDto: ConfigEntryDto) => configDto.key === key); + // @ts-ignore + return state.configEntries[key]; } - function getConfigs(prefix: string): ConfigEntryDto[] { - return configDtos.filter((configDto: ConfigEntryDto) => configDto.key?.startsWith(prefix)); - } - - 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 = typeof item.value == 'boolean' ? item.value : item.value === 'true'; - break; - case 'Int': - value = typeof item.value == 'number' ? item.value : 0; - break; - case 'Float': - value = typeof item.value == 'number' ? item.value : 0.0; - break; - case 'Array': - if (Array.isArray(item.value)) { - switch (item.elementType) { - case 'Boolean': - value = item.value.map(v => typeof v === 'boolean' ? v : v === 'true'); - break; - case 'Int': - case 'Integer': - value = item.value.map(v => typeof v == 'number' ? v : 0); - break; - case 'Float': - value = item.value.map(v => typeof v == 'number' ? v : 0.0); - break; - case 'String': - default: - value = item.value.map(v => v.toString()); - break; - } - } else { - value = []; - } - break; - case 'String': - default: - value = item.value; - break; + function getChangedValues(initial: NestedConfig, current: NestedConfig): Record { + const flatten = (obj: NestedConfig, parentKey = ''): Record => { + let result: Record = {}; + 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]; } - currentLevel[key] = value; - } else { - if (!currentLevel[key]) { - currentLevel[key] = {}; - } - currentLevel = currentLevel[key]; - } - }); - }); - return nestedConfig; - } - - function toConfigValuePair(obj: NestedConfig, parentKey: string = ''): ConfigValuePairDto[] { - let result: ConfigValuePairDto[] = []; - - 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; + }; + + 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 = {}; + 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 result; - } - - if (!isInitialized.current) { - return ( - [...Array(4)].map((_e, i) => -
- - -
- - -
-
- ) - ) + return changed; } return ( - - {(formik) => ( -
-
-

{title}

+ <> + {state.isLoaded ? + + {(formik) => ( + +
+

{title}

-
- {saveMessage && } +
+ {saveMessage && } - + +
+
+ + + + )} + : + [...Array(4)].map((_e, i) => +
+ + +
+ +
- - - - )} - + ) + } + ); } } \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/general/input/SelectInput.tsx b/gameyfin/src/main/frontend/components/general/input/SelectInput.tsx index c7d8d56..85165d4 100644 --- a/gameyfin/src/main/frontend/components/general/input/SelectInput.tsx +++ b/gameyfin/src/main/frontend/components/general/input/SelectInput.tsx @@ -13,7 +13,7 @@ const SelectInput = ({label, values, ...props}) => { {...props} id={field.name} label={label} - defaultSelectedKeys={[field.value]} + selectedKeys={[field.value]} disallowEmptySelection > {values.map((value: string) => ( diff --git a/gameyfin/src/main/frontend/state/ConfigState.ts b/gameyfin/src/main/frontend/state/ConfigState.ts new file mode 100644 index 0000000..dc85d29 --- /dev/null +++ b/gameyfin/src/main/frontend/state/ConfigState.ts @@ -0,0 +1,110 @@ +import {proxy} from 'valtio'; +import ConfigEntryDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigEntryDto"; +import {ConfigEndpoint} from "Frontend/generated/endpoints"; +import ConfigUpdateDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigUpdateDto"; + +type ConfigState = { + isLoaded: boolean; + configEntries: Record; + configNested: NestedConfig; + autoRegisterNewUsers: boolean; +}; + +export const configState = proxy({ + configEntries: {}, + isLoaded: false, + get configNested() { + return toNestedConfig(Object.values(this.configEntries)); + }, + get autoRegisterNewUsers() { + return this.configNested["sso.oidc.auto-register-new-users"] as boolean; + } +}); + +/** Subscribe to and process state updates from backend **/ +export async function initializeConfig() { + if (configState.isLoaded) return; + + // Fetch initial configuration data + const initialEntries = await ConfigEndpoint.getAll(); + initialEntries.forEach((entry) => { + configState.configEntries[entry.key] = entry; + }); + configState.isLoaded = true; + + // Subscribe to real-time updates + ConfigEndpoint.subscribe().onNext((updateDto: ConfigUpdateDto) => { + Object.entries(updateDto.updates).forEach(([key, value]) => { + if (configState.configEntries[key]) { + configState.configEntries[key].value = value; + } + }); + }); +} + +/** Computed properties **/ + +export type NestedConfig = { + [field: string]: any; +} + +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 = typeof item.value == 'boolean' ? item.value : item.value === 'true'; + break; + case 'Int': + value = typeof item.value == 'number' ? item.value : 0; + break; + case 'Float': + value = typeof item.value == 'number' ? item.value : 0.0; + break; + case 'Array': + if (Array.isArray(item.value)) { + switch (item.elementType) { + case 'Boolean': + value = item.value.map(v => typeof v === 'boolean' ? v : v === 'true'); + break; + case 'Int': + case 'Integer': + value = item.value.map(v => typeof v == 'number' ? v : 0); + break; + case 'Float': + value = item.value.map(v => typeof v == 'number' ? v : 0.0); + break; + case 'String': + default: + value = item.value.map(v => v.toString()); + break; + } + } else { + value = []; + } + break; + case 'String': + default: + value = item.value; + break; + } + currentLevel[key] = value; + } else { + if (!currentLevel[key]) { + currentLevel[key] = {}; + } + currentLevel = currentLevel[key]; + } + }); + }); + return nestedConfig; +} \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/ConfigEndpoint.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/ConfigEndpoint.kt index f387161..12bb07e 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/ConfigEndpoint.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/ConfigEndpoint.kt @@ -1,12 +1,14 @@ package de.grimsi.gameyfin.config +import com.vaadin.flow.server.auth.AnonymousAllowed import com.vaadin.hilla.Endpoint import de.grimsi.gameyfin.config.dto.ConfigEntryDto -import de.grimsi.gameyfin.config.dto.ConfigValuePairDto +import de.grimsi.gameyfin.config.dto.ConfigUpdateDto import de.grimsi.gameyfin.core.Role import jakarta.annotation.security.PermitAll import jakarta.annotation.security.RolesAllowed -import java.io.Serializable +import reactor.core.publisher.Flux +import reactor.core.publisher.Sinks @Endpoint @RolesAllowed(Role.Names.ADMIN) @@ -15,29 +17,19 @@ class ConfigEndpoint( ) { /** CRUD endpoints for admins **/ + private val configUpdates = Sinks.many().multicast().onBackpressureBuffer() - fun getAll(prefix: String?): List { - return config.getAll(prefix) + fun getAll(): List { + return config.getAll(null) } - fun get(key: String): Serializable? { - return config.get(key) - } + // FIXME + @AnonymousAllowed + fun subscribe(): Flux = configUpdates.asFlux() - fun set(key: String, value: String) { - config.set(key, value) - } - - fun setAll(configs: List) { - config.setAll(configs) - } - - fun resetConfig(key: String) { - config.deleteConfig(key) - } - - fun deleteConfig(key: String) { - config.deleteConfig(key) + fun update(update: ConfigUpdateDto) { + config.update(update.updates) + configUpdates.tryEmitNext(update) } /** Specific read-only endpoint for all users **/ diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/ConfigService.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/ConfigService.kt index ba4d74f..4fa8608 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/ConfigService.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/ConfigService.kt @@ -1,7 +1,6 @@ package de.grimsi.gameyfin.config import de.grimsi.gameyfin.config.dto.ConfigEntryDto -import de.grimsi.gameyfin.config.dto.ConfigValuePairDto import de.grimsi.gameyfin.config.entities.ConfigEntry import de.grimsi.gameyfin.config.persistence.ConfigRepository import io.github.oshai.kotlinlogging.KotlinLogging @@ -116,11 +115,15 @@ class ConfigService( * Set multiple config values at once. * Configs with a null value will be deleted. * - * @param configs: A map of key-value pairs to set + * @param updates: A map of key-value pairs to set */ - fun setAll(configs: List) { - configs.forEach { - it.value?.let { value -> set(it.key, value) } ?: deleteConfig(it.key) + fun update(updates: Map) { + updates.forEach { (key, value) -> + if (value == null) { + delete(key) + } else { + set(key, value) + } } } @@ -141,7 +144,7 @@ class ConfigService( * * @param key: Key of the config property */ - fun deleteConfig(key: String) { + fun delete(key: String) { log.debug { "Delete config value '$key'" } diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/dto/ConfigValuePairDto.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/dto/ConfigUpdateDto.kt similarity index 57% rename from gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/dto/ConfigValuePairDto.kt rename to gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/dto/ConfigUpdateDto.kt index 27c2746..5452d63 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/dto/ConfigValuePairDto.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/dto/ConfigUpdateDto.kt @@ -4,9 +4,7 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize import de.grimsi.gameyfin.core.serialization.ArrayDeserializer import java.io.Serializable -data class ConfigValuePairDto( - val key: String, - - @field:JsonDeserialize(using = ArrayDeserializer::class) - val value: Serializable? +data class ConfigUpdateDto( + @get:JsonDeserialize(contentUsing = ArrayDeserializer::class) + val updates: Map ) \ No newline at end of file