import { ApolloError } from "@apollo/client/errors"; import { faCheckCircle, faTimesCircle, } from "@fortawesome/free-solid-svg-icons"; import React, { useState, useEffect, useCallback, useRef } from "react"; import { Spinner } from "react-bootstrap"; import { IUIConfig } from "src/core/config"; import * as GQL from "src/core/generated-graphql"; import { useConfiguration, useConfigureDefaults, useConfigureDLNA, useConfigureGeneral, useConfigureInterface, useConfigurePlugin, useConfigureScraping, useConfigureUI, } from "src/core/StashService"; import { useDebounce } from "src/hooks/debounce"; import { useToast } from "src/hooks/Toast"; import { withoutTypename } from "src/utils/data"; import { Icon } from "../Shared/Icon"; type PluginSettings = Record>; export interface ISettingsContextState { loading: boolean; error: ApolloError | undefined; general: GQL.ConfigGeneralInput; interface: GQL.ConfigInterfaceInput; defaults: GQL.ConfigDefaultSettingsInput; scraping: GQL.ConfigScrapingInput; dlna: GQL.ConfigDlnaInput; ui: IUIConfig; plugins: PluginSettings; advancedMode: boolean; // apikey isn't directly settable, so expose it here apiKey: string; saveGeneral: (input: Partial) => void; saveInterface: (input: Partial) => void; saveDefaults: (input: Partial) => void; saveScraping: (input: Partial) => void; saveDLNA: (input: Partial) => void; saveUI: (input: Partial) => void; savePluginSettings: (pluginID: string, input: {}) => void; setAdvancedMode: (value: boolean) => void; refetch: () => void; } export const SettingStateContext = React.createContext(null); export const useSettings = () => { const context = React.useContext(SettingStateContext); if (context === null) { throw new Error("useSettings must be used within a SettingsContext"); } return context; }; export const SettingsContext: React.FC = ({ children }) => { const Toast = useToast(); const { data, error, loading, refetch } = useConfiguration(); const initialRef = useRef(false); const [general, setGeneral] = useState({}); const [pendingGeneral, setPendingGeneral] = useState(); const [updateGeneralConfig] = useConfigureGeneral(); const [iface, setIface] = useState({}); const [pendingInterface, setPendingInterface] = useState(); const [updateInterfaceConfig] = useConfigureInterface(); const [defaults, setDefaults] = useState({}); const [pendingDefaults, setPendingDefaults] = useState(); const [updateDefaultsConfig] = useConfigureDefaults(); const [scraping, setScraping] = useState({}); const [pendingScraping, setPendingScraping] = useState(); const [updateScrapingConfig] = useConfigureScraping(); const [dlna, setDLNA] = useState({}); const [pendingDLNA, setPendingDLNA] = useState(); const [updateDLNAConfig] = useConfigureDLNA(); const [ui, setUI] = useState({}); const [pendingUI, setPendingUI] = useState<{}>(); const [updateUIConfig] = useConfigureUI(); const [plugins, setPlugins] = useState({}); const [pendingPlugins, setPendingPlugins] = useState(); const [updatePluginConfig] = useConfigurePlugin(); const [updateSuccess, setUpdateSuccess] = useState(); const [apiKey, setApiKey] = useState(""); useEffect(() => { if (!data?.configuration || error) return; // always set api key setApiKey(data.configuration.general.apiKey); // only initialise once - assume we have control over these settings and // they aren't modified elsewhere if (initialRef.current) return; initialRef.current = true; setGeneral({ ...withoutTypename(data.configuration.general) }); setIface({ ...withoutTypename(data.configuration.interface) }); setDefaults({ ...withoutTypename(data.configuration.defaults) }); setScraping({ ...withoutTypename(data.configuration.scraping) }); setDLNA({ ...withoutTypename(data.configuration.dlna) }); setUI(data.configuration.ui); setPlugins(data.configuration.plugins); }, [data, error]); const resetSuccess = useDebounce(() => setUpdateSuccess(undefined), 4000); const onSuccess = useCallback(() => { setUpdateSuccess(true); resetSuccess(); }, [resetSuccess]); const onError = useCallback( (err) => { Toast.error(err); setUpdateSuccess(false); }, [Toast] ); // saves the configuration if no further changes are made after a half second const saveGeneralConfig = useDebounce( async (input: GQL.ConfigGeneralInput) => { try { setUpdateSuccess(undefined); await updateGeneralConfig({ variables: { input, }, }); setPendingGeneral(undefined); onSuccess(); } catch (e) { onError(e); } }, 500 ); useEffect(() => { if (!pendingGeneral) { return; } saveGeneralConfig(pendingGeneral); }, [pendingGeneral, saveGeneralConfig]); function saveGeneral(input: Partial) { if (!general) { return; } setGeneral({ ...general, ...input, }); setPendingGeneral((current) => { if (!current) { return input; } return { ...current, ...input, }; }); } // saves the configuration if no further changes are made after a half second const saveInterfaceConfig = useDebounce( async (input: GQL.ConfigInterfaceInput) => { try { setUpdateSuccess(undefined); await updateInterfaceConfig({ variables: { input, }, }); setPendingInterface(undefined); onSuccess(); } catch (e) { onError(e); } }, 500 ); useEffect(() => { if (!pendingInterface) { return; } saveInterfaceConfig(pendingInterface); }, [pendingInterface, saveInterfaceConfig]); function saveInterface(input: Partial) { if (!iface) { return; } setIface({ ...iface, ...input, }); setPendingInterface((current) => { if (!current) { return input; } return { ...current, ...input, }; }); } // saves the configuration if no further changes are made after a half second const saveDefaultsConfig = useDebounce( async (input: GQL.ConfigDefaultSettingsInput) => { try { setUpdateSuccess(undefined); await updateDefaultsConfig({ variables: { input, }, }); setPendingDefaults(undefined); onSuccess(); } catch (e) { onError(e); } }, 500 ); useEffect(() => { if (!pendingDefaults) { return; } saveDefaultsConfig(pendingDefaults); }, [pendingDefaults, saveDefaultsConfig]); function saveDefaults(input: Partial) { if (!defaults) { return; } setDefaults({ ...defaults, ...input, }); setPendingDefaults((current) => { if (!current) { return input; } return { ...current, ...input, }; }); } // saves the configuration if no further changes are made after a half second const saveScrapingConfig = useDebounce( async (input: GQL.ConfigScrapingInput) => { try { setUpdateSuccess(undefined); await updateScrapingConfig({ variables: { input, }, }); setPendingScraping(undefined); onSuccess(); } catch (e) { onError(e); } }, 500 ); useEffect(() => { if (!pendingScraping) { return; } saveScrapingConfig(pendingScraping); }, [pendingScraping, saveScrapingConfig]); function saveScraping(input: Partial) { if (!scraping) { return; } setScraping({ ...scraping, ...input, }); setPendingScraping((current) => { if (!current) { return input; } return { ...current, ...input, }; }); } // saves the configuration if no further changes are made after a half second const saveDLNAConfig = useDebounce(async (input: GQL.ConfigDlnaInput) => { try { setUpdateSuccess(undefined); await updateDLNAConfig({ variables: { input, }, }); setPendingDLNA(undefined); onSuccess(); } catch (e) { onError(e); } }, 500); useEffect(() => { if (!pendingDLNA) { return; } saveDLNAConfig(pendingDLNA); }, [pendingDLNA, saveDLNAConfig]); function saveDLNA(input: Partial) { if (!dlna) { return; } setDLNA({ ...dlna, ...input, }); setPendingDLNA((current) => { if (!current) { return input; } return { ...current, ...input, }; }); } // saves the configuration if no further changes are made after a half second const saveUIConfig = useDebounce(async (input: IUIConfig) => { try { setUpdateSuccess(undefined); await updateUIConfig({ variables: { input, }, }); setPendingUI(undefined); onSuccess(); } catch (e) { onError(e); } }, 500); useEffect(() => { if (!pendingUI) { return; } saveUIConfig(pendingUI); }, [pendingUI, saveUIConfig]); function saveUI(input: IUIConfig) { if (!ui) { return; } setUI({ ...ui, ...input, }); setPendingUI((current) => { if (!current) { // use full UI object to ensure nothing is wiped return { ...ui, ...input, }; } return { ...current, ...input, }; }); } function setAdvancedMode(value: boolean) { saveUI({ advancedMode: value, }); } // saves the configuration if no further changes are made after a half second const savePluginConfig = useDebounce(async (input: PluginSettings) => { try { setUpdateSuccess(undefined); for (const pluginID in input) { await updatePluginConfig({ variables: { plugin_id: pluginID, input: input[pluginID], }, }); } setPendingPlugins(undefined); onSuccess(); } catch (e) { onError(e); } }, 500); useEffect(() => { if (!pendingPlugins) { return; } savePluginConfig(pendingPlugins); }, [pendingPlugins, savePluginConfig]); function savePluginSettings( pluginID: string, input: Record ) { if (!plugins) { return; } setPlugins({ ...plugins, [pluginID]: input, }); setPendingPlugins((current) => { if (!current) { // use full UI object to ensure nothing is wiped return { ...plugins, [pluginID]: input, }; } return { ...current, [pluginID]: input, }; }); } function maybeRenderLoadingIndicator() { if (updateSuccess === false) { return (
); } if ( pendingGeneral || pendingInterface || pendingDefaults || pendingScraping || pendingDLNA || pendingUI || pendingPlugins ) { return (
Loading...
); } if (updateSuccess) { return (
); } } return ( {maybeRenderLoadingIndicator()} {children} ); };