diff --git a/src/apps/dashboard/controllers/general.html b/src/apps/dashboard/controllers/general.html deleted file mode 100644 index 80ea02d52d..0000000000 --- a/src/apps/dashboard/controllers/general.html +++ /dev/null @@ -1,84 +0,0 @@ -
-
-
-
-
-
-

${Settings}

-
-
- -
-
- -
${LabelServerNameHelp}
-
-
- -
-
${LabelDisplayLanguageHelp}
- -
-
-
- -
-

${HeaderPaths}

-
-
-
- -
- -
-
${LabelCachePathHelp}
-
- -
-
-
- -
- -
-
${LabelMetadataPathHelp}
- -
-
- -
-
-

${QuickConnect}

-
-
- -
- -
- -
-

${HeaderPerformance}

-
- -
${LibraryScanFanoutConcurrencyHelp}
-
-
- -
${LabelParallelImageEncodingLimitHelp}
-
-
-
-
- -
-
-
-
-
diff --git a/src/apps/dashboard/controllers/general.js b/src/apps/dashboard/controllers/general.js deleted file mode 100644 index 0a2fc98f82..0000000000 --- a/src/apps/dashboard/controllers/general.js +++ /dev/null @@ -1,105 +0,0 @@ -import 'jquery'; - -import loading from 'components/loading/loading'; -import globalize from 'lib/globalize'; -import 'elements/emby-checkbox/emby-checkbox'; -import 'elements/emby-textarea/emby-textarea'; -import 'elements/emby-input/emby-input'; -import 'elements/emby-select/emby-select'; -import 'elements/emby-button/emby-button'; -import Dashboard from 'utils/dashboard'; -import alert from 'components/alert'; - -function loadPage(page, config, languageOptions, systemInfo) { - page.querySelector('#txtServerName').value = systemInfo.ServerName; - page.querySelector('#txtCachePath').value = systemInfo.CachePath || ''; - page.querySelector('#chkQuickConnectAvailable').checked = config.QuickConnectAvailable === true; - page.querySelector('#txtMetadataPath').value = systemInfo.InternalMetadataPath || ''; - page.querySelector('#txtMetadataNetworkPath').value = systemInfo.MetadataNetworkPath || ''; - const localizationLanguageElem = page.querySelector('#selectLocalizationLanguage'); - localizationLanguageElem.innerHTML = languageOptions.map(function (language) { - return ''; - }).join(''); - localizationLanguageElem.value = config.UICulture; - page.querySelector('#txtLibraryScanFanoutConcurrency').value = config.LibraryScanFanoutConcurrency || ''; - page.querySelector('#txtParallelImageEncodingLimit').value = config.ParallelImageEncodingLimit || ''; - - loading.hide(); -} - -function onSubmit() { - loading.show(); - const form = this; - ApiClient.getServerConfiguration().then(function (config) { - config.ServerName = form.querySelector('#txtServerName').value; - config.UICulture = form.querySelector('#selectLocalizationLanguage').value; - config.CachePath = form.querySelector('#txtCachePath').value; - config.MetadataPath = form.querySelector('#txtMetadataPath').value; - config.MetadataNetworkPath = form.querySelector('#txtMetadataNetworkPath').value; - config.QuickConnectAvailable = form.querySelector('#chkQuickConnectAvailable').checked; - config.LibraryScanFanoutConcurrency = parseInt(form.querySelector('#txtLibraryScanFanoutConcurrency').value || '0', 10); - config.ParallelImageEncodingLimit = parseInt(form.querySelector('#txtParallelImageEncodingLimit').value || '0', 10); - - return ApiClient.updateServerConfiguration(config) - .then(() => { - Dashboard.processServerConfigurationUpdateResult(); - }).catch(() => { - loading.hide(); - alert(globalize.translate('ErrorDefault')); - }); - }); - return false; -} - -export default function (view) { - $('#btnSelectCachePath', view).on('click.selectDirectory', function () { - import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => { - const picker = new DirectoryBrowser(); - picker.show({ - callback: function (path) { - if (path) { - view.querySelector('#txtCachePath').value = path; - } - - picker.close(); - }, - validateWriteable: true, - header: globalize.translate('HeaderSelectServerCachePath'), - instruction: globalize.translate('HeaderSelectServerCachePathHelp') - }); - }); - }); - $('#btnSelectMetadataPath', view).on('click.selectDirectory', function () { - import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => { - const picker = new DirectoryBrowser(); - picker.show({ - path: view.querySelector('#txtMetadataPath').value, - networkSharePath: view.querySelector('#txtMetadataNetworkPath').value, - callback: function (path, networkPath) { - if (path) { - view.querySelector('#txtMetadataPath').value = path; - } - - if (networkPath) { - view.querySelector('#txtMetadataNetworkPath').value = networkPath; - } - - picker.close(); - }, - validateWriteable: true, - header: globalize.translate('HeaderSelectMetadataPath'), - instruction: globalize.translate('HeaderSelectMetadataPathHelp') - }); - }); - }); - $('.dashboardGeneralForm', view).off('submit', onSubmit).on('submit', onSubmit); - view.addEventListener('viewshow', function () { - const promiseConfig = ApiClient.getServerConfiguration(); - const promiseLanguageOptions = ApiClient.getJSON(ApiClient.getUrl('Localization/Options')); - const promiseSystemInfo = ApiClient.getSystemInfo(); - Promise.all([promiseConfig, promiseLanguageOptions, promiseSystemInfo]).then(function (responses) { - loadPage(view, responses[0], responses[1], responses[2]); - }); - }); -} - diff --git a/src/apps/dashboard/features/settings/api/useLocalizationOptions.ts b/src/apps/dashboard/features/settings/api/useLocalizationOptions.ts new file mode 100644 index 0000000000..46a884a396 --- /dev/null +++ b/src/apps/dashboard/features/settings/api/useLocalizationOptions.ts @@ -0,0 +1,21 @@ +import { Api } from '@jellyfin/sdk'; +import { getLocalizationApi } from '@jellyfin/sdk/lib/utils/api/localization-api'; +import { useQuery } from '@tanstack/react-query'; +import { useApi } from 'hooks/useApi'; +import type { AxiosRequestConfig } from 'axios'; + +const fetchLocalizationOptions = async (api: Api, options?: AxiosRequestConfig) => { + const response = await getLocalizationApi(api).getLocalizationOptions(options); + + return response.data; +}; + +export const useLocalizationOptions = () => { + const { api } = useApi(); + + return useQuery({ + queryKey: [ 'LocalizationOptions' ], + queryFn: ({ signal }) => fetchLocalizationOptions(api!, { signal }), + enabled: !!api + }); +}; diff --git a/src/apps/dashboard/routes/_asyncRoutes.ts b/src/apps/dashboard/routes/_asyncRoutes.ts index 4ba4d5e5e1..d52f9724e6 100644 --- a/src/apps/dashboard/routes/_asyncRoutes.ts +++ b/src/apps/dashboard/routes/_asyncRoutes.ts @@ -5,6 +5,7 @@ export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [ { path: 'activity', type: AppType.Dashboard }, { path: 'branding', type: AppType.Dashboard }, { path: 'devices', type: AppType.Dashboard }, + { path: 'settings', type: AppType.Dashboard }, { path: 'keys', type: AppType.Dashboard }, { path: 'libraries/display', type: AppType.Dashboard }, { path: 'libraries/metadata', type: AppType.Dashboard }, diff --git a/src/apps/dashboard/routes/_legacyRoutes.ts b/src/apps/dashboard/routes/_legacyRoutes.ts index 6cd162d88d..d1f5a9bfbb 100644 --- a/src/apps/dashboard/routes/_legacyRoutes.ts +++ b/src/apps/dashboard/routes/_legacyRoutes.ts @@ -9,13 +9,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [ controller: 'dashboard', view: 'dashboard.html' } - }, { - path: 'settings', - pageProps: { - appType: AppType.Dashboard, - controller: 'general', - view: 'general.html' - } }, { path: 'networking', pageProps: { diff --git a/src/apps/dashboard/routes/settings/index.tsx b/src/apps/dashboard/routes/settings/index.tsx new file mode 100644 index 0000000000..60c6221738 --- /dev/null +++ b/src/apps/dashboard/routes/settings/index.tsx @@ -0,0 +1,269 @@ +import Alert from '@mui/material/Alert'; +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import MenuItem from '@mui/material/MenuItem'; +import Stack from '@mui/material/Stack'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import { useLocalizationOptions } from 'apps/dashboard/features/settings/api/useLocalizationOptions'; +import Loading from 'components/loading/LoadingComponent'; +import Page from 'components/Page'; +import { QUERY_KEY, useConfiguration } from 'hooks/useConfiguration'; +import { useSystemInfo } from 'hooks/useSystemInfo'; +import globalize from 'lib/globalize'; +import React, { useCallback, useEffect, useState } from 'react'; +import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom'; +import SearchIcon from '@mui/icons-material/Search'; +import InputAdornment from '@mui/material/InputAdornment'; +import Paper from '@mui/material/Paper'; +import FormControl from '@mui/material/FormControl'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import Button from '@mui/material/Button'; +import Link from '@mui/material/Link'; +import DirectoryBrowser from 'components/directorybrowser/directorybrowser'; +import ServerConnections from 'components/ServerConnections'; +import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api'; +import { queryClient } from 'utils/query/queryClient'; +import { ActionData } from 'types/actionData'; + +export const action = async ({ request }: ActionFunctionArgs) => { + const api = ServerConnections.getCurrentApi(); + if (!api) throw new Error('No Api instance available'); + + const { data: config } = await getConfigurationApi(api).getConfiguration(); + const formData = await request.formData(); + + config.ServerName = formData.get('ServerName')?.toString(); + config.UICulture = formData.get('UICulture')?.toString(); + config.CachePath = formData.get('CachePath')?.toString(); + config.MetadataPath = formData.get('MetadataPath')?.toString(); + config.QuickConnectAvailable = formData.get('QuickConnectAvailable')?.toString() === 'on'; + config.LibraryScanFanoutConcurrency = parseInt(formData.get('LibraryScanFanoutConcurrency')?.toString() || '0', 10); + config.ParallelImageEncodingLimit = parseInt(formData.get('ParallelImageEncodingLimit')?.toString() || '0', 10); + + await getConfigurationApi(api) + .updateConfiguration({ serverConfiguration: config }); + + void queryClient.invalidateQueries({ + queryKey: [ QUERY_KEY ] + }); + + return { + isSaved: true + }; +}; + +export const Component = () => { + const { + data: config, + isPending: isConfigPending, + isError: isConfigError + } = useConfiguration(); + const { + data: languageOptions, + isPending: isLocalizationOptionsPending, + isError: isLocalizationOptionsError + } = useLocalizationOptions(); + const { + data: systemInfo, + isPending: isSystemInfoPending, + isError: isSystemInfoError + } = useSystemInfo(); + + const navigation = useNavigation(); + const actionData = useActionData() as ActionData | undefined; + const isSubmitting = navigation.state === 'submitting'; + const [ cachePath, setCachePath ] = useState(''); + const [ metadataPath, setMetadataPath ] = useState(''); + + const onCachePathChange = useCallback((event: React.ChangeEvent) => { + setCachePath(event.target.value); + }, []); + + const onMetadataPathChange = useCallback((event: React.ChangeEvent) => { + setMetadataPath(event.target.value); + }, []); + + const showCachePathPicker = useCallback(() => { + const picker = new DirectoryBrowser(); + + picker.show({ + callback: function (path: string) { + if (path) { + setCachePath(path); + } + + picker.close(); + }, + validateWriteable: true, + header: globalize.translate('HeaderSelectServerCachePath'), + instruction: globalize.translate('HeaderSelectServerCachePathHelp') + }); + }, []); + + const showMetadataPathPicker = useCallback(() => { + const picker = new DirectoryBrowser(); + + picker.show({ + path: metadataPath, + callback: function (path: string) { + if (path) { + setMetadataPath(path); + } + + picker.close(); + }, + validateWriteable: true, + header: globalize.translate('HeaderSelectMetadataPath'), + instruction: globalize.translate('HeaderSelectMetadataPathHelp') + }); + }, [metadataPath]); + + useEffect(() => { + if (!isSystemInfoPending && !isSystemInfoError) { + setCachePath(systemInfo.CachePath); + setMetadataPath(systemInfo.InternalMetadataPath); + } + }, [systemInfo, isSystemInfoPending, isSystemInfoError]); + + if (isConfigPending || isLocalizationOptionsPending || isSystemInfoPending) { + return ; + } + + return ( + + + {isConfigError || isLocalizationOptionsError || isSystemInfoError ? ( + {globalize.translate('SettingsPageLoadError')} + ) : ( +
+ + {globalize.translate('Settings')} + + {!isSubmitting && actionData?.isSaved && ( + + {globalize.translate('SettingsSaved')} + + )} + + + + + {globalize.translate('LabelDisplayLanguageHelp')} + + {globalize.translate('LearnHowYouCanContribute')} + + + )} + defaultValue={config.UICulture} + > + {languageOptions.map((language) => + {language.Name} + )} + + + {globalize.translate('HeaderPaths')} + + + + + + + ) + }} + /> + + + + + + + ) + }} + /> + + {globalize.translate('QuickConnect')} + + + + + } + label={globalize.translate('EnableQuickConnect')} + /> + + + + {globalize.translate('HeaderPerformance')} + + + + + + + + + )} +
+
+ ); +}; + +Component.displayName = 'SettingsPage'; diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 8007057b71..8cfb28908c 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -822,7 +822,7 @@ "LabelOriginalTitle": "Original title", "LabelOverview": "Overview", "LabelParallelImageEncodingLimit": "Parallel image encoding limit", - "LabelParallelImageEncodingLimitHelp": "Maximum number of image encodings that are allowed to run in parallel. Setting this to 0 will choose a limit based on your systems core count.", + "LabelParallelImageEncodingLimitHelp": "Maximum number of image encodings that are allowed to run in parallel. Leaving this empty will choose a limit based on your systems core count.", "LabelParentalRating": "Parental rating", "LabelParentNumber": "Parent number", "LabelPassword": "Password", @@ -1019,7 +1019,7 @@ "LibraryAccessHelp": "Select the libraries to share with this user. Administrators will be able to edit all folders using the metadata manager.", "LibraryNameInvalid": "Library name cannot be empty.", "LibraryScanFanoutConcurrency": "Parallel library scan tasks limit", - "LibraryScanFanoutConcurrencyHelp": "Maximum number of parallel tasks during library scans. Setting this to 0 will choose a limit based on your systems core count. WARNING: Setting this number too high may cause issues with network file systems; if you encounter problems lower this number.", + "LibraryScanFanoutConcurrencyHelp": "Maximum number of parallel tasks during library scans. Leaving this empty will choose a limit based on your systems core count. WARNING: Setting this number too high may cause issues with network file systems; if you encounter problems lower this number.", "LibraryInvalidItemIdError": "The library is in an invalid state and cannot be edited. You are possibly encountering a bug: the path in the database is not the correct path on the filesystem.", "LimitSupportedVideoResolution": "Limit maximum supported video resolution", "LimitSupportedVideoResolutionHelp": "Use 'Maximum Allowed Video Transcoding Resolution' as maximum supported video resolution.", @@ -1495,6 +1495,7 @@ "ServerRestartNeededAfterPluginInstall": "Jellyfin will need to be restarted after installing the plugin.", "ServerUpdateNeeded": "This server needs to be updated. To download the latest version, please visit {0}", "Settings": "Settings", + "SettingsPageLoadError": "Failed to load settings page", "SettingsSaved": "Settings saved.", "SettingsWarning": "Changing these values may cause instability or connectivity failures. If you experience any problems, we recommend changing them back to default.", "Share": "Share",