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 (
+
+
+
+
+
+ );
+};
+
+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. */