diff --git a/ghost/admin-x-settings/src/admin-x-ds/global/PreviewModal.tsx b/ghost/admin-x-settings/src/admin-x-ds/global/PreviewModal.tsx index 266b10d7c9..e6f336b1ca 100644 --- a/ghost/admin-x-settings/src/admin-x-ds/global/PreviewModal.tsx +++ b/ghost/admin-x-settings/src/admin-x-ds/global/PreviewModal.tsx @@ -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 = ({ +export const PreviewModalContent: React.FC = ({ title, sidebar, preview, @@ -28,6 +29,7 @@ const PreviewModal: React.FC = ({ okColor = 'black', onCancel, onOk, + buttonsDisabled, customButtons, customHeader, sidebarPadding = true @@ -41,7 +43,8 @@ const PreviewModal: React.FC = ({ label: cancelLabel, onClick: (onCancel ? onCancel : () => { modal.remove(); - }) + }), + disabled: buttonsDisabled }); buttons.push({ @@ -49,7 +52,8 @@ const PreviewModal: React.FC = ({ label: okLabel, color: okColor, className: 'min-w-[80px]', - onClick: onOk + onClick: onOk, + disabled: buttonsDisabled }); } @@ -82,4 +86,4 @@ const PreviewModal: React.FC = ({ ); }; -export default NiceModal.create(PreviewModal); \ No newline at end of file +export default NiceModal.create(PreviewModalContent); diff --git a/ghost/admin-x-settings/src/admin-x-ds/global/TextField.tsx b/ghost/admin-x-settings/src/admin-x-ds/global/TextField.tsx index b6809f9717..0f2c3884b9 100644 --- a/ghost/admin-x-settings/src/admin-x-ds/global/TextField.tsx +++ b/ghost/admin-x-settings/src/admin-x-ds/global/TextField.tsx @@ -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; title?: string; hideTitle?: boolean; - type?: TextFieldType; + type?: React.InputHTMLAttributes['type']; value?: string; error?: boolean; placeholder?: string; diff --git a/ghost/admin-x-settings/src/components/providers/ServiceProvider.tsx b/ghost/admin-x-settings/src/components/providers/ServiceProvider.tsx index f53a8d9815..954ee1d2f2 100644 --- a/ghost/admin-x-settings/src/components/providers/ServiceProvider.tsx +++ b/ghost/admin-x-settings/src/components/providers/ServiceProvider.tsx @@ -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; @@ -21,6 +20,7 @@ const ServicesContext = createContext({ }); const ServicesProvider: React.FC = ({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 => { @@ -38,4 +38,4 @@ const ServicesProvider: React.FC = ({children, ghostVersi ); }; -export {ServicesContext, ServicesProvider}; \ No newline at end of file +export {ServicesContext, ServicesProvider}; diff --git a/ghost/admin-x-settings/src/components/settings/site/DesignModal.tsx b/ghost/admin-x-settings/src/components/settings/site/DesignModal.tsx index b0818dcdd5..e1ca042afc 100644 --- a/ghost/admin-x-settings/src/components/settings/site/DesignModal.tsx +++ b/ghost/admin-x-settings/src/components/settings/site/DesignModal.tsx @@ -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: + contents: }, - { - id: 'site-wide', - title: 'Site wide', - contents: - }, - { - id: 'homepage', - title: 'Homepage', - contents: - }, - { - id: 'post', - title: 'Post', - contents: - } + ...themeSettingSections.map(({id, title, settings}) => ({ + id, + title, + contents: + })) ]; return ( @@ -50,24 +46,93 @@ const Sidebar: React.FC = () => { }; const DesignModal: React.FC = () => { - const openPreviewModal = () => { - NiceModal.show(PreviewModal, { - title: 'Design', - okLabel: 'Save', - preview: , - sidebar: , - sidebarPadding: false + const modal = useModal(); + + const {api} = useContext(ServicesContext); + const [themeSettings, setThemeSettings] = useState>([]); + + 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 ( - } - description="Customize your site and manage themes" - navid='branding-and-design' - title="Branding and design" - /> - ); + return } + sidebar={} + 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: ( + <> +

Hey there! It looks like you didn‘t save the changes you made.

+

Save before you go!

+ + ), + okLabel: 'Leave', + okColor: 'red', + onOk: (confirmModal) => { + confirmModal?.remove(); + modal.remove(); + }, + cancelLabel: 'Stay' + }); + } else { + modal.remove(); + } + }} + onOk={async () => { + await handleSave(); + modal.remove(); + }} + />; }; -export default DesignModal; \ No newline at end of file +export default NiceModal.create(DesignModal); diff --git a/ghost/admin-x-settings/src/components/settings/site/DesignSetting.tsx b/ghost/admin-x-settings/src/components/settings/site/DesignSetting.tsx new file mode 100644 index 0000000000..4e13bbddb6 --- /dev/null +++ b/ghost/admin-x-settings/src/components/settings/site/DesignSetting.tsx @@ -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 ( + } + description="Customize your site and manage themes" + navid='branding-and-design' + title="Branding and design" + /> + ); +}; + +export default DesignSetting; diff --git a/ghost/admin-x-settings/src/components/settings/site/SiteSettings.tsx b/ghost/admin-x-settings/src/components/settings/site/SiteSettings.tsx index 750cae2f8b..f45146b3d4 100644 --- a/ghost/admin-x-settings/src/components/settings/site/SiteSettings.tsx +++ b/ghost/admin-x-settings/src/components/settings/site/SiteSettings.tsx @@ -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 ( <> - + ); }; -export default SiteSettings; \ No newline at end of file +export default SiteSettings; diff --git a/ghost/admin-x-settings/src/components/settings/site/designAndBranding/BrandSettings.tsx b/ghost/admin-x-settings/src/components/settings/site/designAndBranding/BrandSettings.tsx index a098bb74aa..2ff4ee4b57 100644 --- a/ghost/admin-x-settings/src/components/settings/site/designAndBranding/BrandSettings.tsx +++ b/ghost/admin-x-settings/src/components/settings/site/designAndBranding/BrandSettings.tsx @@ -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 (
@@ -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)} />
Accent color @@ -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)} />
@@ -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 @@ -56,8 +66,10 @@ const BrandSettings: React.FC = () => { @@ -67,8 +79,10 @@ const BrandSettings: React.FC = () => { {}} - onUpload={() => {}} + onDelete={() => updateSetting('cover_image', null)} + onUpload={async (file) => { + updateSetting('cover_image', await fileService!.uploadImage(file)); + }} > Upload cover @@ -78,4 +92,4 @@ const BrandSettings: React.FC = () => { ); }; -export default BrandSettings; \ No newline at end of file +export default BrandSettings; diff --git a/ghost/admin-x-settings/src/components/settings/site/designAndBranding/ThemeSettings.tsx b/ghost/admin-x-settings/src/components/settings/site/designAndBranding/ThemeSettings.tsx index 6447c38ab7..60cc3cc063 100644 --- a/ghost/admin-x-settings/src/components/settings/site/designAndBranding/ThemeSettings.tsx +++ b/ghost/admin-x-settings/src/components/settings/site/designAndBranding/ThemeSettings.tsx @@ -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: (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 = ({settingSection}) => { + switch (setting.type) { + case 'text': + return ( + setSetting(event.target.value)} + /> + ); + case 'boolean': + return ( + setSetting(event.target.checked)} + /> + ); + case 'select': + return ( +