mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-03-11 02:12:21 -05:00
Improved AdminX design settings experience (#18180)
refs https://github.com/TryGhost/Product/issues/3832 --- ### <samp>🤖 Generated by Copilot at 60bbe0a</samp> This pull request improves the performance and readability of the site design and theme settings UI. It refactors the modal navigation logic, the color picker field component, and the theme preview component. It also removes unnecessary dependencies and props from the modal components, and adds a new `DesignAndThemeModal` component to handle the modal switching.
This commit is contained in:
parent
a27ba55b50
commit
79cd49c01b
10 changed files with 163 additions and 129 deletions
|
@ -1,14 +1,15 @@
|
|||
import ColorIndicator, {SwatchOption} from './ColorIndicator';
|
||||
import ColorPicker from './ColorPicker';
|
||||
import clsx from 'clsx';
|
||||
import {ReactNode, createContext, useContext, useEffect, useId, useState} from 'react';
|
||||
import {ReactNode, createContext, useContext, useEffect, useId, useMemo, useState} from 'react';
|
||||
import {ToggleDirections} from './Toggle';
|
||||
import {debounce} from '../../../utils/debounce';
|
||||
|
||||
const ColorPickerContext = createContext<{colorPickers: Array<{ id: string; setExpanded: ((expanded: boolean) => void) }>}>({
|
||||
colorPickers: []
|
||||
});
|
||||
|
||||
const ColorPickerField = ({testId, title, direction, value, hint, error, eyedropper, clearButtonValue, onChange, swatches = []}: {
|
||||
const ColorPickerField = ({testId, title, direction, value, hint, error, eyedropper, clearButtonValue, onChange, swatches = [], alwaysOpen = false, debounceMs}: {
|
||||
testId?: string;
|
||||
title?: ReactNode;
|
||||
direction?: ToggleDirections;
|
||||
|
@ -19,11 +20,18 @@ const ColorPickerField = ({testId, title, direction, value, hint, error, eyedrop
|
|||
clearButtonValue?: string | null;
|
||||
onChange?: (newValue: string | null) => void;
|
||||
swatches?: SwatchOption[];
|
||||
alwaysOpen?: boolean;
|
||||
debounceMs?: number;
|
||||
}) => {
|
||||
const [isExpanded, setExpanded] = useState(false);
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const context = useContext(ColorPickerContext);
|
||||
const id = useId();
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
context.colorPickers.push({id, setExpanded});
|
||||
|
||||
|
@ -48,16 +56,29 @@ const ColorPickerField = ({testId, title, direction, value, hint, error, eyedrop
|
|||
}
|
||||
}, [context, id, isExpanded]);
|
||||
|
||||
const debouncedOnChange = useMemo(() => {
|
||||
if (onChange && debounceMs) {
|
||||
return debounce(onChange, debounceMs);
|
||||
} else {
|
||||
return onChange;
|
||||
}
|
||||
}, [debounceMs, onChange]);
|
||||
|
||||
const handleChange = (newValue: string | null) => {
|
||||
setLocalValue(newValue);
|
||||
debouncedOnChange?.(newValue);
|
||||
};
|
||||
|
||||
let content = (
|
||||
<ColorIndicator
|
||||
isExpanded={isExpanded}
|
||||
swatches={swatches}
|
||||
value={value}
|
||||
value={localValue}
|
||||
onSwatchChange={(newValue) => {
|
||||
onChange?.(newValue);
|
||||
handleChange(newValue);
|
||||
setExpanded(false);
|
||||
}}
|
||||
onTogglePicker={() => setExpanded(!isExpanded)}
|
||||
onTogglePicker={() => !alwaysOpen && setExpanded(!isExpanded)}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -67,7 +88,7 @@ const ColorPickerField = ({testId, title, direction, value, hint, error, eyedrop
|
|||
<div className="shrink-0">
|
||||
{content}
|
||||
</div>
|
||||
<div className={clsx('flex-1', direction === 'rtl' ? 'pr-2' : 'pl-2', hint ? 'mt-[-2px]' : 'mt-[1px]')} onClick={() => setExpanded(!isExpanded)}>
|
||||
<div className={clsx('flex-1', direction === 'rtl' ? 'pr-2' : 'pl-2', hint ? 'mt-[-2px]' : 'mt-[1px]')} onClick={() => !alwaysOpen && setExpanded(!isExpanded)}>
|
||||
{title}
|
||||
{hint && <div className={`text-xs ${error ? 'text-red' : 'text-grey-700'}`}>{hint}</div>}
|
||||
</div>
|
||||
|
@ -75,12 +96,12 @@ const ColorPickerField = ({testId, title, direction, value, hint, error, eyedrop
|
|||
);
|
||||
}
|
||||
|
||||
let selectedSwatch = swatches.find(swatch => swatch.value === value);
|
||||
let selectedSwatch = swatches.find(swatch => swatch.value === localValue);
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex-col" data-testid={testId} onClick={event => event.stopPropagation()}>
|
||||
{content}
|
||||
{isExpanded && <ColorPicker clearButtonValue={clearButtonValue} eyedropper={eyedropper} hexValue={selectedSwatch?.hex || value || undefined} onChange={onChange} />}
|
||||
{(alwaysOpen || isExpanded) && <ColorPicker clearButtonValue={clearButtonValue} eyedropper={eyedropper} hexValue={selectedSwatch?.hex || localValue || undefined} onChange={handleChange} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -28,59 +28,61 @@ export const RouteContext = createContext<RoutingContextData>({
|
|||
});
|
||||
|
||||
export type RoutingModalProps = {
|
||||
pathName: string;
|
||||
params?: Record<string, string>
|
||||
}
|
||||
|
||||
const AddIntegrationModal = () => import('../settings/advanced/integrations/AddIntegrationModal');
|
||||
const AddNewsletterModal = () => import('../settings/email/newsletters/AddNewsletterModal');
|
||||
const AddRecommendationModal = () => import('../settings/site/recommendations/AddRecommendationModal');
|
||||
const AmpModal = () => import('../settings/advanced/integrations/AmpModal');
|
||||
const ChangeThemeModal = () => import('../settings/site/ThemeModal');
|
||||
const CustomIntegrationModal = () => import('../settings/advanced/integrations/CustomIntegrationModal');
|
||||
const DesignModal = () => import('../settings/site/DesignModal');
|
||||
const EditRecommendationModal = () => import('../settings/site/recommendations/EditRecommendationModal');
|
||||
const FirstpromoterModal = () => import('../settings/advanced/integrations/FirstPromoterModal');
|
||||
const HistoryModal = () => import('../settings/advanced/HistoryModal');
|
||||
const InviteUserModal = () => import('../settings/general/InviteUserModal');
|
||||
const NavigationModal = () => import('../settings/site/NavigationModal');
|
||||
const NewsletterDetailModal = () => import('../settings/email/newsletters/NewsletterDetailModal');
|
||||
const PinturaModal = () => import('../settings/advanced/integrations/PinturaModal');
|
||||
const PortalModal = () => import('../settings/membership/portal/PortalModal');
|
||||
const SlackModal = () => import('../settings/advanced/integrations/SlackModal');
|
||||
const StripeConnectModal = () => import('../settings/membership/stripe/StripeConnectModal');
|
||||
const TierDetailModal = () => import('../settings/membership/tiers/TierDetailModal');
|
||||
const UnsplashModal = () => import('../settings/advanced/integrations/UnsplashModal');
|
||||
const UserDetailModal = () => import('../settings/general/UserDetailModal');
|
||||
const ZapierModal = () => import('../settings/advanced/integrations/ZapierModal');
|
||||
const AnnouncementBarModal = () => import('../settings/site/AnnouncementBarModal');
|
||||
const EmbedSignupFormModal = () => import('../settings/membership/embedSignup/EmbedSignupFormModal');
|
||||
const modals: {[key: string]: () => Promise<{default: React.FC<NiceModalHocProps & RoutingModalProps>}>} = {
|
||||
AddIntegrationModal: () => import('../settings/advanced/integrations/AddIntegrationModal'),
|
||||
AddNewsletterModal: () => import('../settings/email/newsletters/AddNewsletterModal'),
|
||||
AddRecommendationModal: () => import('../settings/site/recommendations/AddRecommendationModal'),
|
||||
AmpModal: () => import('../settings/advanced/integrations/AmpModal'),
|
||||
CustomIntegrationModal: () => import('../settings/advanced/integrations/CustomIntegrationModal'),
|
||||
DesignAndThemeModal: () => import('../settings/site/DesignAndThemeModal'),
|
||||
EditRecommendationModal: () => import('../settings/site/recommendations/EditRecommendationModal'),
|
||||
FirstpromoterModal: () => import('../settings/advanced/integrations/FirstPromoterModal'),
|
||||
HistoryModal: () => import('../settings/advanced/HistoryModal'),
|
||||
InviteUserModal: () => import('../settings/general/InviteUserModal'),
|
||||
NavigationModal: () => import('../settings/site/NavigationModal'),
|
||||
NewsletterDetailModal: () => import('../settings/email/newsletters/NewsletterDetailModal'),
|
||||
PinturaModal: () => import('../settings/advanced/integrations/PinturaModal'),
|
||||
PortalModal: () => import('../settings/membership/portal/PortalModal'),
|
||||
SlackModal: () => import('../settings/advanced/integrations/SlackModal'),
|
||||
StripeConnectModal: () => import('../settings/membership/stripe/StripeConnectModal'),
|
||||
TierDetailModal: () => import('../settings/membership/tiers/TierDetailModal'),
|
||||
UnsplashModal: () => import('../settings/advanced/integrations/UnsplashModal'),
|
||||
UserDetailModal: () => import('../settings/general/UserDetailModal'),
|
||||
ZapierModal: () => import('../settings/advanced/integrations/ZapierModal'),
|
||||
AnnouncementBarModal: () => import('../settings/site/AnnouncementBarModal'),
|
||||
EmbedSignupFormModal: () => import('../settings/membership/embedSignup/EmbedSignupFormModal')
|
||||
};
|
||||
|
||||
const modalPaths: {[key: string]: () => Promise<{default: React.FC<NiceModalHocProps & RoutingModalProps>}>} = {
|
||||
'design/edit/themes': ChangeThemeModal,
|
||||
'design/edit': DesignModal,
|
||||
'navigation/edit': NavigationModal,
|
||||
'users/invite': InviteUserModal,
|
||||
'users/show/:slug': UserDetailModal,
|
||||
'portal/edit': PortalModal,
|
||||
'tiers/add': TierDetailModal,
|
||||
'tiers/show/:id': TierDetailModal,
|
||||
'stripe-connect': StripeConnectModal,
|
||||
'newsletters/add': AddNewsletterModal,
|
||||
'newsletters/show/:id': NewsletterDetailModal,
|
||||
'history/view': HistoryModal,
|
||||
'history/view/:user': HistoryModal,
|
||||
'integrations/zapier': ZapierModal,
|
||||
'integrations/slack': SlackModal,
|
||||
'integrations/amp': AmpModal,
|
||||
'integrations/unsplash': UnsplashModal,
|
||||
'integrations/firstpromoter': FirstpromoterModal,
|
||||
'integrations/pintura': PinturaModal,
|
||||
'integrations/add': AddIntegrationModal,
|
||||
'integrations/show/:id': CustomIntegrationModal,
|
||||
'recommendations/add': AddRecommendationModal,
|
||||
'recommendations/:id': EditRecommendationModal,
|
||||
'announcement-bar/edit': AnnouncementBarModal,
|
||||
'embed-signup-form/show': EmbedSignupFormModal
|
||||
const modalPaths: {[key: string]: keyof typeof modals} = {
|
||||
'design/edit/themes': 'DesignAndThemeModal',
|
||||
'design/edit': 'DesignAndThemeModal',
|
||||
'navigation/edit': 'NavigationModal',
|
||||
'users/invite': 'InviteUserModal',
|
||||
'users/show/:slug': 'UserDetailModal',
|
||||
'portal/edit': 'PortalModal',
|
||||
'tiers/add': 'TierDetailModal',
|
||||
'tiers/show/:id': 'TierDetailModal',
|
||||
'stripe-connect': 'StripeConnectModal',
|
||||
'newsletters/add': 'AddNewsletterModal',
|
||||
'newsletters/show/:id': 'NewsletterDetailModal',
|
||||
'history/view': 'HistoryModal',
|
||||
'history/view/:user': 'HistoryModal',
|
||||
'integrations/zapier': 'ZapierModal',
|
||||
'integrations/slack': 'SlackModal',
|
||||
'integrations/amp': 'AmpModal',
|
||||
'integrations/unsplash': 'UnsplashModal',
|
||||
'integrations/firstpromoter': 'FirstpromoterModal',
|
||||
'integrations/pintura': 'PinturaModal',
|
||||
'integrations/add': 'AddIntegrationModal',
|
||||
'integrations/show/:id': 'CustomIntegrationModal',
|
||||
'recommendations/add': 'AddRecommendationModal',
|
||||
'recommendations/:id': 'EditRecommendationModal',
|
||||
'announcement-bar/edit': 'AnnouncementBarModal',
|
||||
'embed-signup-form/show': 'EmbedSignupFormModal'
|
||||
};
|
||||
|
||||
function getHashPath(urlPath: string | undefined) {
|
||||
|
@ -97,7 +99,7 @@ function getHashPath(urlPath: string | undefined) {
|
|||
return null;
|
||||
}
|
||||
|
||||
const handleNavigation = () => {
|
||||
const handleNavigation = (currentRoute: string | undefined) => {
|
||||
// Get the hash from the URL
|
||||
let hash = window.location.hash;
|
||||
hash = hash.substring(1);
|
||||
|
@ -109,13 +111,15 @@ const handleNavigation = () => {
|
|||
const pathName = getHashPath(url.pathname);
|
||||
|
||||
if (pathName) {
|
||||
const [path, modal] = Object.entries(modalPaths).find(([modalPath]) => matchRoute(pathName, modalPath)) || [];
|
||||
const [, currentModalName] = Object.entries(modalPaths).find(([modalPath]) => matchRoute(currentRoute || '', modalPath)) || [];
|
||||
const [path, modalName] = Object.entries(modalPaths).find(([modalPath]) => matchRoute(pathName, modalPath)) || [];
|
||||
|
||||
return {
|
||||
pathName,
|
||||
modal: (path && modal) ?
|
||||
modal().then(({default: component}) => {
|
||||
NiceModal.show(component, {params: matchRoute(pathName, path)});
|
||||
changingModal: modalName && modalName !== currentModalName,
|
||||
modal: (path && modalName) ?
|
||||
modals[modalName]().then(({default: component}) => {
|
||||
NiceModal.show(component, {pathName, params: matchRoute(pathName, path)});
|
||||
}) :
|
||||
undefined
|
||||
};
|
||||
|
@ -143,7 +147,7 @@ const RoutingProvider: React.FC<RouteProviderProps> = ({externalNavigate, childr
|
|||
useEffect(() => {
|
||||
// Preload all the modals after initial render to avoid a delay when opening them
|
||||
setTimeout(() => {
|
||||
Object.values(modalPaths).forEach(modal => modal());
|
||||
Object.values(modalPaths).forEach(modal => modals[modal]());
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
|
@ -166,13 +170,16 @@ const RoutingProvider: React.FC<RouteProviderProps> = ({externalNavigate, childr
|
|||
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
const {pathName, modal} = handleNavigation();
|
||||
setRoute(pathName);
|
||||
setRoute((currentRoute) => {
|
||||
const {pathName, modal, changingModal} = handleNavigation(currentRoute);
|
||||
|
||||
if (modal) {
|
||||
setLoadingModal(true);
|
||||
modal.then(() => setLoadingModal(false));
|
||||
}
|
||||
if (modal && changingModal) {
|
||||
setLoadingModal(true);
|
||||
modal.then(() => setLoadingModal(false));
|
||||
}
|
||||
|
||||
return pathName;
|
||||
});
|
||||
};
|
||||
|
||||
handleHashChange();
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import ChangeThemeModal from './ThemeModal';
|
||||
import DesignModal from './DesignModal';
|
||||
import NiceModal, {useModal} from '@ebay/nice-modal-react';
|
||||
import {RoutingModalProps} from '../../providers/RoutingProvider';
|
||||
|
||||
const DesignAndThemeModal: React.FC<RoutingModalProps> = ({pathName}) => {
|
||||
const modal = useModal();
|
||||
|
||||
if (pathName === 'design/edit') {
|
||||
return <DesignModal />;
|
||||
} else if (pathName === 'design/edit/themes') {
|
||||
return <ChangeThemeModal />;
|
||||
} else {
|
||||
modal.remove();
|
||||
}
|
||||
};
|
||||
|
||||
export default NiceModal.create(DesignAndThemeModal);
|
|
@ -2,7 +2,6 @@ import BrandSettings, {BrandSettingValues} from './designAndBranding/BrandSettin
|
|||
// import Button from '../../../admin-x-ds/global/Button';
|
||||
// import ChangeThemeModal from './ThemeModal';
|
||||
import Icon from '../../../admin-x-ds/global/Icon';
|
||||
import NiceModal, {NiceModalHandler, useModal} from '@ebay/nice-modal-react';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import StickyFooter from '../../../admin-x-ds/global/StickyFooter';
|
||||
import TabView, {Tab} from '../../../admin-x-ds/global/TabView';
|
||||
|
@ -21,7 +20,6 @@ import {useGlobalData} from '../../providers/GlobalDataProvider';
|
|||
const Sidebar: React.FC<{
|
||||
brandSettings: BrandSettingValues
|
||||
themeSettingSections: Array<{id: string, title: string, settings: CustomThemeSetting[]}>
|
||||
modal: NiceModalHandler<Record<string, unknown>>;
|
||||
updateBrandSetting: (key: string, value: SettingValue) => void
|
||||
updateThemeSetting: (updated: CustomThemeSetting) => void
|
||||
onTabChange: (id: string) => void
|
||||
|
@ -29,7 +27,6 @@ const Sidebar: React.FC<{
|
|||
}> = ({
|
||||
brandSettings,
|
||||
themeSettingSections,
|
||||
modal,
|
||||
updateBrandSetting,
|
||||
updateThemeSetting,
|
||||
onTabChange,
|
||||
|
@ -68,7 +65,6 @@ const Sidebar: React.FC<{
|
|||
<div className='w-full px-7'>
|
||||
<button className='group flex w-full items-center justify-between text-sm font-medium opacity-80 transition-all hover:opacity-100' data-testid='change-theme' type='button' onClick={async () => {
|
||||
await handleSave();
|
||||
modal.remove();
|
||||
updateRoute('design/edit/themes');
|
||||
}}>
|
||||
<div className='text-left'>
|
||||
|
@ -84,8 +80,6 @@ const Sidebar: React.FC<{
|
|||
};
|
||||
|
||||
const DesignModal: React.FC = () => {
|
||||
const modal = useModal();
|
||||
|
||||
const {settings, siteData} = useGlobalData();
|
||||
const {mutateAsync: editSettings} = useEditSettings();
|
||||
const {data: {posts: [latestPost]} = {posts: []}} = useBrowsePosts({
|
||||
|
@ -110,10 +104,10 @@ const DesignModal: React.FC = () => {
|
|||
} = useForm({
|
||||
initialState: {
|
||||
settings: settings as Array<Setting & { dirty?: boolean }>,
|
||||
themeSettings: (themeSettings?.custom_theme_settings || []) as Array<CustomThemeSetting & { dirty?: boolean }>
|
||||
themeSettings: themeSettings ? (themeSettings.custom_theme_settings as Array<CustomThemeSetting & { dirty?: boolean }>) : undefined
|
||||
},
|
||||
onSave: async () => {
|
||||
if (formState.themeSettings.some(setting => setting.dirty)) {
|
||||
if (formState.themeSettings?.some(setting => setting.dirty)) {
|
||||
const response = await editThemeSettings(formState.themeSettings);
|
||||
updateForm(state => ({...state, themeSettings: response.custom_theme_settings}));
|
||||
}
|
||||
|
@ -138,14 +132,14 @@ const DesignModal: React.FC = () => {
|
|||
};
|
||||
|
||||
const updateThemeSetting = (updated: CustomThemeSetting) => {
|
||||
updateForm(state => ({...state, themeSettings: state.themeSettings.map(setting => (
|
||||
updateForm(state => ({...state, themeSettings: state.themeSettings?.map(setting => (
|
||||
setting.key === updated.key ? {...updated, dirty: true} : setting
|
||||
))}));
|
||||
};
|
||||
|
||||
const [description, accentColor, icon, logo, coverImage] = getSettingValues(formState.settings, ['description', 'accent_color', 'icon', 'logo', 'cover_image']) as string[];
|
||||
|
||||
const themeSettingGroups = formState.themeSettings.reduce((groups, setting) => {
|
||||
const themeSettingGroups = (formState.themeSettings || []).reduce((groups, setting) => {
|
||||
const group = (setting.group === 'homepage' || setting.group === 'post') ? setting.group : 'site-wide';
|
||||
|
||||
return {
|
||||
|
@ -208,7 +202,6 @@ const DesignModal: React.FC = () => {
|
|||
<Sidebar
|
||||
brandSettings={{description, accentColor, icon, logo, coverImage}}
|
||||
handleSave={handleSave}
|
||||
modal={modal}
|
||||
themeSettingSections={themeSettingSections}
|
||||
updateBrandSetting={updateBrandSetting}
|
||||
updateThemeSetting={updateThemeSetting}
|
||||
|
@ -240,4 +233,4 @@ const DesignModal: React.FC = () => {
|
|||
/>;
|
||||
};
|
||||
|
||||
export default NiceModal.create(DesignModal);
|
||||
export default DesignModal;
|
||||
|
|
|
@ -37,7 +37,6 @@ interface ThemeModalContentProps {
|
|||
const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
|
||||
currentTab,
|
||||
setCurrentTab,
|
||||
modal,
|
||||
themes
|
||||
}) => {
|
||||
const {updateRoute} = useRouting();
|
||||
|
@ -63,7 +62,6 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
|
|||
|
||||
const onClose = () => {
|
||||
updateRoute('design/edit');
|
||||
modal.remove();
|
||||
};
|
||||
|
||||
const handleThemeUpload = async ({
|
||||
|
@ -216,7 +214,7 @@ const ThemeModalContent: React.FC<ThemeModalContentProps> = ({
|
|||
return null;
|
||||
};
|
||||
|
||||
const ChangeThemeModal = NiceModal.create(() => {
|
||||
const ChangeThemeModal = () => {
|
||||
const [currentTab, setCurrentTab] = useState('official');
|
||||
const [selectedTheme, setSelectedTheme] = useState<OfficialTheme|null>(null);
|
||||
const [previewMode, setPreviewMode] = useState('desktop');
|
||||
|
@ -291,7 +289,6 @@ const ChangeThemeModal = NiceModal.create(() => {
|
|||
installedTheme: installedTheme!,
|
||||
onActivate: () => {
|
||||
updateRoute('design/edit');
|
||||
modal.remove();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@ -323,7 +320,6 @@ const ChangeThemeModal = NiceModal.create(() => {
|
|||
}}
|
||||
onClose={() => {
|
||||
updateRoute('design/edit');
|
||||
modal.remove();
|
||||
}}
|
||||
onInstall={onInstall} />
|
||||
}
|
||||
|
@ -348,6 +344,6 @@ const ChangeThemeModal = NiceModal.create(() => {
|
|||
</div>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default ChangeThemeModal;
|
||||
|
|
|
@ -6,10 +6,9 @@ import React, {useRef, useState} from 'react';
|
|||
import SettingGroupContent from '../../../../admin-x-ds/settings/SettingGroupContent';
|
||||
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
||||
import usePinturaEditor from '../../../../hooks/usePinturaEditor';
|
||||
import {SettingValue} from '../../../../api/settings';
|
||||
import {SettingValue, getSettingValues} from '../../../../api/settings';
|
||||
import {debounce} from '../../../../utils/debounce';
|
||||
import {getImageUrl, useUploadImage} from '../../../../api/images';
|
||||
import {getSettingValues} from '../../../../api/settings';
|
||||
import {useGlobalData} from '../../../providers/GlobalDataProvider';
|
||||
|
||||
export interface BrandSettingValues {
|
||||
|
@ -33,7 +32,6 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
|
|||
updateSetting('description', value);
|
||||
}, 500)
|
||||
);
|
||||
const updateSettingDebounced = debounce(updateSetting, 500);
|
||||
|
||||
const pinturaEnabled = Boolean(pintura) && Boolean(pinturaJsUrl) && Boolean(pinturaCssUrl);
|
||||
|
||||
|
@ -64,11 +62,13 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
|
|||
}}
|
||||
/>
|
||||
<ColorPickerField
|
||||
debounceMs={200}
|
||||
direction='rtl'
|
||||
title={<Heading className='mt-[3px]' grey={true} level={6}>Accent color</Heading>}
|
||||
value={values.accentColor}
|
||||
alwaysOpen
|
||||
// we debounce this because the color picker fires a lot of events.
|
||||
onChange={value => updateSettingDebounced('accent_color', value)}
|
||||
onChange={value => updateSetting('accent_color', value)}
|
||||
/>
|
||||
<div className={`flex justify-between ${values.icon ? 'items-start ' : 'items-end'}`}>
|
||||
<div>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import React, {useEffect, useRef} from 'react';
|
||||
import IframeBuffering from '../../../../utils/IframeBuffering';
|
||||
import React, {useCallback} from 'react';
|
||||
import {CustomThemeSetting} from '../../../../api/customThemeSettings';
|
||||
|
||||
type BrandSettings = {
|
||||
|
@ -7,7 +8,7 @@ type BrandSettings = {
|
|||
icon: string;
|
||||
logo: string;
|
||||
coverImage: string;
|
||||
themeSettings: Array<CustomThemeSetting & { dirty?: boolean }>;
|
||||
themeSettings?: Array<CustomThemeSetting & { dirty?: boolean }>;
|
||||
}
|
||||
|
||||
interface ThemePreviewProps {
|
||||
|
@ -28,8 +29,13 @@ function getPreviewData({
|
|||
icon: string;
|
||||
logo: string;
|
||||
coverImage: string;
|
||||
themeSettings: Array<CustomThemeSetting & { dirty?: boolean }>,
|
||||
}): string {
|
||||
themeSettings?: Array<CustomThemeSetting & { dirty?: boolean }>,
|
||||
}) {
|
||||
// Don't render twice while theme settings are loading
|
||||
if (!themeSettings) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('c', accentColor);
|
||||
params.append('d', description);
|
||||
|
@ -48,10 +54,10 @@ function getPreviewData({
|
|||
}
|
||||
|
||||
const ThemePreview: React.FC<ThemePreviewProps> = ({settings,url}) => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const previewData = getPreviewData({...settings});
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) {
|
||||
const injectContentIntoIframe = useCallback((iframe: HTMLIFrameElement) => {
|
||||
if (!url || !previewData) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -60,9 +66,7 @@ const ThemePreview: React.FC<ThemePreviewProps> = ({settings,url}) => {
|
|||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/html;charset=utf-8',
|
||||
'x-ghost-preview': getPreviewData({
|
||||
...settings
|
||||
}),
|
||||
'x-ghost-preview': previewData,
|
||||
Accept: 'text/plain',
|
||||
mode: 'cors',
|
||||
credentials: 'include'
|
||||
|
@ -86,27 +90,22 @@ const ThemePreview: React.FC<ThemePreviewProps> = ({settings,url}) => {
|
|||
|
||||
// Send the data to the iframe's window using postMessage
|
||||
// Inject the received content into the iframe
|
||||
const iframe = iframeRef.current;
|
||||
if (iframe) {
|
||||
iframe.contentDocument?.open();
|
||||
iframe.contentDocument?.write(finalDoc);
|
||||
iframe.contentDocument?.close();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// handle error in fetching data
|
||||
iframe.contentDocument?.open();
|
||||
iframe.contentDocument?.write(finalDoc);
|
||||
iframe.contentDocument?.close();
|
||||
});
|
||||
}, [url, settings]);
|
||||
}, [previewData, url]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className='h-[110%] w-[110%] origin-top-left scale-[.90909] max-[1600px]:h-[130%] max-[1600px]:w-[130%] max-[1600px]:scale-[.76923]'
|
||||
data-testid="theme-preview"
|
||||
height="100%"
|
||||
title="Site Preview"
|
||||
></iframe>
|
||||
</>
|
||||
<IframeBuffering
|
||||
addDelay={false}
|
||||
className="absolute h-[110%] w-[110%] origin-top-left scale-[.90909] max-[1600px]:h-[130%] max-[1600px]:w-[130%] max-[1600px]:scale-[.76923]"
|
||||
generateContent={injectContentIntoIframe}
|
||||
height='100%'
|
||||
parentClassName="relative h-full w-full"
|
||||
testId="theme-preview"
|
||||
width='100%'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -8,7 +8,6 @@ import SettingGroupContent from '../../../../admin-x-ds/settings/SettingGroupCon
|
|||
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
||||
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
|
||||
import {CustomThemeSetting} from '../../../../api/customThemeSettings';
|
||||
import {debounce} from '../../../../utils/debounce';
|
||||
import {getImageUrl, useUploadImage} from '../../../../api/images';
|
||||
import {humanizeSettingKey} from '../../../../api/settings';
|
||||
|
||||
|
@ -23,8 +22,6 @@ const ThemeSetting: React.FC<{
|
|||
setSetting(imageUrl);
|
||||
};
|
||||
|
||||
const updateSettingDebounced = debounce(setSetting, 500);
|
||||
|
||||
switch (setting.type) {
|
||||
case 'text':
|
||||
return (
|
||||
|
@ -58,11 +55,12 @@ const ThemeSetting: React.FC<{
|
|||
case 'color':
|
||||
return (
|
||||
<ColorPickerField
|
||||
debounceMs={200}
|
||||
direction='rtl'
|
||||
hint={setting.description}
|
||||
title={humanizeSettingKey(setting.key)}
|
||||
value={setting.value || ''}
|
||||
onChange={value => updateSettingDebounced(value)}
|
||||
onChange={value => setSetting(value)}
|
||||
/>
|
||||
);
|
||||
case 'image':
|
||||
|
|
|
@ -49,6 +49,7 @@ const IframeBuffering: React.FC<IframeBufferingProps> = ({generateContent, class
|
|||
<iframe
|
||||
ref={iframes[0]}
|
||||
className={`${className} ${visibleIframeIndex !== 0 ? 'z-10 opacity-0' : 'z-20 opacity-100'}`}
|
||||
data-visible={visibleIframeIndex === 0}
|
||||
frameBorder="0"
|
||||
height={height}
|
||||
title="Buffered Preview 1"
|
||||
|
@ -58,6 +59,7 @@ const IframeBuffering: React.FC<IframeBufferingProps> = ({generateContent, class
|
|||
<iframe
|
||||
ref={iframes[1]}
|
||||
className={`${className} ${visibleIframeIndex !== 1 ? 'z-10 opacity-0' : 'z-20 opacity-100'}`}
|
||||
data-visible={visibleIframeIndex === 1}
|
||||
frameBorder="0"
|
||||
height={height}
|
||||
title="Buffered Preview 2"
|
||||
|
|
|
@ -29,11 +29,11 @@ test.describe('Design settings', async () => {
|
|||
|
||||
// Homepage and post preview
|
||||
|
||||
await expect(modal.frameLocator('[data-testid="theme-preview"]').getByText('homepage preview')).toHaveCount(1);
|
||||
await expect(modal.frameLocator('[data-testid="theme-preview"] iframe[data-visible=true]').getByText('homepage preview')).toHaveCount(1);
|
||||
|
||||
await modal.getByTestId('design-toolbar').getByRole('tab', {name: 'Post'}).click();
|
||||
|
||||
await expect(modal.frameLocator('[data-testid="theme-preview"]').getByText('post preview')).toHaveCount(1);
|
||||
await expect(modal.frameLocator('[data-testid="theme-preview"] iframe[data-visible=true]').getByText('post preview')).toHaveCount(1);
|
||||
|
||||
// Desktop and mobile preview
|
||||
|
||||
|
@ -49,11 +49,11 @@ test.describe('Design settings', async () => {
|
|||
|
||||
await modal.getByTestId('design-setting-tabs').getByRole('tab', {name: 'Homepage'}).click();
|
||||
|
||||
await expect(modal.frameLocator('[data-testid="theme-preview"]').getByText('homepage preview')).toHaveCount(1);
|
||||
await expect(modal.frameLocator('[data-testid="theme-preview"] iframe[data-visible=true]').getByText('homepage preview')).toHaveCount(1);
|
||||
|
||||
await modal.getByTestId('design-setting-tabs').getByRole('tab', {name: 'Post'}).click();
|
||||
|
||||
await expect(modal.frameLocator('[data-testid="theme-preview"]').getByText('post preview')).toHaveCount(1);
|
||||
await expect(modal.frameLocator('[data-testid="theme-preview"] iframe[data-visible=true]').getByText('post preview')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('Editing brand settings', async ({page}) => {
|
||||
|
@ -77,7 +77,7 @@ test.describe('Design settings', async () => {
|
|||
|
||||
const modal = page.getByTestId('design-modal');
|
||||
|
||||
await expect(modal.frameLocator('[data-testid="theme-preview"]').getByText('homepage preview')).toHaveCount(1);
|
||||
await expect(modal.frameLocator('[data-testid="theme-preview"] iframe[data-visible=true]').getByText('homepage preview')).toHaveCount(1);
|
||||
|
||||
await modal.getByLabel('Site description').fill('new description');
|
||||
// set timeout of 500ms to wait for the debounce
|
||||
|
|
Loading…
Add table
Reference in a new issue