From ca5f94df6395f449160fcfc8ab28b9c4e07a2eb1 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Wed, 20 Nov 2024 17:56:59 -0500 Subject: [PATCH 1/4] Add dashboard branding page --- .../drawer/sections/ServerDrawerSection.tsx | 7 + .../branding/api/useBrandingOptions.ts | 35 ++++ src/apps/dashboard/routes/_asyncRoutes.ts | 1 + src/apps/dashboard/routes/branding/index.tsx | 174 ++++++++++++++++++ src/components/ServerConnections.js | 15 ++ src/components/router/AsyncRoute.tsx | 18 +- src/elements/emby-textarea/emby-textarea.scss | 5 +- src/styles/fonts.scss | 14 ++ 8 files changed, 259 insertions(+), 10 deletions(-) create mode 100644 src/apps/dashboard/features/branding/api/useBrandingOptions.ts create mode 100644 src/apps/dashboard/routes/branding/index.tsx diff --git a/src/apps/dashboard/components/drawer/sections/ServerDrawerSection.tsx b/src/apps/dashboard/components/drawer/sections/ServerDrawerSection.tsx index 2c4ce010c4..be509af859 100644 --- a/src/apps/dashboard/components/drawer/sections/ServerDrawerSection.tsx +++ b/src/apps/dashboard/components/drawer/sections/ServerDrawerSection.tsx @@ -1,4 +1,5 @@ import { Dashboard, ExpandLess, ExpandMore, LibraryAdd, People, PlayCircle, Settings } from '@mui/icons-material'; +import Palette from '@mui/icons-material/Palette'; import Collapse from '@mui/material/Collapse'; import List from '@mui/material/List'; import ListItem from '@mui/material/ListItem'; @@ -65,6 +66,12 @@ const ServerDrawerSection = () => { + + + + + + diff --git a/src/apps/dashboard/features/branding/api/useBrandingOptions.ts b/src/apps/dashboard/features/branding/api/useBrandingOptions.ts new file mode 100644 index 0000000000..0537581018 --- /dev/null +++ b/src/apps/dashboard/features/branding/api/useBrandingOptions.ts @@ -0,0 +1,35 @@ +import { Api } from '@jellyfin/sdk'; +import { getBrandingApi } from '@jellyfin/sdk/lib/utils/api/branding-api'; +import { queryOptions, useQuery } from '@tanstack/react-query'; +import type { AxiosRequestConfig } from 'axios'; + +import { useApi } from 'hooks/useApi'; + +export const QUERY_KEY = 'BrandingOptions'; + +const fetchBrandingOptions = async ( + api?: Api, + options?: AxiosRequestConfig +) => { + if (!api) { + console.error('[fetchBrandingOptions] no Api instance provided'); + throw new Error('No Api instance provided to fetchBrandingOptions'); + } + + return getBrandingApi(api) + .getBrandingOptions(options) + .then(({ data }) => data); +}; + +export const getBrandingOptionsQuery = ( + api?: Api +) => queryOptions({ + queryKey: [ QUERY_KEY ], + queryFn: ({ signal }) => fetchBrandingOptions(api, { signal }), + enabled: !!api +}); + +export const useBrandingOptions = () => { + const { api } = useApi(); + return useQuery(getBrandingOptionsQuery(api)); +}; diff --git a/src/apps/dashboard/routes/_asyncRoutes.ts b/src/apps/dashboard/routes/_asyncRoutes.ts index c04243d0c6..c2e1e4a8d2 100644 --- a/src/apps/dashboard/routes/_asyncRoutes.ts +++ b/src/apps/dashboard/routes/_asyncRoutes.ts @@ -2,6 +2,7 @@ import { AsyncRouteType, type AsyncRoute } from 'components/router/AsyncRoute'; export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [ { path: 'activity', type: AsyncRouteType.Dashboard }, + { path: 'branding', type: AsyncRouteType.Dashboard }, { path: 'playback/trickplay', type: AsyncRouteType.Dashboard }, { path: 'plugins/:pluginId', page: 'plugins/plugin', type: AsyncRouteType.Dashboard }, { path: 'users', type: AsyncRouteType.Dashboard }, diff --git a/src/apps/dashboard/routes/branding/index.tsx b/src/apps/dashboard/routes/branding/index.tsx new file mode 100644 index 0000000000..3a6a5f4adb --- /dev/null +++ b/src/apps/dashboard/routes/branding/index.tsx @@ -0,0 +1,174 @@ +import type { BrandingOptions } from '@jellyfin/sdk/lib/generated-client/models/branding-options'; +import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api'; +import Alert from '@mui/material/Alert'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Stack from '@mui/material/Stack'; +import Switch from '@mui/material/Switch'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import React, { useCallback, useEffect, useState } from 'react'; +import { type ActionFunctionArgs, Form, useActionData } from 'react-router-dom'; + +import { getBrandingOptionsQuery, QUERY_KEY, useBrandingOptions } from 'apps/dashboard/features/branding/api/useBrandingOptions'; +import Loading from 'components/loading/LoadingComponent'; +import Page from 'components/Page'; +import ServerConnections from 'components/ServerConnections'; +import globalize from 'lib/globalize'; +import { queryClient } from 'utils/query/queryClient'; + +interface ActionData { + isSaved: boolean +} + +const BRANDING_CONFIG_KEY = 'branding'; +const BrandingOption = { + CustomCss: 'CustomCss', + LoginDisclaimer: 'LoginDisclaimer', + SplashscreenEnabled: 'SplashscreenEnabled' +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + const api = ServerConnections.getCurrentApi(); + if (!api) throw new Error('No Api instance available'); + + const formData = await request.formData(); + const data = Object.fromEntries(formData); + + const brandingOptions: BrandingOptions = { + CustomCss: data.CustomCss?.toString(), + LoginDisclaimer: data.LoginDisclaimer?.toString(), + SplashscreenEnabled: data.SplashscreenEnabled?.toString() === 'on' + }; + + await getConfigurationApi(api) + .updateNamedConfiguration({ + key: BRANDING_CONFIG_KEY, + body: JSON.stringify(brandingOptions) + }); + + void queryClient.invalidateQueries({ + queryKey: [ QUERY_KEY ] + }); + + return { + isSaved: true + }; +}; + +export const loader = () => { + return queryClient.ensureQueryData( + getBrandingOptionsQuery(ServerConnections.getCurrentApi())); +}; + +export const Component = () => { + const actionData = useActionData() as ActionData | undefined; + const [ isSubmitting, setIsSubmitting ] = useState(false); + + const { + data: defaultBrandingOptions, + isPending + } = useBrandingOptions(); + const [ brandingOptions, setBrandingOptions ] = useState(defaultBrandingOptions || {}); + + useEffect(() => { + setIsSubmitting(false); + }, [ actionData ]); + + const onSubmit = useCallback(() => { + setIsSubmitting(true); + }, []); + + const setSplashscreenEnabled = useCallback((_: React.ChangeEvent, isEnabled: boolean) => { + setBrandingOptions({ + ...brandingOptions, + [BrandingOption.SplashscreenEnabled]: isEnabled + }); + }, [ brandingOptions ]); + + const setBrandingOption = useCallback((event: React.ChangeEvent) => { + if (Object.keys(BrandingOption).includes(event.target.name)) { + setBrandingOptions({ + ...brandingOptions, + [event.target.name]: event.target.value + }); + } + }, [ brandingOptions ]); + + if (isPending) return ; + + return ( + + +
+ + + {globalize.translate('HeaderBranding')} + + + {!isSubmitting && actionData?.isSaved && ( + + {globalize.translate('SettingsSaved')} + + )} + + + } + label={globalize.translate('EnableSplashScreen')} + /> + + + + + + + +
+
+
+ ); +}; + +Component.displayName = 'BrandingPage'; diff --git a/src/components/ServerConnections.js b/src/components/ServerConnections.js index be2ac43877..9920b9baae 100644 --- a/src/components/ServerConnections.js +++ b/src/components/ServerConnections.js @@ -1,3 +1,6 @@ +// NOTE: This is used for jsdoc return type +// eslint-disable-next-line no-unused-vars +import { Api } from '@jellyfin/sdk'; import { MINIMUM_VERSION } from '@jellyfin/sdk/lib/versions'; import { ConnectionManager, Credentials, ApiClient } from 'jellyfin-apiclient'; @@ -6,6 +9,7 @@ import Dashboard from '../utils/dashboard'; import Events from '../utils/events.ts'; import { setUserInfo } from '../scripts/settings/userSettings'; import appSettings from '../scripts/settings/appSettings'; +import { toApi } from 'utils/jellyfin-apiclient/compat'; const normalizeImageOptions = options => { if (!options.quality && (options.maxWidth || options.width || options.maxHeight || options.height || options.fillWidth || options.fillHeight)) { @@ -111,6 +115,17 @@ class ServerConnections extends ConnectionManager { return apiClient; } + /** + * Gets the Api that is currently connected. + * @returns {Api|undefined} The current Api instance. + */ + getCurrentApi() { + const apiClient = this.currentApiClient(); + if (!apiClient) return; + + return toApi(apiClient); + } + /** * Gets the ApiClient that is currently connected or throws if not defined. * @async diff --git a/src/components/router/AsyncRoute.tsx b/src/components/router/AsyncRoute.tsx index f63e49276a..c18cd05396 100644 --- a/src/components/router/AsyncRoute.tsx +++ b/src/components/router/AsyncRoute.tsx @@ -37,16 +37,16 @@ export const toAsyncPageRoute = ({ return { path, lazy: async () => { - const { default: route } = await importRoute(page ?? path, type); + const { + // If there is a default export, use it as the Component for compatibility + default: Component, + ...route + } = await importRoute(page ?? path, type); - // If route is not a RouteObject, use it as the Component - if (!route.Component) { - return { - Component: route - }; - } - - return route; + return { + Component, + ...route + }; } }; }; diff --git a/src/elements/emby-textarea/emby-textarea.scss b/src/elements/emby-textarea/emby-textarea.scss index 0866664914..87b2c6d86f 100644 --- a/src/elements/emby-textarea/emby-textarea.scss +++ b/src/elements/emby-textarea/emby-textarea.scss @@ -6,7 +6,7 @@ /* Remove select styling */ /* Font size must the 16px or larger to prevent iOS page zoom on focus */ - font-size: inherit; + font-size: 110%; /* General select styles: change as needed */ font-family: inherit; @@ -19,6 +19,9 @@ outline: none !important; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); width: 100%; + + /* Make the height at least as tall as inputs */ + min-height: 2.5em; } .emby-textarea::-moz-focus-inner { diff --git a/src/styles/fonts.scss b/src/styles/fonts.scss index 88bf8578ad..ff001b013d 100644 --- a/src/styles/fonts.scss +++ b/src/styles/fonts.scss @@ -22,6 +22,20 @@ h3 { @include font(400, 1.17em); } +.textarea-mono { + font-family: ui-monospace, + Menlo, Monaco, + "Cascadia Mono", "Segoe UI Mono", + "Roboto Mono", + "Oxygen Mono", + "Ubuntu Mono", + "Source Code Pro", + "Fira Mono", + "Droid Sans Mono", + "Consolas", "Courier New", monospace + !important; +} + .layout-tv { /* Per WebOS and Tizen guidelines, fonts must be 20px minimum. This takes the 16px baseline and multiplies it by 1.25 to get 20px. */ From 06f2c226e1ee9808791f87770611b2445be497f6 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Wed, 20 Nov 2024 18:06:44 -0500 Subject: [PATCH 2/4] Remove branding from general settings page --- src/controllers/dashboard/general.html | 18 ------------------ src/controllers/dashboard/general.js | 25 ++++++------------------- 2 files changed, 6 insertions(+), 37 deletions(-) diff --git a/src/controllers/dashboard/general.html b/src/controllers/dashboard/general.html index 7ab360fdc3..c79fe73d6a 100644 --- a/src/controllers/dashboard/general.html +++ b/src/controllers/dashboard/general.html @@ -62,24 +62,6 @@ -
-

${HeaderBranding}

-
- -
${LabelLoginDisclaimerHelp}
-
-
- -
${LabelCustomCssHelp}
-
-
- -
-
-

${HeaderPerformance}

diff --git a/src/controllers/dashboard/general.js b/src/controllers/dashboard/general.js index 141a671a79..b131302de3 100644 --- a/src/controllers/dashboard/general.js +++ b/src/controllers/dashboard/general.js @@ -39,25 +39,17 @@ function onSubmit() { config.LibraryScanFanoutConcurrency = parseInt(form.querySelector('#txtLibraryScanFanoutConcurrency').value || '0', 10); config.ParallelImageEncodingLimit = parseInt(form.querySelector('#txtParallelImageEncodingLimit').value || '0', 10); - ApiClient.updateServerConfiguration(config).then(function() { - ApiClient.getNamedConfiguration(brandingConfigKey).then(function(brandingConfig) { - brandingConfig.LoginDisclaimer = form.querySelector('#txtLoginDisclaimer').value; - brandingConfig.CustomCss = form.querySelector('#txtCustomCss').value; - brandingConfig.SplashscreenEnabled = form.querySelector('#chkSplashScreenAvailable').checked; - - ApiClient.updateNamedConfiguration(brandingConfigKey, brandingConfig).then(function () { - Dashboard.processServerConfigurationUpdateResult(); - }); + return ApiClient.updateServerConfiguration(config) + .then(() => { + Dashboard.processServerConfigurationUpdateResult(); + }).catch(() => { + loading.hide(); + alert(globalize.translate('ErrorDefault')); }); - }, function () { - alert(globalize.translate('ErrorDefault')); - Dashboard.processServerConfigurationUpdateResult(); - }); }); return false; } -const brandingConfigKey = 'branding'; export default function (view) { $('#btnSelectCachePath', view).on('click.selectDirectory', function () { import('../../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => { @@ -107,11 +99,6 @@ export default function (view) { Promise.all([promiseConfig, promiseLanguageOptions, promiseSystemInfo]).then(function (responses) { loadPage(view, responses[0], responses[1], responses[2]); }); - ApiClient.getNamedConfiguration(brandingConfigKey).then(function (config) { - view.querySelector('#txtLoginDisclaimer').value = config.LoginDisclaimer || ''; - view.querySelector('#txtCustomCss').value = config.CustomCss || ''; - view.querySelector('#chkSplashScreenAvailable').checked = config.SplashscreenEnabled === true; - }); }); } From edacbb6c323a19c3e07f5354f5debddd886caaac Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Thu, 21 Nov 2024 13:54:01 -0500 Subject: [PATCH 3/4] Fix stylelint issues --- src/styles/fonts.scss | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/styles/fonts.scss b/src/styles/fonts.scss index ff001b013d..5496ef6b41 100644 --- a/src/styles/fonts.scss +++ b/src/styles/fonts.scss @@ -23,17 +23,21 @@ h3 { } .textarea-mono { - font-family: ui-monospace, - Menlo, Monaco, - "Cascadia Mono", "Segoe UI Mono", + font-family: + ui-monospace, + Menlo, + Monaco, + "Cascadia Mono", + "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Mono", "Source Code Pro", "Fira Mono", "Droid Sans Mono", - "Consolas", "Courier New", monospace - !important; + "Consolas", + "Courier New", + monospace !important; } .layout-tv { From fbaab4e3c832a992ec4aa56639864761250cb552 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Thu, 21 Nov 2024 14:07:08 -0500 Subject: [PATCH 4/4] Set min rows on text areas --- src/apps/dashboard/routes/branding/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/apps/dashboard/routes/branding/index.tsx b/src/apps/dashboard/routes/branding/index.tsx index 3a6a5f4adb..9958f13803 100644 --- a/src/apps/dashboard/routes/branding/index.tsx +++ b/src/apps/dashboard/routes/branding/index.tsx @@ -133,6 +133,7 @@ export const Component = () => { {