Add dashboard branding page

This commit is contained in:
Bill Thornton 2024-11-20 17:56:59 -05:00
parent 441494d5b2
commit ca5f94df63
8 changed files with 259 additions and 10 deletions

View File

@ -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 = () => {
<ListItemText primary={globalize.translate('General')} />
</ListItemLink>
</ListItem>
<ListItemLink to='/dashboard/branding'>
<ListItemIcon>
<Palette />
</ListItemIcon>
<ListItemText primary={globalize.translate('HeaderBranding')} />
</ListItemLink>
<ListItem disablePadding>
<ListItemLink to='/dashboard/users'>
<ListItemIcon>

View File

@ -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));
};

View File

@ -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 },

View File

@ -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<HTMLInputElement>, isEnabled: boolean) => {
setBrandingOptions({
...brandingOptions,
[BrandingOption.SplashscreenEnabled]: isEnabled
});
}, [ brandingOptions ]);
const setBrandingOption = useCallback((event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
if (Object.keys(BrandingOption).includes(event.target.name)) {
setBrandingOptions({
...brandingOptions,
[event.target.name]: event.target.value
});
}
}, [ brandingOptions ]);
if (isPending) return <Loading />;
return (
<Page
id='brandingPage'
className='mainAnimatedPage type-interior'
>
<Box className='content-primary'>
<Form
method='POST'
onSubmit={onSubmit}
>
<Stack spacing={3}>
<Typography variant='h1'>
{globalize.translate('HeaderBranding')}
</Typography>
{!isSubmitting && actionData?.isSaved && (
<Alert severity='success'>
{globalize.translate('SettingsSaved')}
</Alert>
)}
<FormControlLabel
control={
<Switch
name={BrandingOption.SplashscreenEnabled}
checked={brandingOptions?.SplashscreenEnabled}
onChange={setSplashscreenEnabled}
/>
}
label={globalize.translate('EnableSplashScreen')}
/>
<TextField
fullWidth
multiline
maxRows={5}
InputProps={{
className: 'textarea-mono'
}}
name={BrandingOption.LoginDisclaimer}
label={globalize.translate('LabelLoginDisclaimer')}
helperText={globalize.translate('LabelLoginDisclaimerHelp')}
value={brandingOptions?.LoginDisclaimer}
onChange={setBrandingOption}
/>
<TextField
fullWidth
multiline
maxRows={20}
InputProps={{
className: 'textarea-mono'
}}
name={BrandingOption.CustomCss}
label={globalize.translate('LabelCustomCss')}
helperText={globalize.translate('LabelCustomCssHelp')}
value={brandingOptions?.CustomCss}
onChange={setBrandingOption}
/>
<Button
type='submit'
size='large'
>
{globalize.translate('Save')}
</Button>
</Stack>
</Form>
</Box>
</Page>
);
};
Component.displayName = 'BrandingPage';

View File

@ -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

View File

@ -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
};
}
};
};

View File

@ -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 {

View File

@ -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. */