Updated design settings to be editable and show theme settings

refs https://github.com/TryGhost/Team/issues/3354
This commit is contained in:
Jono Mingard 2023-06-08 10:30:25 +12:00
parent 567422174d
commit 446841ce9c
12 changed files with 325 additions and 91 deletions

View File

@ -14,12 +14,13 @@ export interface PreviewModalProps {
okColor?: string;
onCancel?: () => void;
onOk?: () => void;
buttonsDisabled?: boolean
customButtons?: React.ReactNode;
customHeader?: React.ReactNode;
sidebarPadding?: boolean;
}
const PreviewModal: React.FC<PreviewModalProps> = ({
export const PreviewModalContent: React.FC<PreviewModalProps> = ({
title,
sidebar,
preview,
@ -28,6 +29,7 @@ const PreviewModal: React.FC<PreviewModalProps> = ({
okColor = 'black',
onCancel,
onOk,
buttonsDisabled,
customButtons,
customHeader,
sidebarPadding = true
@ -41,7 +43,8 @@ const PreviewModal: React.FC<PreviewModalProps> = ({
label: cancelLabel,
onClick: (onCancel ? onCancel : () => {
modal.remove();
})
}),
disabled: buttonsDisabled
});
buttons.push({
@ -49,7 +52,8 @@ const PreviewModal: React.FC<PreviewModalProps> = ({
label: okLabel,
color: okColor,
className: 'min-w-[80px]',
onClick: onOk
onClick: onOk,
disabled: buttonsDisabled
});
}
@ -82,4 +86,4 @@ const PreviewModal: React.FC<PreviewModalProps> = ({
);
};
export default NiceModal.create(PreviewModal);
export default NiceModal.create(PreviewModalContent);

View File

@ -3,13 +3,11 @@ import Hint from './Hint';
import React, {useId} from 'react';
import clsx from 'clsx';
type TextFieldType = 'text' | 'number' | 'email' | 'password' | 'file' | 'date' | 'time' | 'search';
interface TextFieldProps {
inputRef?: React.RefObject<HTMLInputElement>;
title?: string;
hideTitle?: boolean;
type?: TextFieldType;
type?: React.InputHTMLAttributes<HTMLInputElement>['type'];
value?: string;
error?: boolean;
placeholder?: string;

View File

@ -1,6 +1,5 @@
import React from 'react';
import React, {createContext} from 'react';
import setupGhostApi from '../../utils/api';
import {createContext} from 'react';
export interface FileService {
uploadImage: (file: File) => Promise<string>;
@ -21,6 +20,7 @@ const ServicesContext = createContext<ServicesContextProps>({
});
const ServicesProvider: React.FC<ServicesProviderProps> = ({children, ghostVersion}) => {
// TODO: Will this re-render? (if so, will it make duplicate requests because the api object is different?)
const apiService = setupGhostApi({ghostVersion});
const fileService = {
uploadImage: async (file: File): Promise<string> => {
@ -38,4 +38,4 @@ const ServicesProvider: React.FC<ServicesProviderProps> = ({children, ghostVersi
);
};
export {ServicesContext, ServicesProvider};
export {ServicesContext, ServicesProvider};

View File

@ -1,37 +1,33 @@
import BrandSettings from './designAndBranding/BrandSettings';
import Button from '../../../admin-x-ds/global/Button';
import NiceModal from '@ebay/nice-modal-react';
import PreviewModal from '../../../admin-x-ds/global/PreviewModal';
import React from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import BrandSettings, {BrandSettingValues} from './designAndBranding/BrandSettings';
import ConfirmationModal from '../../../admin-x-ds/global/ConfirmationModal';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React, {useContext, useEffect, useState} from 'react';
import StickyFooter from '../../../admin-x-ds/global/StickyFooter';
import TabView from '../../../admin-x-ds/global/TabView';
import TabView, {Tab} from '../../../admin-x-ds/global/TabView';
import ThemePreview from './designAndBranding/ThemePreivew';
import ThemeSettings from './designAndBranding/ThemeSettings';
import {Tab} from '../../../admin-x-ds/global/TabView';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {CustomThemeSetting, SettingValue} from '../../../types/api';
import {PreviewModalContent} from '../../../admin-x-ds/global/PreviewModal';
import {ServicesContext} from '../../providers/ServiceProvider';
const Sidebar: React.FC = () => {
const Sidebar: React.FC<{
brandSettings: BrandSettingValues
updateBrandSetting: (key: string, value: SettingValue) => void
themeSettingSections: Array<{id: string, title: string, settings: CustomThemeSetting[]}>
updateThemeSetting: (updated: CustomThemeSetting) => void
}> = ({brandSettings,updateBrandSetting,themeSettingSections,updateThemeSetting}) => {
const tabs: Tab[] = [
{
id: 'brand',
title: 'Brand',
contents: <BrandSettings />
contents: <BrandSettings updateSetting={updateBrandSetting} values={brandSettings} />
},
{
id: 'site-wide',
title: 'Site wide',
contents: <ThemeSettings settingSection='site-wide' />
},
{
id: 'homepage',
title: 'Homepage',
contents: <ThemeSettings settingSection='homepage' />
},
{
id: 'post',
title: 'Post',
contents: <ThemeSettings settingSection='post' />
}
...themeSettingSections.map(({id, title, settings}) => ({
id,
title,
contents: <ThemeSettings settings={settings} updateSetting={updateThemeSetting} />
}))
];
return (
@ -50,24 +46,93 @@ const Sidebar: React.FC = () => {
};
const DesignModal: React.FC = () => {
const openPreviewModal = () => {
NiceModal.show(PreviewModal, {
title: 'Design',
okLabel: 'Save',
preview: <ThemePreview />,
sidebar: <Sidebar />,
sidebarPadding: false
const modal = useModal();
const {api} = useContext(ServicesContext);
const [themeSettings, setThemeSettings] = useState<Array<CustomThemeSetting & { dirty?: boolean }>>([]);
useEffect(() => {
api.customThemeSettings.browse().then((response) => {
setThemeSettings(response.custom_theme_settings);
});
}, [api]);
const {
saveState,
handleSave,
updateSetting,
getSettingValues,
dirty
} = useSettingGroup({
onSave: async () => {
if (themeSettings.some(setting => setting.dirty)) {
const response = await api.customThemeSettings.edit(themeSettings);
setThemeSettings(response.custom_theme_settings);
}
}
});
const [description, accentColor, icon, logo, coverImage] = getSettingValues(['description', 'accent_color', 'icon', 'logo', 'cover_image']) as string[];
const themeSettingGroups = themeSettings.reduce((groups, setting) => {
const group = (setting.group === 'homepage' || setting.group === 'post') ? setting.group : 'site-wide';
return {
...groups,
[group]: (groups[group] || []).concat(setting)
};
}, {} as {[key: string]: CustomThemeSetting[] | undefined});
const themeSettingSections = Object.entries(themeSettingGroups).map(([id, settings]) => ({
id,
settings: settings || [],
title: id === 'site-wide' ? 'Site wide' : (id === 'homepage' ? 'Homepage' : 'Post')
}));
const updateThemeSetting = (updated: CustomThemeSetting) => {
setThemeSettings(themeSettings.map(setting => (
setting.key === updated.key ? {...updated, dirty: true} : setting
)));
};
return (
<SettingGroup
customButtons={<Button color='green' label='Customize' link onClick={openPreviewModal}/>}
description="Customize your site and manage themes"
navid='branding-and-design'
title="Branding and design"
/>
);
return <PreviewModalContent
buttonsDisabled={saveState === 'saving'}
okLabel='Save'
preview={<ThemePreview />}
sidebar={<Sidebar
brandSettings={{description, accentColor, icon, logo, coverImage}}
themeSettingSections={themeSettingSections}
updateBrandSetting={updateSetting}
updateThemeSetting={updateThemeSetting}
/>}
sidebarPadding={false}
title='Design'
onCancel={() => {
if (dirty || themeSettings.some(setting => setting.dirty)) {
NiceModal.show(ConfirmationModal, {
title: 'Are you sure you want to leave this page?',
prompt: (
<>
<p>Hey there! It looks like you didn&lsquo;t save the changes you made.</p>
<p>Save before you go!</p>
</>
),
okLabel: 'Leave',
okColor: 'red',
onOk: (confirmModal) => {
confirmModal?.remove();
modal.remove();
},
cancelLabel: 'Stay'
});
} else {
modal.remove();
}
}}
onOk={async () => {
await handleSave();
modal.remove();
}}
/>;
};
export default DesignModal;
export default NiceModal.create(DesignModal);

View File

@ -0,0 +1,22 @@
import Button from '../../../admin-x-ds/global/Button';
import DesignModal from './DesignModal';
import NiceModal from '@ebay/nice-modal-react';
import React from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
const DesignSetting: React.FC = () => {
const openPreviewModal = () => {
NiceModal.show(DesignModal);
};
return (
<SettingGroup
customButtons={<Button color='green' label='Customize' link onClick={openPreviewModal}/>}
description="Customize your site and manage themes"
navid='branding-and-design'
title="Branding and design"
/>
);
};
export default DesignSetting;

View File

@ -1,4 +1,4 @@
import DesignModal from './DesignModal';
import DesignSetting from './DesignSetting';
import React from 'react';
import SettingSection from '../../../admin-x-ds/settings/SettingSection';
@ -6,10 +6,10 @@ const SiteSettings: React.FC = () => {
return (
<>
<SettingSection title="Site">
<DesignModal />
<DesignSetting />
</SettingSection>
</>
);
};
export default SiteSettings;
export default SiteSettings;

View File

@ -1,17 +1,22 @@
import Heading from '../../../../admin-x-ds/global/Heading';
import Hint from '../../../../admin-x-ds/global/Hint';
import ImageUpload from '../../../../admin-x-ds/global/ImageUpload';
import React from 'react';
import React, {useContext} from 'react';
import SettingGroupContent from '../../../../admin-x-ds/settings/SettingGroupContent';
import TextField from '../../../../admin-x-ds/global/TextField';
import useSettingGroup from '../../../../hooks/useSettingGroup';
import {ServicesContext} from '../../../providers/ServiceProvider';
import {SettingValue} from '../../../../types/api';
const BrandSettings: React.FC = () => {
const {
getSettingValues
} = useSettingGroup();
export interface BrandSettingValues {
description: string
accentColor: string
icon: string | null
logo: string | null
coverImage: string | null
}
const [description, accentColor] = getSettingValues(['description', 'accent_color']) as string[];
const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key: string, value: SettingValue) => void }> = ({values,updateSetting}) => {
const {fileService} = useContext(ServicesContext);
return (
<div className='mt-7'>
@ -21,7 +26,8 @@ const BrandSettings: React.FC = () => {
clearBg={true}
hint='Used in your theme, meta data and search results'
title='Site description'
value={description}
value={values.description}
onChange={event => updateSetting('description', event.target.value)}
/>
<div className='flex items-center justify-between gap-3'>
<Heading level={6}>Accent color</Heading>
@ -32,7 +38,9 @@ const BrandSettings: React.FC = () => {
className='text-right'
clearBg={true}
maxLength={7}
value={accentColor}
type='color'
value={values.accentColor}
onChange={event => updateSetting('accent_color', event.target.value)}
/>
</div>
</div>
@ -44,8 +52,10 @@ const BrandSettings: React.FC = () => {
height='36px'
id='logo'
width='150px'
onDelete={() => {}}
onUpload={() => {}}
onDelete={() => updateSetting('icon', null)}
onUpload={async (file) => {
updateSetting('icon', await fileService!.uploadImage(file));
}}
>
Upload icon
</ImageUpload>
@ -56,8 +66,10 @@ const BrandSettings: React.FC = () => {
<ImageUpload
height='80px'
id='logo'
onDelete={() => {}}
onUpload={() => {}}
onDelete={() => updateSetting('logo', null)}
onUpload={async (file) => {
updateSetting('logo', await fileService!.uploadImage(file));
}}
>
Upload logo
</ImageUpload>
@ -67,8 +79,10 @@ const BrandSettings: React.FC = () => {
<ImageUpload
height='140px'
id='cover'
onDelete={() => {}}
onUpload={() => {}}
onDelete={() => updateSetting('cover_image', null)}
onUpload={async (file) => {
updateSetting('cover_image', await fileService!.uploadImage(file));
}}
>
Upload cover
</ImageUpload>
@ -78,4 +92,4 @@ const BrandSettings: React.FC = () => {
);
};
export default BrandSettings;
export default BrandSettings;

View File

@ -1,17 +1,81 @@
import React from 'react';
import Heading from '../../../../admin-x-ds/global/Heading';
import ImageUpload from '../../../../admin-x-ds/global/ImageUpload';
import React, {useContext} from 'react';
import Select from '../../../../admin-x-ds/global/Select';
import SettingGroupContent from '../../../../admin-x-ds/settings/SettingGroupContent';
import TextField from '../../../../admin-x-ds/global/TextField';
import Toggle from '../../../../admin-x-ds/global/Toggle';
import {CustomThemeSetting} from '../../../../types/api';
import {ServicesContext} from '../../../providers/ServiceProvider';
import {humanizeSettingKey} from '../../../../utils/helpers';
type ThemeSettingSection = 'site-wide' | 'homepage' | 'post';
const ThemeSetting: React.FC<{
setting: CustomThemeSetting,
setSetting: <Setting extends CustomThemeSetting>(value: Setting['value']) => void
}> = ({setting, setSetting}) => {
const {fileService} = useContext(ServicesContext);
interface ThemeSettingsProps {
settingSection: ThemeSettingSection;
}
const handleImageUpload = async (file: File) => {
const imageUrl = await fileService!.uploadImage(file);
setSetting(imageUrl);
};
const ThemeSettings: React.FC<ThemeSettingsProps> = ({settingSection}) => {
switch (setting.type) {
case 'text':
return (
<TextField
title={humanizeSettingKey(setting.key)}
value={setting.value || ''}
onChange={event => setSetting(event.target.value)}
/>
);
case 'boolean':
return (
<Toggle
direction="rtl"
id={`theme-setting-${setting.key}`}
label={humanizeSettingKey(setting.key)}
onChange={event => setSetting(event.target.checked)}
/>
);
case 'select':
return (
<Select
defaultSelectedOption={setting.value}
options={setting.options.map(option => ({label: option, value: option}))}
title={humanizeSettingKey(setting.key)}
onSelect={value => setSetting(value)}
/>
);
case 'color':
return (
<TextField
title={humanizeSettingKey(setting.key)}
type='color'
value={setting.value || ''}
onChange={event => setSetting(event.target.value)}
/>
);
case 'image':
return <>
<Heading useLabelTag>{humanizeSettingKey(setting.key)}</Heading>
<ImageUpload
height={setting.value ? '100px' : '32px'}
id='cover-image'
imageURL={setting.value || ''}
onDelete={() => setSetting(null)}
onUpload={file => handleImageUpload(file)}
>Upload image</ImageUpload>
</>;
}
};
const ThemeSettings: React.FC<{ settings: CustomThemeSetting[], updateSetting: (setting: CustomThemeSetting) => void }> = ({settings, updateSetting}) => {
return (
<>
{settingSection}
</>
<SettingGroupContent className='mt-7'>
{settings.map(setting => <ThemeSetting key={setting.key} setSetting={(value: any) => updateSetting({...setting, value})} setting={setting} />)}
</SettingGroupContent>
);
};
export default ThemeSettings;
export default ThemeSettings;

View File

@ -12,11 +12,12 @@ export interface SettingGroupHook {
saveState: SaveState;
siteData: SiteData | null;
focusRef: React.RefObject<HTMLInputElement>;
handleSave: () => void;
handleSave: () => Promise<void>;
handleCancel: () => void;
updateSetting: (key: string, value: SettingValue) => void;
getSettingValues: (keys: string[]) => (SettingValue|undefined)[];
handleStateChange: (newState: TSettingGroupStates) => void;
dirty: boolean
}
type UpdateAction = {
@ -54,7 +55,7 @@ function settingsReducer(state: Setting[], action: ActionType) {
}
}
const useSettingGroup = (): SettingGroupHook => {
const useSettingGroup = ({onSave}: { onSave?: () => void | Promise<void> } = {}): SettingGroupHook => {
// create a ref to focus the input field
const focusRef = useRef<HTMLInputElement>(null);
@ -96,20 +97,29 @@ const useSettingGroup = (): SettingGroupHook => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings]);
// function to save the changed settings via API
const handleSave = async () => {
const changedSettings = localSettings?.filter(setting => setting.dirty)
const changedSettings = () => {
return localSettings?.filter(setting => setting.dirty)
?.map((setting) => {
return {
key: setting.key,
value: setting.value
};
});
if (!changedSettings?.length) {
};
// function to save the changed settings via API
const handleSave = async () => {
if (!changedSettings()?.length && !onSave) {
return;
}
setSaveState('saving');
await saveSettings?.(changedSettings);
if (changedSettings()?.length) {
await saveSettings?.(changedSettings());
}
if (onSave) {
await onSave();
}
setSaveState('saved');
setCurrentState('view');
};
@ -156,7 +166,11 @@ const useSettingGroup = (): SettingGroupHook => {
handleCancel,
updateSetting,
getSettingValues,
handleStateChange
handleStateChange,
get dirty() {
return !!changedSettings()?.length;
}
};
};

View File

@ -54,4 +54,23 @@ export type SiteData = {
url: string;
locale: string;
version: string;
};
};
type CustomThemeSettingData =
{ type: 'text', value: string | null, default: string | null } |
{ type: 'color', value: string, default: string } |
{ type: 'image', value: string | null } |
{ type: 'boolean', value: boolean, default: boolean } |
{
type: 'select',
value: string
default: string
options: string[]
};
export type CustomThemeSetting = CustomThemeSettingData & {
id: string
key: string
// homepage and post are the only two groups we handle, but technically theme authors can put other things in package.json
group?: 'homepage' | 'post' | string
}

View File

@ -1,4 +1,4 @@
import {Setting, SiteData, User, UserRole} from '../types/api';
import {CustomThemeSetting, Setting, SiteData, User, UserRole} from '../types/api';
import {getGhostPaths} from './helpers';
interface Meta {
@ -49,6 +49,10 @@ export interface InvitesResponseType {
invites: UserInvite[];
}
export interface CustomThemeSettingsResponseType {
custom_theme_settings: CustomThemeSetting[];
}
export interface SiteResponseType {
site: SiteData;
}
@ -119,6 +123,10 @@ interface API {
token?: string;
}) => Promise<InvitesResponseType>;
delete: (inviteId: string) => Promise<void>;
};
customThemeSettings: {
browse: () => Promise<CustomThemeSettingsResponseType>
edit: (newSettings: CustomThemeSetting[]) => Promise<CustomThemeSettingsResponseType>
}
}
@ -129,7 +137,7 @@ interface GhostApiOptions {
function setupGhostApi({ghostVersion}: GhostApiOptions): API {
const {apiRoot} = getGhostPaths();
function fetcher(url: string, options: RequestOptions) {
function fetcher(url: string, options: RequestOptions = {}) {
const endpoint = `${apiRoot}${url}`;
// By default, we set the Content-Type header to application/json
const defaultHeaders = {
@ -300,6 +308,23 @@ function setupGhostApi({ghostVersion}: GhostApiOptions): API {
});
return;
}
},
customThemeSettings: {
browse: async () => {
const response = await fetcher('/custom_theme_settings/');
const data: CustomThemeSettingsResponseType = await response.json();
return data;
},
edit: async (newSettings) => {
const response = await fetcher('/custom_theme_settings/', {
method: 'PUT',
body: JSON.stringify({custom_theme_settings: newSettings})
});
const data: CustomThemeSettingsResponseType = await response.json();
return data;
}
}
};

View File

@ -59,6 +59,15 @@ export function generateAvatarColor(name: string) {
return 'hsl(' + h + ', ' + s + '%, ' + l + '%)';
}
export function humanizeSettingKey(key: string) {
const allCaps = ['API', 'CTA', 'RSS'];
return key
.replace(/^[a-z]/, char => char.toUpperCase())
.replace(/_/g, ' ')
.replace(new RegExp(`\\b(${allCaps.join('|')})\\b`, 'ig'), match => match.toUpperCase());
}
export function isOwnerUser(user: User) {
return user.roles.some(role => role.name === 'Owner');
}