mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-17 08:15:44 +00:00
Enable real-time UI starting with config pages
Implement client side state caching and updating via websocket Simplify config REST endpoints
This commit is contained in:
Generated
+31
@@ -53,6 +53,7 @@
|
|||||||
"react-router": "7.5.2",
|
"react-router": "7.5.2",
|
||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
"swiper": "^11.2.6",
|
"swiper": "^11.2.6",
|
||||||
|
"valtio": "^2.1.5",
|
||||||
"yup": "^1.6.1"
|
"yup": "^1.6.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -15816,6 +15817,12 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/pump": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz",
|
||||||
@@ -18159,6 +18166,30 @@
|
|||||||
"node": ">= 0.10"
|
"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": {
|
"node_modules/vfile": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
"react-router": "7.5.2",
|
"react-router": "7.5.2",
|
||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
"swiper": "^11.2.6",
|
"swiper": "^11.2.6",
|
||||||
|
"valtio": "^2.1.5",
|
||||||
"yup": "^1.6.1"
|
"yup": "^1.6.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -136,7 +137,8 @@
|
|||||||
"swiper": "$swiper",
|
"swiper": "$swiper",
|
||||||
"react-player": "$react-player",
|
"react-player": "$react-player",
|
||||||
"react-markdown": "$react-markdown",
|
"react-markdown": "$react-markdown",
|
||||||
"remark-breaks": "$remark-breaks"
|
"remark-breaks": "$remark-breaks",
|
||||||
|
"valtio": "$valtio"
|
||||||
},
|
},
|
||||||
"vaadin": {
|
"vaadin": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -197,6 +199,6 @@
|
|||||||
"workbox-core": "7.3.0",
|
"workbox-core": "7.3.0",
|
||||||
"workbox-precaching": "7.3.0"
|
"workbox-precaching": "7.3.0"
|
||||||
},
|
},
|
||||||
"hash": "f66bddf01ef8dd09a431102050ddd561b4587fdd43bcbff127b93872febbee92"
|
"hash": "c57af53043f6c6a9f0b03c75c28c3fde0bbfd828f9ce0e179263959c61ec888d"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,4 +119,4 @@ const validationSchema = Yup.object({
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
export const LibraryManagement = withConfigPage(LibraryManagementLayout, "Library Management", "library", validationSchema);
|
export const LibraryManagement = withConfigPage(LibraryManagementLayout, "Library Management", validationSchema);
|
||||||
@@ -94,4 +94,4 @@ const validationSchema = Yup.object({
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
export const LogManagement = withConfigPage(LogManagementLayout, "Logging", "logs", validationSchema);
|
export const LogManagement = withConfigPage(LogManagementLayout, "Logging", validationSchema);
|
||||||
@@ -9,7 +9,7 @@ import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/messages/t
|
|||||||
import SendTestNotificationModal from "Frontend/components/administration/messages/SendTestNotificationModal";
|
import SendTestNotificationModal from "Frontend/components/administration/messages/SendTestNotificationModal";
|
||||||
import EditTemplateModal from "Frontend/components/administration/messages/EditTemplateModel";
|
import EditTemplateModal from "Frontend/components/administration/messages/EditTemplateModel";
|
||||||
|
|
||||||
function MessageManagementLayout({getConfig, getConfigs, formik}: any) {
|
function MessageManagementLayout({getConfig, formik}: any) {
|
||||||
|
|
||||||
const editorModal = useDisclosure();
|
const editorModal = useDisclosure();
|
||||||
const testNotificationModal = useDisclosure();
|
const testNotificationModal = useDisclosure();
|
||||||
|
|||||||
@@ -121,4 +121,4 @@ const validationSchema = Yup.object({
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SsoManagement = withConfigPage(SsoManagementLayout, "Single Sign-On", "sso", validationSchema);
|
export const SsoManagement = withConfigPage(SsoManagementLayout, "Single Sign-On", validationSchema);
|
||||||
@@ -26,4 +26,4 @@ function SystemManagementLayout({getConfig, formik, setSaveMessage}: any) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SystemManagement = withConfigPage(SystemManagementLayout, "System", "system", null);
|
export const SystemManagement = withConfigPage(SystemManagementLayout, "System");
|
||||||
@@ -2,27 +2,25 @@ import React, {useEffect, useState} from "react";
|
|||||||
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
||||||
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
||||||
import Section from "Frontend/components/general/Section";
|
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 UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto";
|
||||||
import {UserManagementCard} from "Frontend/components/general/cards/UserManagementCard";
|
import {UserManagementCard} from "Frontend/components/general/cards/UserManagementCard";
|
||||||
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
||||||
import {Info, UserPlus} from "@phosphor-icons/react";
|
import {Info, UserPlus} from "@phosphor-icons/react";
|
||||||
import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
|
import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
|
||||||
import InviteUserModal from "Frontend/components/general/modals/InviteUserModal";
|
import InviteUserModal from "Frontend/components/general/modals/InviteUserModal";
|
||||||
|
import {useSnapshot} from "valtio/react";
|
||||||
|
import {configState} from "Frontend/state/ConfigState";
|
||||||
|
|
||||||
function UserManagementLayout({getConfig, formik}: any) {
|
function UserManagementLayout({getConfig, formik}: any) {
|
||||||
const inviteUserModal = useDisclosure();
|
const inviteUserModal = useDisclosure();
|
||||||
const [users, setUsers] = useState<UserInfoDto[]>([]);
|
const [users, setUsers] = useState<UserInfoDto[]>([]);
|
||||||
const [autoRegisterNewUsers, setAutoRegisterNewUsers] = useState(true);
|
const autoRegisterNewUsers = useSnapshot(configState);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
UserEndpoint.getAllUsers().then(
|
UserEndpoint.getAllUsers().then(
|
||||||
(response) => setUsers(response)
|
(response) => setUsers(response)
|
||||||
);
|
);
|
||||||
|
|
||||||
ConfigEndpoint.get("sso.oidc.auto-register-new-users").then(
|
|
||||||
(response) => setAutoRegisterNewUsers(response as boolean)
|
|
||||||
);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -56,4 +54,4 @@ function UserManagementLayout({getConfig, formik}: any) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserManagement = withConfigPage(UserManagementLayout, "User Management", "users");
|
export const UserManagement = withConfigPage(UserManagementLayout, "User Management");
|
||||||
@@ -1,30 +1,22 @@
|
|||||||
import React, {useEffect, useRef, useState} from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
import {ConfigEndpoint} from "Frontend/generated/endpoints";
|
import {ConfigEndpoint} from "Frontend/generated/endpoints";
|
||||||
import ConfigEntryDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigEntryDto";
|
import ConfigEntryDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigEntryDto";
|
||||||
import {Form, Formik} from "formik";
|
import {Form, Formik} from "formik";
|
||||||
import {Button, Skeleton} from "@heroui/react";
|
import {Button, Skeleton} from "@heroui/react";
|
||||||
import {Check, Info} from "@phosphor-icons/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 {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
||||||
|
import {configState, initializeConfig, NestedConfig} from "Frontend/state/ConfigState";
|
||||||
|
import {useSnapshot} from "valtio/react";
|
||||||
|
|
||||||
type NestedConfig = {
|
export default function withConfigPage(WrappedComponent: React.ComponentType<any>, title: String, validationSchema?: any) {
|
||||||
[field: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function withConfigPage(WrappedComponent: React.ComponentType<any>, title: String, configPrefix: string, validationSchema?: any) {
|
|
||||||
return function ConfigPage(props: any) {
|
return function ConfigPage(props: any) {
|
||||||
const isInitialized = useRef(false);
|
|
||||||
const [configSaved, setConfigSaved] = useState(false);
|
const [configSaved, setConfigSaved] = useState(false);
|
||||||
const [configDtos, setConfigDtos] = useState<ConfigEntryDto[]>([]);
|
|
||||||
const [nestedConfigDtos, setNestedConfigDtos] = useState<NestedConfig>({});
|
|
||||||
const [saveMessage, setSaveMessage] = useState<string>();
|
const [saveMessage, setSaveMessage] = useState<string>();
|
||||||
|
|
||||||
|
const state = useSnapshot(configState);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ConfigEndpoint.getAll(configPrefix).then((response: any) => {
|
initializeConfig();
|
||||||
setConfigDtos(response as ConfigEntryDto[]);
|
|
||||||
setNestedConfigDtos(toNestedConfig(response as ConfigEntryDto[]));
|
|
||||||
isInitialized.current = true;
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -34,116 +26,67 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
|
|||||||
}, [configSaved])
|
}, [configSaved])
|
||||||
|
|
||||||
async function handleSubmit(values: NestedConfig): Promise<void> {
|
async function handleSubmit(values: NestedConfig): Promise<void> {
|
||||||
const configValues = toConfigValuePair(values);
|
const changed = getChangedValues(state.configNested, values);
|
||||||
await ConfigEndpoint.setAll(configValues);
|
await ConfigEndpoint.update({updates: changed});
|
||||||
setNestedConfigDtos(values);
|
|
||||||
setConfigSaved(true);
|
setConfigSaved(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConfig(key: string): ConfigEntryDto | undefined {
|
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[] {
|
function getChangedValues(initial: NestedConfig, current: NestedConfig): Record<string, any> {
|
||||||
return configDtos.filter((configDto: ConfigEntryDto) => configDto.key?.startsWith(prefix));
|
const flatten = (obj: NestedConfig, parentKey = ''): Record<string, any> => {
|
||||||
}
|
let result: Record<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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toConfigValuePair(obj: NestedConfig, parentKey: string = ''): ConfigValuePairDto[] {
|
|
||||||
let result: ConfigValuePairDto[] = [];
|
|
||||||
|
|
||||||
for (const key in obj) {
|
for (const key in obj) {
|
||||||
if (obj.hasOwnProperty(key)) {
|
if (obj.hasOwnProperty(key)) {
|
||||||
const newKey = parentKey ? `${parentKey}.${key}` : key;
|
const newKey = parentKey ? `${parentKey}.${key}` : key;
|
||||||
if (typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
|
if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
|
||||||
result = result.concat(toConfigValuePair(obj[key], newKey));
|
Object.assign(result, flatten(obj[key], newKey));
|
||||||
} else {
|
} else {
|
||||||
result.push({key: newKey, value: obj[key]});
|
result[newKey] = obj[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
};
|
||||||
|
|
||||||
if (!isInitialized.current) {
|
const arraysEqual = (a: any[], b: any[]): boolean => {
|
||||||
return (
|
if (a.length !== b.length) return false;
|
||||||
[...Array(4)].map((_e, i) =>
|
for (let i = 0; i < a.length; i++) {
|
||||||
<div className="flex flex-col flex-grow gap-8 mb-12" key={i}>
|
if (Array.isArray(a[i]) && Array.isArray(b[i])) {
|
||||||
<Skeleton className="h-10 w-full rounded-md"/>
|
if (!arraysEqual(a[i], b[i])) return false;
|
||||||
<Skeleton className="h-12 flex w-1/3 rounded-md"/>
|
} else if (a[i] !== b[i]) {
|
||||||
<div className="flex flex-row gap-8">
|
return false;
|
||||||
<Skeleton className="h-12 flex w-1/3 rounded-md"/>
|
}
|
||||||
<Skeleton className="h-12 flex w-1/3 rounded-md"/>
|
}
|
||||||
</div>
|
return true;
|
||||||
</div>
|
};
|
||||||
)
|
|
||||||
)
|
const flatInitial = flatten(initial);
|
||||||
|
const flatCurrent = flatten(current);
|
||||||
|
|
||||||
|
const changed: Record<string, any> = {};
|
||||||
|
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 changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{state.isLoaded ?
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={nestedConfigDtos}
|
initialValues={state.configNested}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
validationSchema={validationSchema}
|
validationSchema={validationSchema}
|
||||||
enableReinitialize={true}
|
enableReinitialize={true}
|
||||||
@@ -171,12 +114,23 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
|
|||||||
|
|
||||||
<WrappedComponent {...props}
|
<WrappedComponent {...props}
|
||||||
getConfig={getConfig}
|
getConfig={getConfig}
|
||||||
getConfigs={getConfigs}
|
|
||||||
formik={formik}
|
formik={formik}
|
||||||
setSaveMessage={setSaveMessage}/>
|
setSaveMessage={setSaveMessage}/>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
</Formik>
|
</Formik> :
|
||||||
|
[...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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,7 @@ const SelectInput = ({label, values, ...props}) => {
|
|||||||
{...props}
|
{...props}
|
||||||
id={field.name}
|
id={field.name}
|
||||||
label={label}
|
label={label}
|
||||||
defaultSelectedKeys={[field.value]}
|
selectedKeys={[field.value]}
|
||||||
disallowEmptySelection
|
disallowEmptySelection
|
||||||
>
|
>
|
||||||
{values.map((value: string) => (
|
{values.map((value: string) => (
|
||||||
|
|||||||
@@ -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<string, ConfigEntryDto>;
|
||||||
|
configNested: NestedConfig;
|
||||||
|
autoRegisterNewUsers: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const configState = proxy<ConfigState>({
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
package de.grimsi.gameyfin.config
|
package de.grimsi.gameyfin.config
|
||||||
|
|
||||||
|
import com.vaadin.flow.server.auth.AnonymousAllowed
|
||||||
import com.vaadin.hilla.Endpoint
|
import com.vaadin.hilla.Endpoint
|
||||||
import de.grimsi.gameyfin.config.dto.ConfigEntryDto
|
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 de.grimsi.gameyfin.core.Role
|
||||||
import jakarta.annotation.security.PermitAll
|
import jakarta.annotation.security.PermitAll
|
||||||
import jakarta.annotation.security.RolesAllowed
|
import jakarta.annotation.security.RolesAllowed
|
||||||
import java.io.Serializable
|
import reactor.core.publisher.Flux
|
||||||
|
import reactor.core.publisher.Sinks
|
||||||
|
|
||||||
@Endpoint
|
@Endpoint
|
||||||
@RolesAllowed(Role.Names.ADMIN)
|
@RolesAllowed(Role.Names.ADMIN)
|
||||||
@@ -15,29 +17,19 @@ class ConfigEndpoint(
|
|||||||
) {
|
) {
|
||||||
|
|
||||||
/** CRUD endpoints for admins **/
|
/** CRUD endpoints for admins **/
|
||||||
|
private val configUpdates = Sinks.many().multicast().onBackpressureBuffer<ConfigUpdateDto>()
|
||||||
|
|
||||||
fun getAll(prefix: String?): List<ConfigEntryDto> {
|
fun getAll(): List<ConfigEntryDto> {
|
||||||
return config.getAll(prefix)
|
return config.getAll(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun get(key: String): Serializable? {
|
// FIXME
|
||||||
return config.get(key)
|
@AnonymousAllowed
|
||||||
}
|
fun subscribe(): Flux<ConfigUpdateDto> = configUpdates.asFlux()
|
||||||
|
|
||||||
fun set(key: String, value: String) {
|
fun update(update: ConfigUpdateDto) {
|
||||||
config.set(key, value)
|
config.update(update.updates)
|
||||||
}
|
configUpdates.tryEmitNext(update)
|
||||||
|
|
||||||
fun setAll(configs: List<ConfigValuePairDto>) {
|
|
||||||
config.setAll(configs)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun resetConfig(key: String) {
|
|
||||||
config.deleteConfig(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteConfig(key: String) {
|
|
||||||
config.deleteConfig(key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Specific read-only endpoint for all users **/
|
/** Specific read-only endpoint for all users **/
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package de.grimsi.gameyfin.config
|
package de.grimsi.gameyfin.config
|
||||||
|
|
||||||
import de.grimsi.gameyfin.config.dto.ConfigEntryDto
|
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.entities.ConfigEntry
|
||||||
import de.grimsi.gameyfin.config.persistence.ConfigRepository
|
import de.grimsi.gameyfin.config.persistence.ConfigRepository
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
@@ -116,11 +115,15 @@ class ConfigService(
|
|||||||
* Set multiple config values at once.
|
* Set multiple config values at once.
|
||||||
* Configs with a null value will be deleted.
|
* 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<ConfigValuePairDto>) {
|
fun update(updates: Map<String, Serializable?>) {
|
||||||
configs.forEach {
|
updates.forEach { (key, value) ->
|
||||||
it.value?.let { value -> set(it.key, value) } ?: deleteConfig(it.key)
|
if (value == null) {
|
||||||
|
delete(key)
|
||||||
|
} else {
|
||||||
|
set(key, value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +144,7 @@ class ConfigService(
|
|||||||
*
|
*
|
||||||
* @param key: Key of the config property
|
* @param key: Key of the config property
|
||||||
*/
|
*/
|
||||||
fun deleteConfig(key: String) {
|
fun delete(key: String) {
|
||||||
|
|
||||||
log.debug { "Delete config value '$key'" }
|
log.debug { "Delete config value '$key'" }
|
||||||
|
|
||||||
|
|||||||
+3
-5
@@ -4,9 +4,7 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize
|
|||||||
import de.grimsi.gameyfin.core.serialization.ArrayDeserializer
|
import de.grimsi.gameyfin.core.serialization.ArrayDeserializer
|
||||||
import java.io.Serializable
|
import java.io.Serializable
|
||||||
|
|
||||||
data class ConfigValuePairDto(
|
data class ConfigUpdateDto(
|
||||||
val key: String,
|
@get:JsonDeserialize(contentUsing = ArrayDeserializer::class)
|
||||||
|
val updates: Map<String, Serializable?>
|
||||||
@field:JsonDeserialize(using = ArrayDeserializer::class)
|
|
||||||
val value: Serializable?
|
|
||||||
)
|
)
|
||||||
Reference in New Issue
Block a user