diff --git a/apps/admin-x-settings/src/admin-x-ds/global/form/HtmlEditor.tsx b/apps/admin-x-settings/src/admin-x-ds/global/form/HtmlEditor.tsx new file mode 100644 index 0000000000..844941227e --- /dev/null +++ b/apps/admin-x-settings/src/admin-x-ds/global/form/HtmlEditor.tsx @@ -0,0 +1,160 @@ +import React, {ReactNode, Suspense, useCallback, useMemo} from 'react'; + +export interface HtmlEditorProps { + value?: string + onChange?: (html: string) => void + onBlur?: () => void + placeholder?: string + nodes?: 'DEFAULT_NODES' | 'BASIC_NODES' | 'MINIMAL_NODES'; +} + +declare global { + interface Window { + '@tryghost/koenig-lexical': any; + } +} + +const fetchKoenig = function ({editorUrl, editorVersion}: { editorUrl: string; editorVersion: string; }) { + let status = 'pending'; + let response: any; + + const fetchPackage = async () => { + if (window['@tryghost/koenig-lexical']) { + return window['@tryghost/koenig-lexical']; + } + + await import(editorUrl.replace('{version}', editorVersion)); + + return window['@tryghost/koenig-lexical']; + }; + + const suspender = fetchPackage().then( + (res) => { + status = 'success'; + response = res; + }, + (err) => { + status = 'error'; + response = err; + } + ); + + const read = () => { + switch (status) { + case 'pending': + throw suspender; + case 'error': + throw response; + default: + return response; + } + }; + + return {read}; +}; + +type EditorResource = ReturnType; + +class ErrorHandler extends React.Component<{ children: ReactNode }> { + state = { + hasError: false + }; + + static getDerivedStateFromError() { + return {hasError: true}; + } + + componentDidCatch(error: any, errorInfo: any) { + console.error(error, errorInfo); // eslint-disable-line + } + + render() { + if (this.state.hasError) { + return ( +

Loading has failed. Try refreshing the browser!

+ ); + } + + return this.props.children; + } +} + +const KoenigWrapper: React.FC = ({ + editor, + value, + onChange, + onBlur, + placeholder, + nodes +}) => { + const onError = useCallback((error: any) => { + // ensure we're still showing errors in development + console.error(error); // eslint-disable-line + + // TODO: Sentry integration? + // if (this.config.sentry_dsn) { + // Sentry.captureException(error, { + // tags: {lexical: true}, + // contexts: { + // koenig: { + // version: window['@tryghost/koenig-lexical']?.version + // } + // } + // }); + // } + // don't rethrow, Lexical will attempt to gracefully recover + }, []); + + const koenig = useMemo(() => new Proxy({} as { [key: string]: any }, { + get: (_target, prop) => { + return editor.read()[prop]; + } + }), [editor]); + + return ( + + + + + + ); +}; + +const HtmlEditor: React.FC = ({ + config, + className, + ...props +}) => { + const editorResource = useMemo(() => fetchKoenig({ + editorUrl: config.editor.url, + editorVersion: config.editor.version + }), [config.editor.url, config.editor.version]); + + return
+
+ + Loading editor...

}> + +
+
+
+
; +}; + +export default HtmlEditor; diff --git a/apps/admin-x-settings/src/admin-x-ds/global/form/HtmlField.stories.tsx b/apps/admin-x-settings/src/admin-x-ds/global/form/HtmlField.stories.tsx new file mode 100644 index 0000000000..98c052252e --- /dev/null +++ b/apps/admin-x-settings/src/admin-x-ds/global/form/HtmlField.stories.tsx @@ -0,0 +1,33 @@ +import type {Meta, StoryObj} from '@storybook/react'; + +import HtmlField from './HtmlField'; + +const meta = { + title: 'Global / Form / Htmlfield', + component: HtmlField, + tags: ['autodocs'], + args: { + config: { + editor: { + url: 'https://cdn.jsdelivr.net/ghost/koenig-lexical@~{version}/dist/koenig-lexical.umd.js', + version: '0.3' + } + } + } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + placeholder: 'Enter something' + } +}; + +export const WithHeading: Story = { + args: { + title: 'Title', + placeholder: 'Enter something' + } +}; diff --git a/apps/admin-x-settings/src/admin-x-ds/global/form/HtmlField.tsx b/apps/admin-x-settings/src/admin-x-ds/global/form/HtmlField.tsx new file mode 100644 index 0000000000..ae507d676a --- /dev/null +++ b/apps/admin-x-settings/src/admin-x-ds/global/form/HtmlField.tsx @@ -0,0 +1,60 @@ +import Heading from '../Heading'; +import Hint from '../Hint'; +import HtmlEditor, {HtmlEditorProps} from './HtmlEditor'; +import React from 'react'; +import clsx from 'clsx'; + +export type HtmlFieldProps = HtmlEditorProps & { + /** + * Should be passed the Ghost instance config to get the editor JS URL + */ + config: { editor: { url: string; version: string; } }; + title?: string; + hideTitle?: boolean; + error?: boolean; + hint?: React.ReactNode; + clearBg?: boolean; + className?: string; + containerClassName?: string; + hintClassName?: string; + unstyled?: boolean; +} + +/** + * Renders a mini Koenig editor using KoenigComposableEditor. + * Intended for use in settings forms where we don't need the full editor. + */ +const HtmlField: React.FC = ({ + title, + hideTitle, + error, + placeholder, + hint, + value, + clearBg = true, + className = '', + containerClassName = '', + hintClassName = '', + unstyled = false, + ...props +}) => { + const textFieldClasses = unstyled ? '' : clsx( + 'h-10 border-b py-2', + clearBg ? 'bg-transparent' : 'bg-grey-75 px-[10px]', + error ? `border-red` : `border-grey-500 hover:border-grey-700 focus:border-black`, + (title && !hideTitle && !clearBg) && `mt-2`, + className + ); + + return ( +
+ {title && {title}} +
+ +
+ {hint && {hint}} +
+ ); +}; + +export default HtmlField; diff --git a/apps/admin-x-settings/src/components/settings/membership/portal/SignupOptions.tsx b/apps/admin-x-settings/src/components/settings/membership/portal/SignupOptions.tsx index fec5e5b2a3..ce901b6ca1 100644 --- a/apps/admin-x-settings/src/components/settings/membership/portal/SignupOptions.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/portal/SignupOptions.tsx @@ -1,6 +1,7 @@ import CheckboxGroup from '../../../../admin-x-ds/global/form/CheckboxGroup'; import Form from '../../../../admin-x-ds/global/form/Form'; -import React, {useContext} from 'react'; +import HtmlField from '../../../../admin-x-ds/global/form/HtmlField'; +import React, {useContext, useMemo} from 'react'; import Toggle from '../../../../admin-x-ds/global/form/Toggle'; import {CheckboxProps} from '../../../../admin-x-ds/global/form/Checkbox'; import {Setting, SettingValue, Tier} from '../../../../types/api'; @@ -15,9 +16,17 @@ const SignupOptions: React.FC<{ }> = ({localSettings, updateSetting, localTiers, updateTier}) => { const {config} = useContext(SettingsContext); - const [membersSignupAccess, portalName, portalSignupCheckboxRequired, portalPlansJson] = getSettingValues(localSettings, ['members_signup_access', 'portal_name', 'portal_signup_checkbox_required', 'portal_plans']); + const [membersSignupAccess, portalName, portalSignupTermsHtml, portalSignupCheckboxRequired, portalPlansJson] = getSettingValues( + localSettings, ['members_signup_access', 'portal_name', 'portal_signup_terms_html', 'portal_signup_checkbox_required', 'portal_plans'] + ); const portalPlans = JSON.parse(portalPlansJson?.toString() || '[]') as string[]; + const signupTermsLength = useMemo(() => { + const div = document.createElement('div'); + div.innerHTML = portalSignupTermsHtml?.toString() || ''; + return div.innerText.length; + }, [portalSignupTermsHtml]); + const togglePlan = (plan: string) => { const index = portalPlans.indexOf(plan); @@ -98,7 +107,15 @@ const SignupOptions: React.FC<{ /> )} -
TODO: Display notice at signup (Koenig)
+ {/* TODO: validate length according to hint */} + Recommended: 115 characters. You've used {signupTermsLength}} + nodes='MINIMAL_NODES' + title='Display notice at signup' + value={portalSignupTermsHtml?.toString()} + onChange={html => updateSetting('portal_signup_terms_html', html)} + />