0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-11 02:12:21 -05:00

Regrouped design setting tabs

REF DES-831
- Changed tabs from `Brand`, `Site wide`, `Homepage` and `Post` to `Global` and `Theme settings`.
- Added `Site wide`, `Homepage` and `Post` grouping inside the `Theme settings` tab.
This commit is contained in:
Sanne de Vries 2024-10-09 16:25:27 +01:00 committed by Aileen Booker
parent 4bf7b86ef4
commit 7725d78670
6 changed files with 380 additions and 122 deletions

View file

@ -28,7 +28,7 @@ const ToggleGroup: React.FC<ToggleGroupProps> = ({children, gap = 'md', classNam
}
className = clsx(
'flex flex-col gap-3',
'flex flex-col',
gapClass,
className
);

View file

@ -1,4 +1,4 @@
import BrandSettings, {BrandSettingValues} from './designAndBranding/BrandSettings';
import GlobalSettings, {GlobalSettingValues} from './designAndBranding/GlobalSettings';
import React, {useEffect, useState} from 'react';
import ThemePreview from './designAndBranding/ThemePreview';
import ThemeSettings from './designAndBranding/ThemeSettings';
@ -13,32 +13,32 @@ import {useGlobalData} from '../../providers/GlobalDataProvider';
import {useRouting} from '@tryghost/admin-x-framework/routing';
const Sidebar: React.FC<{
brandSettings: BrandSettingValues
globalSettings: GlobalSettingValues
themeSettingSections: Array<{id: string, title: string, settings: CustomThemeSetting[]}>
updateBrandSetting: (key: string, value: SettingValue) => void
updateGlobalSetting: (key: string, value: SettingValue) => void
updateThemeSetting: (updated: CustomThemeSetting) => void
onTabChange: (id: string) => void
handleSave: () => Promise<boolean>
}> = ({
brandSettings,
globalSettings,
themeSettingSections,
updateBrandSetting,
updateGlobalSetting,
updateThemeSetting,
onTabChange
}) => {
const [selectedTab, setSelectedTab] = useState('brand');
const [selectedTab, setSelectedTab] = useState('global');
const tabs: Tab[] = [
{
id: 'brand',
title: 'Brand',
contents: <BrandSettings updateSetting={updateBrandSetting} values={brandSettings} />
id: 'global',
title: 'Global',
contents: <GlobalSettings updateSetting={updateGlobalSetting} values={globalSettings} />
},
...themeSettingSections.map(({id, title, settings}) => ({
id,
title,
contents: <ThemeSettings settings={settings} updateSetting={updateThemeSetting} />
}))
{
id: 'theme-settings',
title: 'Theme settings',
contents: <ThemeSettings sections={themeSettingSections} updateSetting={updateThemeSetting} />
}
];
const handleTabChange = (id: string) => {
@ -52,7 +52,7 @@ const Sidebar: React.FC<{
{tabs.length > 1 ?
<TabView selectedTab={selectedTab} tabs={tabs} onTabChange={handleTabChange} />
:
<BrandSettings updateSetting={updateBrandSetting} values={brandSettings} />
<GlobalSettings updateSetting={updateGlobalSetting} values={globalSettings} />
}
</div>
</div>
@ -111,7 +111,7 @@ const DesignModal: React.FC = () => {
}
}, [setFormState, themeSettings]);
const updateBrandSetting = (key: string, value: SettingValue) => {
const updateGlobalSetting = (key: string, value: SettingValue) => {
updateForm(state => ({...state, settings: state.settings.map(setting => (
setting.key === key ? {...setting, value, dirty: true} : setting
))}));
@ -188,10 +188,10 @@ const DesignModal: React.FC = () => {
/>;
const sidebarContent =
<Sidebar
brandSettings={{description, accentColor, icon, logo, coverImage, headingFont, bodyFont}}
globalSettings={{description, accentColor, icon, logo, coverImage, headingFont, bodyFont}}
handleSave={handleSave}
themeSettingSections={themeSettingSections}
updateBrandSetting={updateBrandSetting}
updateGlobalSetting={updateGlobalSetting}
updateThemeSetting={updateThemeSetting}
onTabChange={onTabChange}
/>;

View file

@ -0,0 +1,230 @@
import React, {useState} from 'react';
import UnsplashSelector from '../../../selectors/UnsplashSelector';
import usePinturaEditor from '../../../../hooks/usePinturaEditor';
import {APIError} from '@tryghost/admin-x-framework/errors';
import {CUSTOM_FONTS} from '@tryghost/custom-fonts';
import {ColorPickerField, Form, Heading, Hint, ImageUpload, Select} from '@tryghost/admin-x-design-system';
import {SettingValue, getSettingValues} from '@tryghost/admin-x-framework/api/settings';
import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images';
import {useFramework} from '@tryghost/admin-x-framework';
import {useGlobalData} from '../../../providers/GlobalDataProvider';
import {useHandleError} from '@tryghost/admin-x-framework/hooks';
import type {Font} from '@tryghost/custom-fonts';
// TODO: create custom types for heading and body fonts in @tryghost/custom-fonts, so we can extend
// them separately
type BodyFontOption = {
value: Font | 'Theme default',
label: Font | 'Theme default'
};
type HeadingFontOption = BodyFontOption;
export interface GlobalSettingValues {
description: string
accentColor: string
icon: string | null
logo: string | null
coverImage: string | null
headingFont: string
bodyFont: string
}
const DEFAULT_FONT = 'Theme default';
const GlobalSettings: React.FC<{ values: GlobalSettingValues, updateSetting: (key: string, value: SettingValue) => void }> = ({values,updateSetting}) => {
const {mutateAsync: uploadImage} = useUploadImage();
const {settings} = useGlobalData();
const [unsplashEnabled] = getSettingValues<boolean>(settings, ['unsplash']);
const [showUnsplash, setShowUnsplash] = useState<boolean>(false);
const {unsplashConfig} = useFramework();
const handleError = useHandleError();
const editor = usePinturaEditor();
const [headingFont, setHeadingFont] = useState(values.headingFont || DEFAULT_FONT);
const [bodyFont, setBodyFont] = useState(values.bodyFont || DEFAULT_FONT);
// TODO: replace with getCustomFonts() once custom-fonts is updated and differentiates
// between heading and body fonts
const customHeadingFonts: HeadingFontOption[] = CUSTOM_FONTS.map(x => ({label: x, value: x}));
customHeadingFonts.unshift({label: DEFAULT_FONT, value: DEFAULT_FONT});
const customBodyFonts: BodyFontOption[] = CUSTOM_FONTS.map(x => ({label: x, value: x}));
customBodyFonts.unshift({label: DEFAULT_FONT, value: DEFAULT_FONT});
const selectedHeadingFont = {label: headingFont, value: headingFont};
const selectedBodyFont = {label: bodyFont, value: bodyFont};
return (
<>
<Form className='mt-4' gap='sm' margins='lg' title=''>
<ColorPickerField
debounceMs={200}
direction='rtl'
title={<Heading className='mt-[3px]' level={6}>Accent color</Heading>}
value={values.accentColor}
// we debounce this because the color picker fires a lot of events.
onChange={value => updateSetting('accent_color', value)}
/>
<div className='flex items-start justify-between'>
<div>
<Heading level={6}>Publication icon</Heading>
<Hint className='!mt-0 mr-5 max-w-[160px]'>A square, social icon, at least 60x60px</Hint>
</div>
<div className='flex gap-3'>
<ImageUpload
deleteButtonClassName='!top-1 !right-1'
editButtonClassName='!top-1 !right-1'
height={values.icon ? '66px' : '36px'}
id='logo'
imageBWCheckedBg={true}
imageURL={values.icon || ''}
width={values.icon ? '66px' : '160px'}
onDelete={() => updateSetting('icon', null)}
onUpload={async (file) => {
try {
updateSetting('icon', getImageUrl(await uploadImage({file})));
} catch (e) {
const error = e as APIError;
if (error.response!.status === 415) {
error.message = 'Unsupported file type';
}
handleError(error);
}
}}
>
Upload icon
</ImageUpload>
</div>
</div>
<div className={`flex items-start justify-between ${values.icon && 'mt-2'}`}>
<div>
<Heading level={6}>Publication logo</Heading>
<Hint className='!mt-0 mr-5 max-w-[160px]'>Appears usually in the main header of your theme</Hint>
</div>
<div>
<ImageUpload
deleteButtonClassName='!top-1 !right-1'
height='60px'
id='site-logo'
imageBWCheckedBg={true}
imageFit='contain'
imageURL={values.logo || ''}
width='160px'
onDelete={() => updateSetting('logo', null)}
onUpload={async (file) => {
try {
updateSetting('logo', getImageUrl(await uploadImage({file})));
} catch (e) {
const error = e as APIError;
if (error.response!.status === 415) {
error.message = 'Unsupported file type';
}
handleError(error);
}
}}
>
Upload logo
</ImageUpload>
</div>
</div>
<div className='mt-2 flex items-start justify-between'>
<div>
<Heading level={6}>Publication cover</Heading>
<Hint className='!mt-0 mr-5 max-w-[160px]'>Usually as a large banner image on your index pages</Hint>
</div>
<ImageUpload
deleteButtonClassName='!top-1 !right-1'
editButtonClassName='!top-1 !right-10'
height='95px'
id='cover'
imageURL={values.coverImage || ''}
openUnsplash={() => setShowUnsplash(true)}
pintura={
{
isEnabled: editor.isEnabled,
openEditor: async () => editor.openEditor({
image: values.coverImage || '',
handleSave: async (file:File) => {
try {
updateSetting('cover_image', getImageUrl(await uploadImage({file})));
} catch (e) {
handleError(e);
}
}
})
}
}
unsplashButtonClassName='!bg-transparent !h-6 !top-1.5 !w-6 !right-1.5 z-50'
unsplashEnabled={unsplashEnabled}
width='160px'
onDelete={() => updateSetting('cover_image', null)}
onUpload={async (file: any) => {
try {
updateSetting('cover_image', getImageUrl(await uploadImage({file})));
} catch (e) {
const error = e as APIError;
if (error.response!.status === 415) {
error.message = 'Unsupported file type';
}
handleError(error);
}
}}
>
Upload cover
</ImageUpload>
{
showUnsplash && unsplashConfig && unsplashEnabled && (
<UnsplashSelector
unsplashProviderConfig={unsplashConfig}
onClose={() => {
setShowUnsplash(false);
}}
onImageInsert={(image) => {
if (image.src) {
updateSetting('cover_image', image.src);
}
setShowUnsplash(false);
}}
/>
)
}
</div>
</Form>
<Form className='-mt-4' gap='sm' margins='lg' title='Typography'>
<Select
hint={''}
options={customHeadingFonts}
selectedOption={selectedHeadingFont}
title={'Heading font'}
onSelect={(option) => {
if (option?.value === DEFAULT_FONT) {
setHeadingFont(DEFAULT_FONT);
updateSetting('heading_font', '');
} else {
setHeadingFont(option?.value || '');
updateSetting('heading_font', option?.value || '');
}
}}
/>
<Select
hint={''}
options={customBodyFonts}
selectedOption={selectedBodyFont}
title={'Body font'}
onSelect={(option) => {
if (option?.value === DEFAULT_FONT) {
setBodyFont(DEFAULT_FONT);
updateSetting('body_font', '');
} else {
setBodyFont(option?.value || '');
updateSetting('body_font', option?.value || '');
}
}}
/>
</Form>
</>
);
};
export default GlobalSettings;

View file

@ -3,7 +3,7 @@ import React, {useCallback} from 'react';
import {CustomThemeSetting, hiddenCustomThemeSettingValue} from '@tryghost/admin-x-framework/api/customThemeSettings';
import {isCustomThemeSettingVisible} from '../../../../utils/isCustomThemeSettingsVisible';
type BrandSettings = {
type GlobalSettings = {
description: string;
accentColor: string;
icon: string;
@ -15,7 +15,7 @@ type BrandSettings = {
}
interface ThemePreviewProps {
settings: BrandSettings
settings: GlobalSettings
url: string
}

View file

@ -0,0 +1,99 @@
import React, {useEffect, useState} from 'react';
import {ColorPickerField, Heading, Hint, ImageUpload, Select, TextField, Toggle} from '@tryghost/admin-x-design-system';
import {CustomThemeSetting} from '@tryghost/admin-x-framework/api/customThemeSettings';
import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images';
import {humanizeSettingKey} from '@tryghost/admin-x-framework/api/settings';
import {useHandleError} from '@tryghost/admin-x-framework/hooks';
interface ThemeSettingProps {
setting: CustomThemeSetting;
setSetting: <Setting extends CustomThemeSetting>(value: Setting['value']) => void;
}
const ThemeSetting: React.FC<ThemeSettingProps> = ({setting, setSetting}) => {
const [fieldValues, setFieldValues] = useState<{ [key: string]: string | null }>({});
useEffect(() => {
const valueAsString = setting.value === null ? '' : String(setting.value);
setFieldValues(values => ({...values, [setting.key]: valueAsString}));
}, [setting]);
const handleBlur = (key: string) => {
if (fieldValues[key] !== undefined) {
setSetting(fieldValues[key]);
}
};
const handleChange = (key: string, value: string) => {
setFieldValues(values => ({...values, [key]: value}));
};
const {mutateAsync: uploadImage} = useUploadImage();
const handleError = useHandleError();
const handleImageUpload = async (file: File) => {
try {
const imageUrl = getImageUrl(await uploadImage({file}));
setSetting(imageUrl);
} catch (e) {
handleError(e);
}
};
switch (setting.type) {
case 'text':
return (
<TextField
hint={setting.description}
title={humanizeSettingKey(setting.key)}
value={fieldValues[setting.key] || ''}
onBlur={() => handleBlur(setting.key)}
onChange={event => handleChange(setting.key, event.target.value)}
/>
);
case 'boolean':
return (
<Toggle
checked={setting.value}
direction="rtl"
hint={setting.description}
label={humanizeSettingKey(setting.key)}
onChange={event => setSetting(event.target.checked)}
/>
);
case 'select':
return (
<Select
hint={setting.description}
options={setting.options.map(option => ({label: option, value: option}))}
selectedOption={{label: setting.value, value: setting.value}}
testId={`setting-select-${setting.key}`}
title={humanizeSettingKey(setting.key)}
onSelect={option => setSetting(option?.value || null)}
/>
);
case 'color':
return (
<ColorPickerField
debounceMs={200}
direction='rtl'
hint={setting.description}
title={humanizeSettingKey(setting.key)}
value={setting.value || ''}
onChange={value => setSetting(value)}
/>
);
case 'image':
return <>
<Heading useLabelTag>{humanizeSettingKey(setting.key)}</Heading>
<ImageUpload
height={setting.value ? '100px' : '32px'}
id={`custom-${setting.key}`}
imageURL={setting.value || ''}
onDelete={() => setSetting(null)}
onUpload={file => handleImageUpload(file)}
>Upload image</ImageUpload>
{setting.description && <Hint>{setting.description}</Hint>}
</>;
}
};
export default ThemeSetting;

View file

@ -1,109 +1,38 @@
import React, {useEffect, useState} from 'react';
import {ColorPickerField, Heading, Hint, ImageUpload, Select, SettingGroupContent, TextField, Toggle} from '@tryghost/admin-x-design-system';
import React from 'react';
import ThemeSetting from './ThemeSetting';
import {CustomThemeSetting} from '@tryghost/admin-x-framework/api/customThemeSettings';
import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images';
import {humanizeSettingKey} from '@tryghost/admin-x-framework/api/settings';
import {Form} from '@tryghost/admin-x-design-system';
import {isCustomThemeSettingVisible} from '../../../../utils/isCustomThemeSettingsVisible';
import {useHandleError} from '@tryghost/admin-x-framework/hooks';
const ThemeSetting: React.FC<{
setting: CustomThemeSetting,
setSetting: <Setting extends CustomThemeSetting>(value: Setting['value']) => void
}> = ({setting, setSetting}) => {
const [fieldValues, setFieldValues] = useState<{ [key: string]: string | null }>({});
useEffect(() => {
const valueAsString = setting.value === null ? '' : String(setting.value);
setFieldValues(values => ({...values, [setting.key]: valueAsString}));
}, [setting]);
const handleBlur = (key: string) => {
if (fieldValues[key] !== undefined) {
setSetting(fieldValues[key]);
}
};
const handleChange = (key: string, value: string) => {
setFieldValues(values => ({...values, [key]: value}));
};
const {mutateAsync: uploadImage} = useUploadImage();
const handleError = useHandleError();
const handleImageUpload = async (file: File) => {
try {
const imageUrl = getImageUrl(await uploadImage({file}));
setSetting(imageUrl);
} catch (e) {
handleError(e);
}
};
switch (setting.type) {
case 'text':
return (
<TextField
hint={setting.description}
title={humanizeSettingKey(setting.key)}
value={fieldValues[setting.key] || ''}
onBlur={() => handleBlur(setting.key)}
onChange={event => handleChange(setting.key, event.target.value)}
/>
);
case 'boolean':
return (
<Toggle
checked={setting.value}
direction="rtl"
hint={setting.description}
label={humanizeSettingKey(setting.key)}
onChange={event => setSetting(event.target.checked)}
/>
);
case 'select':
return (
<Select
hint={setting.description}
options={setting.options.map(option => ({label: option, value: option}))}
selectedOption={{label: setting.value, value: setting.value}}
testId={`setting-select-${setting.key}`}
title={humanizeSettingKey(setting.key)}
onSelect={option => setSetting(option?.value || null)}
/>
);
case 'color':
return (
<ColorPickerField
debounceMs={200}
direction='rtl'
hint={setting.description}
title={humanizeSettingKey(setting.key)}
value={setting.value || ''}
onChange={value => setSetting(value)}
/>
);
case 'image':
return <>
<Heading useLabelTag>{humanizeSettingKey(setting.key)}</Heading>
<ImageUpload
height={setting.value ? '100px' : '32px'}
id={`custom-${setting.key}`}
imageURL={setting.value || ''}
onDelete={() => setSetting(null)}
onUpload={file => handleImageUpload(file)}
>Upload image</ImageUpload>
{setting.description && <Hint>{setting.description}</Hint>}
</>;
}
};
const ThemeSettings: React.FC<{ settings: CustomThemeSetting[], updateSetting: (setting: CustomThemeSetting) => void }> = ({settings, updateSetting}) => {
// Filter out custom theme settings that should not be visible
const settingsKeyValueObj = settings.reduce((obj, {key, value}) => ({...obj, [key]: value}), {});
const filteredSettings = settings.filter(setting => isCustomThemeSettingVisible(setting, settingsKeyValueObj));
interface ThemeSettingsProps {
sections: Array<{
id: string;
title: string;
settings: CustomThemeSetting[];
}>;
updateSetting: (setting: CustomThemeSetting) => void;
}
const ThemeSettings: React.FC<ThemeSettingsProps> = ({sections, updateSetting}) => {
return (
<SettingGroupContent className='mt-7'>
{filteredSettings.map(setting => <ThemeSetting key={setting.key} setSetting={value => updateSetting({...setting, value} as CustomThemeSetting)} setting={setting} />)}
</SettingGroupContent>
<>
{sections.map((section) => {
const filteredSettings = section.settings.filter(setting => isCustomThemeSettingVisible(setting, section.settings.reduce((obj, {key, value}) => ({...obj, [key]: value}), {}))
);
return (
<Form key={section.id} className='first-of-type:mt-6' gap='sm' margins='lg' title={section.title}>
{filteredSettings.map(setting => (
<ThemeSetting
key={setting.key}
setSetting={value => updateSetting({...setting, value} as CustomThemeSetting)}
setting={setting}
/>
))}
</Form>
);
})}
</>
);
};