mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-27 18:52:14 +03:00
Updated design settings to be editable and show theme settings
refs https://github.com/TryGhost/Team/issues/3354
This commit is contained in:
parent
567422174d
commit
446841ce9c
@ -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);
|
||||||
|
@ -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;
|
||||||
|
@ -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};
|
||||||
|
@ -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‘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);
|
||||||
|
@ -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;
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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');
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user