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; okColor?: string;
onCancel?: () => void; onCancel?: () => void;
onOk?: () => void; onOk?: () => void;
buttonsDisabled?: boolean
customButtons?: React.ReactNode; customButtons?: React.ReactNode;
customHeader?: React.ReactNode; customHeader?: React.ReactNode;
sidebarPadding?: boolean; sidebarPadding?: boolean;
} }
const PreviewModal: React.FC<PreviewModalProps> = ({ export const PreviewModalContent: React.FC<PreviewModalProps> = ({
title, title,
sidebar, sidebar,
preview, preview,
@ -28,6 +29,7 @@ const PreviewModal: React.FC<PreviewModalProps> = ({
okColor = 'black', okColor = 'black',
onCancel, onCancel,
onOk, onOk,
buttonsDisabled,
customButtons, customButtons,
customHeader, customHeader,
sidebarPadding = true sidebarPadding = true
@ -41,7 +43,8 @@ const PreviewModal: React.FC<PreviewModalProps> = ({
label: cancelLabel, label: cancelLabel,
onClick: (onCancel ? onCancel : () => { onClick: (onCancel ? onCancel : () => {
modal.remove(); modal.remove();
}) }),
disabled: buttonsDisabled
}); });
buttons.push({ buttons.push({
@ -49,7 +52,8 @@ const PreviewModal: React.FC<PreviewModalProps> = ({
label: okLabel, label: okLabel,
color: okColor, color: okColor,
className: 'min-w-[80px]', 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 React, {useId} from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
type TextFieldType = 'text' | 'number' | 'email' | 'password' | 'file' | 'date' | 'time' | 'search';
interface TextFieldProps { interface TextFieldProps {
inputRef?: React.RefObject<HTMLInputElement>; inputRef?: React.RefObject<HTMLInputElement>;
title?: string; title?: string;
hideTitle?: boolean; hideTitle?: boolean;
type?: TextFieldType; type?: React.InputHTMLAttributes<HTMLInputElement>['type'];
value?: string; value?: string;
error?: boolean; error?: boolean;
placeholder?: string; placeholder?: string;

View File

@ -1,6 +1,5 @@
import React from 'react'; import React, {createContext} from 'react';
import setupGhostApi from '../../utils/api'; import setupGhostApi from '../../utils/api';
import {createContext} from 'react';
export interface FileService { export interface FileService {
uploadImage: (file: File) => Promise<string>; uploadImage: (file: File) => Promise<string>;
@ -21,6 +20,7 @@ const ServicesContext = createContext<ServicesContextProps>({
}); });
const ServicesProvider: React.FC<ServicesProviderProps> = ({children, ghostVersion}) => { 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 apiService = setupGhostApi({ghostVersion});
const fileService = { const fileService = {
uploadImage: async (file: File): Promise<string> => { 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 BrandSettings, {BrandSettingValues} from './designAndBranding/BrandSettings';
import Button from '../../../admin-x-ds/global/Button'; import ConfirmationModal from '../../../admin-x-ds/global/ConfirmationModal';
import NiceModal from '@ebay/nice-modal-react'; import NiceModal, {useModal} from '@ebay/nice-modal-react';
import PreviewModal from '../../../admin-x-ds/global/PreviewModal'; import React, {useContext, useEffect, useState} from 'react';
import React from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import StickyFooter from '../../../admin-x-ds/global/StickyFooter'; 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 ThemePreview from './designAndBranding/ThemePreivew';
import ThemeSettings from './designAndBranding/ThemeSettings'; 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[] = [ const tabs: Tab[] = [
{ {
id: 'brand', id: 'brand',
title: 'Brand', title: 'Brand',
contents: <BrandSettings /> contents: <BrandSettings updateSetting={updateBrandSetting} values={brandSettings} />
}, },
{ ...themeSettingSections.map(({id, title, settings}) => ({
id: 'site-wide', id,
title: 'Site wide', title,
contents: <ThemeSettings settingSection='site-wide' /> contents: <ThemeSettings settings={settings} updateSetting={updateThemeSetting} />
}, }))
{
id: 'homepage',
title: 'Homepage',
contents: <ThemeSettings settingSection='homepage' />
},
{
id: 'post',
title: 'Post',
contents: <ThemeSettings settingSection='post' />
}
]; ];
return ( return (
@ -50,24 +46,93 @@ const Sidebar: React.FC = () => {
}; };
const DesignModal: React.FC = () => { const DesignModal: React.FC = () => {
const openPreviewModal = () => { const modal = useModal();
NiceModal.show(PreviewModal, {
title: 'Design', const {api} = useContext(ServicesContext);
okLabel: 'Save', const [themeSettings, setThemeSettings] = useState<Array<CustomThemeSetting & { dirty?: boolean }>>([]);
preview: <ThemePreview />,
sidebar: <Sidebar />, useEffect(() => {
sidebarPadding: false 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 ( return <PreviewModalContent
<SettingGroup buttonsDisabled={saveState === 'saving'}
customButtons={<Button color='green' label='Customize' link onClick={openPreviewModal}/>} okLabel='Save'
description="Customize your site and manage themes" preview={<ThemePreview />}
navid='branding-and-design' sidebar={<Sidebar
title="Branding and design" 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 React from 'react';
import SettingSection from '../../../admin-x-ds/settings/SettingSection'; import SettingSection from '../../../admin-x-ds/settings/SettingSection';
@ -6,10 +6,10 @@ const SiteSettings: React.FC = () => {
return ( return (
<> <>
<SettingSection title="Site"> <SettingSection title="Site">
<DesignModal /> <DesignSetting />
</SettingSection> </SettingSection>
</> </>
); );
}; };
export default SiteSettings; export default SiteSettings;

View File

@ -1,17 +1,22 @@
import Heading from '../../../../admin-x-ds/global/Heading'; import Heading from '../../../../admin-x-ds/global/Heading';
import Hint from '../../../../admin-x-ds/global/Hint'; import Hint from '../../../../admin-x-ds/global/Hint';
import ImageUpload from '../../../../admin-x-ds/global/ImageUpload'; 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 SettingGroupContent from '../../../../admin-x-ds/settings/SettingGroupContent';
import TextField from '../../../../admin-x-ds/global/TextField'; 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 = () => { export interface BrandSettingValues {
const { description: string
getSettingValues accentColor: string
} = useSettingGroup(); 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 ( return (
<div className='mt-7'> <div className='mt-7'>
@ -21,7 +26,8 @@ const BrandSettings: React.FC = () => {
clearBg={true} clearBg={true}
hint='Used in your theme, meta data and search results' hint='Used in your theme, meta data and search results'
title='Site description' title='Site description'
value={description} value={values.description}
onChange={event => updateSetting('description', event.target.value)}
/> />
<div className='flex items-center justify-between gap-3'> <div className='flex items-center justify-between gap-3'>
<Heading level={6}>Accent color</Heading> <Heading level={6}>Accent color</Heading>
@ -32,7 +38,9 @@ const BrandSettings: React.FC = () => {
className='text-right' className='text-right'
clearBg={true} clearBg={true}
maxLength={7} maxLength={7}
value={accentColor} type='color'
value={values.accentColor}
onChange={event => updateSetting('accent_color', event.target.value)}
/> />
</div> </div>
</div> </div>
@ -44,8 +52,10 @@ const BrandSettings: React.FC = () => {
height='36px' height='36px'
id='logo' id='logo'
width='150px' width='150px'
onDelete={() => {}} onDelete={() => updateSetting('icon', null)}
onUpload={() => {}} onUpload={async (file) => {
updateSetting('icon', await fileService!.uploadImage(file));
}}
> >
Upload icon Upload icon
</ImageUpload> </ImageUpload>
@ -56,8 +66,10 @@ const BrandSettings: React.FC = () => {
<ImageUpload <ImageUpload
height='80px' height='80px'
id='logo' id='logo'
onDelete={() => {}} onDelete={() => updateSetting('logo', null)}
onUpload={() => {}} onUpload={async (file) => {
updateSetting('logo', await fileService!.uploadImage(file));
}}
> >
Upload logo Upload logo
</ImageUpload> </ImageUpload>
@ -67,8 +79,10 @@ const BrandSettings: React.FC = () => {
<ImageUpload <ImageUpload
height='140px' height='140px'
id='cover' id='cover'
onDelete={() => {}} onDelete={() => updateSetting('cover_image', null)}
onUpload={() => {}} onUpload={async (file) => {
updateSetting('cover_image', await fileService!.uploadImage(file));
}}
> >
Upload cover Upload cover
</ImageUpload> </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 { const handleImageUpload = async (file: File) => {
settingSection: ThemeSettingSection; 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 ( return (
<> <SettingGroupContent className='mt-7'>
{settingSection} {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; saveState: SaveState;
siteData: SiteData | null; siteData: SiteData | null;
focusRef: React.RefObject<HTMLInputElement>; focusRef: React.RefObject<HTMLInputElement>;
handleSave: () => void; handleSave: () => Promise<void>;
handleCancel: () => void; handleCancel: () => void;
updateSetting: (key: string, value: SettingValue) => void; updateSetting: (key: string, value: SettingValue) => void;
getSettingValues: (keys: string[]) => (SettingValue|undefined)[]; getSettingValues: (keys: string[]) => (SettingValue|undefined)[];
handleStateChange: (newState: TSettingGroupStates) => void; handleStateChange: (newState: TSettingGroupStates) => void;
dirty: boolean
} }
type UpdateAction = { 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 // create a ref to focus the input field
const focusRef = useRef<HTMLInputElement>(null); const focusRef = useRef<HTMLInputElement>(null);
@ -96,20 +97,29 @@ const useSettingGroup = (): SettingGroupHook => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings]); }, [settings]);
// function to save the changed settings via API const changedSettings = () => {
const handleSave = async () => { return localSettings?.filter(setting => setting.dirty)
const changedSettings = localSettings?.filter(setting => setting.dirty)
?.map((setting) => { ?.map((setting) => {
return { return {
key: setting.key, key: setting.key,
value: setting.value value: setting.value
}; };
}); });
if (!changedSettings?.length) { };
// function to save the changed settings via API
const handleSave = async () => {
if (!changedSettings()?.length && !onSave) {
return; return;
} }
setSaveState('saving'); setSaveState('saving');
await saveSettings?.(changedSettings); if (changedSettings()?.length) {
await saveSettings?.(changedSettings());
}
if (onSave) {
await onSave();
}
setSaveState('saved'); setSaveState('saved');
setCurrentState('view'); setCurrentState('view');
}; };
@ -156,7 +166,11 @@ const useSettingGroup = (): SettingGroupHook => {
handleCancel, handleCancel,
updateSetting, updateSetting,
getSettingValues, getSettingValues,
handleStateChange handleStateChange,
get dirty() {
return !!changedSettings()?.length;
}
}; };
}; };

View File

@ -54,4 +54,23 @@ export type SiteData = {
url: string; url: string;
locale: string; locale: string;
version: 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'; import {getGhostPaths} from './helpers';
interface Meta { interface Meta {
@ -49,6 +49,10 @@ export interface InvitesResponseType {
invites: UserInvite[]; invites: UserInvite[];
} }
export interface CustomThemeSettingsResponseType {
custom_theme_settings: CustomThemeSetting[];
}
export interface SiteResponseType { export interface SiteResponseType {
site: SiteData; site: SiteData;
} }
@ -119,6 +123,10 @@ interface API {
token?: string; token?: string;
}) => Promise<InvitesResponseType>; }) => Promise<InvitesResponseType>;
delete: (inviteId: string) => Promise<void>; 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 { function setupGhostApi({ghostVersion}: GhostApiOptions): API {
const {apiRoot} = getGhostPaths(); const {apiRoot} = getGhostPaths();
function fetcher(url: string, options: RequestOptions) { function fetcher(url: string, options: RequestOptions = {}) {
const endpoint = `${apiRoot}${url}`; const endpoint = `${apiRoot}${url}`;
// By default, we set the Content-Type header to application/json // By default, we set the Content-Type header to application/json
const defaultHeaders = { const defaultHeaders = {
@ -300,6 +308,23 @@ function setupGhostApi({ghostVersion}: GhostApiOptions): API {
}); });
return; 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 + '%)'; 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) { export function isOwnerUser(user: User) {
return user.roles.some(role => role.name === 'Owner'); return user.roles.some(role => role.name === 'Owner');
} }