diff --git a/app/soapbox/features/forms/index.tsx b/app/soapbox/features/forms/index.tsx index b7886c0b1..79495c6cf 100644 --- a/app/soapbox/features/forms/index.tsx +++ b/app/soapbox/features/forms/index.tsx @@ -79,6 +79,12 @@ interface ISimpleInput { hint?: React.ReactNode, error?: boolean, onChange?: React.ChangeEventHandler, + min?: number, + max?: number, + pattern?: string, + name?: string, + placeholder?: string, + value?: string | number, } export const SimpleInput: React.FC = (props) => { @@ -95,6 +101,9 @@ export const SimpleInput: React.FC = (props) => { interface ISimpleTextarea { label?: React.ReactNode, hint?: React.ReactNode, + value?: string, + onChange?: React.ChangeEventHandler, + rows?: number, } export const SimpleTextarea: React.FC = (props) => { @@ -149,6 +158,7 @@ export const FieldsGroup: React.FC = ({ children }) => ( interface ICheckbox { label?: React.ReactNode, hint?: React.ReactNode, + name?: string, checked?: boolean, onChange?: React.ChangeEventHandler, } @@ -227,8 +237,11 @@ export const SelectDropdown: React.FC = (props) => { }; interface ITextInput { + name?: string, onChange?: React.ChangeEventHandler, + label?: React.ReactNode, placeholder?: string, + value?: string, } export const TextInput: React.FC = props => ( @@ -243,7 +256,15 @@ FileChooser.defaultProps = { accept: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], }; -export const FileChooserLogo: React.FC = props => ( +interface IFileChooserLogo { + label?: React.ReactNode, + hint?: React.ReactNode, + name?: string, + accept?: string[], + onChange: React.ChangeEventHandler, +} + +export const FileChooserLogo: React.FC = props => ( ); diff --git a/app/soapbox/features/soapbox_config/index.js b/app/soapbox/features/soapbox_config/index.js deleted file mode 100644 index da108e92d..000000000 --- a/app/soapbox/features/soapbox_config/index.js +++ /dev/null @@ -1,458 +0,0 @@ -import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { updateConfig } from 'soapbox/actions/admin'; -import { uploadMedia } from 'soapbox/actions/media'; -import snackbar from 'soapbox/actions/snackbar'; -import Icon from 'soapbox/components/icon'; -import { Column } from 'soapbox/components/ui'; -import { - SimpleForm, - FieldsGroup, - TextInput, - SimpleInput, - SimpleTextarea, - FileChooserLogo, - Checkbox, -} from 'soapbox/features/forms'; -import ThemeToggle from 'soapbox/features/ui/components/theme-toggle'; -import { normalizeSoapboxConfig } from 'soapbox/normalizers'; - -import Accordion from '../ui/components/accordion'; - -import ColorWithPicker from './components/color-with-picker'; -import IconPicker from './components/icon-picker'; -import SitePreview from './components/site-preview'; - -const messages = defineMessages({ - heading: { id: 'column.soapbox_config', defaultMessage: 'Soapbox config' }, - saved: { id: 'soapbox_config.saved', defaultMessage: 'Soapbox config saved!' }, - copyrightFooterLabel: { id: 'soapbox_config.copyright_footer.meta_fields.label_placeholder', defaultMessage: 'Copyright footer' }, - promoItemIcon: { id: 'soapbox_config.promo_panel.meta_fields.icon_placeholder', defaultMessage: 'Icon' }, - promoItemLabel: { id: 'soapbox_config.promo_panel.meta_fields.label_placeholder', defaultMessage: 'Label' }, - promoItemURL: { id: 'soapbox_config.promo_panel.meta_fields.url_placeholder', defaultMessage: 'URL' }, - homeFooterItemLabel: { id: 'soapbox_config.home_footer.meta_fields.label_placeholder', defaultMessage: 'Label' }, - homeFooterItemURL: { id: 'soapbox_config.home_footer.meta_fields.url_placeholder', defaultMessage: 'URL' }, - cryptoAdressItemTicker: { id: 'soapbox_config.crypto_address.meta_fields.ticker_placeholder', defaultMessage: 'Ticker' }, - cryptoAdressItemAddress: { id: 'soapbox_config.crypto_address.meta_fields.address_placeholder', defaultMessage: 'Address' }, - cryptoAdressItemNote: { id: 'soapbox_config.crypto_address.meta_fields.note_placeholder', defaultMessage: 'Note (optional)' }, - cryptoDonatePanelLimitLabel: { id: 'soapbox_config.crypto_donate_panel_limit.meta_fields.limit_placeholder', defaultMessage: 'Number of items to display in the crypto homepage widget' }, - customCssLabel: { id: 'soapbox_config.custom_css.meta_fields.url_placeholder', defaultMessage: 'URL' }, - rawJSONLabel: { id: 'soapbox_config.raw_json_label', defaultMessage: 'Advanced: Edit raw JSON data' }, - rawJSONHint: { id: 'soapbox_config.raw_json_hint', defaultMessage: 'Edit the settings data directly. Changes made directly to the JSON file will override the form fields above. Click "Save" to apply your changes.' }, - verifiedCanEditNameLabel: { id: 'soapbox_config.verified_can_edit_name_label', defaultMessage: 'Allow verified users to edit their own display name.' }, - displayFqnLabel: { id: 'soapbox_config.display_fqn_label', defaultMessage: 'Display domain (eg @user@domain) for local accounts.' }, - greentextLabel: { id: 'soapbox_config.greentext_label', defaultMessage: 'Enable greentext support' }, - promoPanelIconsLink: { id: 'soapbox_config.hints.promo_panel_icons.link', defaultMessage: 'Soapbox Icons List' }, - authenticatedProfileLabel: { id: 'soapbox_config.authenticated_profile_label', defaultMessage: 'Profiles require authentication' }, - authenticatedProfileHint: { id: 'soapbox_config.authenticated_profile_hint', defaultMessage: 'Users must be logged-in to view replies and media on user profiles.' }, - singleUserModeLabel: { id: 'soapbox_config.single_user_mode_label', defaultMessage: 'Single user mode' }, - singleUserModeHint: { id: 'soapbox_config.single_user_mode_hint', defaultMessage: 'Front page will redirect to a given user profile.' }, - singleUserModeProfileLabel: { id: 'soapbox_config.single_user_mode_profile_label', defaultMessage: 'Main user handle' }, - singleUserModeProfileHint: { id: 'soapbox_config.single_user_mode_profile_hint', defaultMessage: '@handle' }, -}); - -const templates = { - promoPanelItem: ImmutableMap({ icon: '', text: '', url: '' }), - footerItem: ImmutableMap({ title: '', url: '' }), - cryptoAddress: ImmutableMap({ ticker: '', address: '', note: '' }), -}; - -const mapStateToProps = state => { - return { - initialData: state.soapbox, - }; -}; - -export default @connect(mapStateToProps) -@injectIntl -class SoapboxConfig extends ImmutablePureComponent { - - static propTypes = { - initialData: ImmutablePropTypes.map.isRequired, - dispatch: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - }; - - state = { - isLoading: false, - data: this.props.initialData, - jsonEditorExpanded: false, - rawJSON: JSON.stringify(this.props.soapbox, null, 2), - jsonValid: true, - } - - setConfig = (path, value) => { - const { data } = this.state; - const newData = data.setIn(path, value); - this.setState({ data: newData, jsonValid: true }); - }; - - putConfig = data => { - this.setState({ data, jsonValid: true }); - }; - - getParams = () => { - const { data } = this.state; - return [{ - group: ':pleroma', - key: ':frontend_configurations', - value: [{ - tuple: [':soapbox_fe', data.toJS()], - }], - }]; - } - - handleSubmit = (event) => { - const { dispatch, intl } = this.props; - dispatch(updateConfig(this.getParams())).then(() => { - this.setState({ isLoading: false }); - dispatch(snackbar.success(intl.formatMessage(messages.saved))); - }).catch((error) => { - this.setState({ isLoading: false }); - }); - this.setState({ isLoading: true }); - event.preventDefault(); - } - - handleChange = (path, getValue) => { - return e => { - this.setConfig(path, getValue(e)); - }; - }; - - handleFileChange = path => { - return e => { - const data = new FormData(); - data.append('file', e.target.files[0]); - this.props.dispatch(uploadMedia(data)).then(({ data }) => { - this.handleChange(path, e => data.url)(e); - }).catch(() => {}); - }; - }; - - handleAddItem = (path, template) => { - return e => { - this.setConfig( - path, - this.getSoapboxConfig().getIn(path, ImmutableList()).push(template), - ); - }; - }; - - handleDeleteItem = path => { - return e => { - const data = this.state.data.deleteIn(path); - this.setState({ data }); - }; - }; - - handleItemChange = (path, key, field, template, getValue = e => e.target.value) => { - return this.handleChange( - path, (e) => - template - .merge(field) - .set(key, getValue(e)), - ); - }; - - handlePromoItemChange = (index, key, field, getValue) => { - return this.handleItemChange( - ['promoPanel', 'items', index], key, field, templates.promoPanelItem, getValue, - ); - }; - - handleHomeFooterItemChange = (index, key, field, getValue) => { - return this.handleItemChange( - ['navlinks', 'homeFooter', index], key, field, templates.footerItem, getValue, - ); - }; - - handleCryptoAdressItemChange = (index, key, field, getValue) => { - return this.handleItemChange( - ['cryptoAddresses', index], key, field, templates.cryptoAddress, getValue, - ); - }; - - handleEditJSON = e => { - this.setState({ rawJSON: e.target.value }); - } - - getSoapboxConfig = () => { - return normalizeSoapboxConfig(this.state.data); - } - - toggleJSONEditor = (value) => this.setState({ jsonEditorExpanded: value }); - - componentDidUpdate(prevProps, prevState) { - if (prevProps.initialData !== this.props.initialData) { - this.putConfig(this.props.initialData); - } - - if (prevState.data !== this.state.data) { - this.setState({ rawJSON: JSON.stringify(this.state.data, null, 2) }); - } - - if (prevState.rawJSON !== this.state.rawJSON) { - try { - const data = fromJS(JSON.parse(this.state.rawJSON)); - this.putConfig(data); - } catch { - this.setState({ jsonValid: false }); - } - } - } - - render() { - const { intl } = this.props; - const soapbox = this.getSoapboxConfig(); - - return ( - - -
- - -
-
- } - value={soapbox.get('brandColor')} - onChange={this.handleChange(['brandColor'], (e) => e.hex)} - /> - } - value={soapbox.get('accentColor')} - onChange={this.handleChange(['accentColor'], (e) => e.hex)} - /> -
-
- - value)} - themeMode={soapbox.getIn(['defaultSettings', 'themeMode'])} - intl={intl} - /> -
-
-
-
- } - name='logo' - hint={
-
-
- - e.target.value)} - /> - - - e.target.checked)} - /> - e.target.checked)} - /> - e.target.checked)} - /> - e.target.checked)} - /> - e.target.checked)} - /> - {soapbox.get('singleUserMode') && ( - e.target.value)} - /> - )} - - -
- - - - - - {intl.formatMessage(messages.promoPanelIconsLink)} }} /> - - { - soapbox.getIn(['promoPanel', 'items']).map((field, i) => ( -
- val.id)} - /> - - - -
- )) - } -
-
- - -
-
-
-
- -
- - - - - { - soapbox.getIn(['navlinks', 'homeFooter']).map((field, i) => ( -
- - - -
- )) - } -
-
- - -
-
-
-
- -
- - - - - { - soapbox.get('cryptoAddresses').map((address, i) => ( -
- - - - -
- )) - } -
-
- - -
-
-
-
- - Number(e.target.value))} - /> - - -
- -
-
-
-
- -
-
-
- ); - } - -} diff --git a/app/soapbox/features/soapbox_config/index.tsx b/app/soapbox/features/soapbox_config/index.tsx new file mode 100644 index 000000000..10ee37e5b --- /dev/null +++ b/app/soapbox/features/soapbox_config/index.tsx @@ -0,0 +1,464 @@ +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; +import React, { useState, useEffect, useMemo } from 'react'; +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; + +import { updateConfig } from 'soapbox/actions/admin'; +import { uploadMedia } from 'soapbox/actions/media'; +import snackbar from 'soapbox/actions/snackbar'; +import Icon from 'soapbox/components/icon'; +import { Column } from 'soapbox/components/ui'; +import { + SimpleForm, + FieldsGroup, + TextInput, + SimpleInput, + SimpleTextarea, + FileChooserLogo, + Checkbox, +} from 'soapbox/features/forms'; +import ThemeToggle from 'soapbox/features/ui/components/theme-toggle'; +import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; +import { normalizeSoapboxConfig } from 'soapbox/normalizers'; + +import Accordion from '../ui/components/accordion'; + +import ColorWithPicker from './components/color-with-picker'; +import IconPicker from './components/icon-picker'; +import SitePreview from './components/site-preview'; + +import type { ColorChangeHandler, ColorResult } from 'react-color'; + +const messages = defineMessages({ + heading: { id: 'column.soapbox_config', defaultMessage: 'Soapbox config' }, + saved: { id: 'soapbox_config.saved', defaultMessage: 'Soapbox config saved!' }, + copyrightFooterLabel: { id: 'soapbox_config.copyright_footer.meta_fields.label_placeholder', defaultMessage: 'Copyright footer' }, + promoItemIcon: { id: 'soapbox_config.promo_panel.meta_fields.icon_placeholder', defaultMessage: 'Icon' }, + promoItemLabel: { id: 'soapbox_config.promo_panel.meta_fields.label_placeholder', defaultMessage: 'Label' }, + promoItemURL: { id: 'soapbox_config.promo_panel.meta_fields.url_placeholder', defaultMessage: 'URL' }, + homeFooterItemLabel: { id: 'soapbox_config.home_footer.meta_fields.label_placeholder', defaultMessage: 'Label' }, + homeFooterItemURL: { id: 'soapbox_config.home_footer.meta_fields.url_placeholder', defaultMessage: 'URL' }, + cryptoAdressItemTicker: { id: 'soapbox_config.crypto_address.meta_fields.ticker_placeholder', defaultMessage: 'Ticker' }, + cryptoAdressItemAddress: { id: 'soapbox_config.crypto_address.meta_fields.address_placeholder', defaultMessage: 'Address' }, + cryptoAdressItemNote: { id: 'soapbox_config.crypto_address.meta_fields.note_placeholder', defaultMessage: 'Note (optional)' }, + cryptoDonatePanelLimitLabel: { id: 'soapbox_config.crypto_donate_panel_limit.meta_fields.limit_placeholder', defaultMessage: 'Number of items to display in the crypto homepage widget' }, + customCssLabel: { id: 'soapbox_config.custom_css.meta_fields.url_placeholder', defaultMessage: 'URL' }, + rawJSONLabel: { id: 'soapbox_config.raw_json_label', defaultMessage: 'Advanced: Edit raw JSON data' }, + rawJSONHint: { id: 'soapbox_config.raw_json_hint', defaultMessage: 'Edit the settings data directly. Changes made directly to the JSON file will override the form fields above. Click "Save" to apply your changes.' }, + verifiedCanEditNameLabel: { id: 'soapbox_config.verified_can_edit_name_label', defaultMessage: 'Allow verified users to edit their own display name.' }, + displayFqnLabel: { id: 'soapbox_config.display_fqn_label', defaultMessage: 'Display domain (eg @user@domain) for local accounts.' }, + greentextLabel: { id: 'soapbox_config.greentext_label', defaultMessage: 'Enable greentext support' }, + promoPanelIconsLink: { id: 'soapbox_config.hints.promo_panel_icons.link', defaultMessage: 'Soapbox Icons List' }, + authenticatedProfileLabel: { id: 'soapbox_config.authenticated_profile_label', defaultMessage: 'Profiles require authentication' }, + authenticatedProfileHint: { id: 'soapbox_config.authenticated_profile_hint', defaultMessage: 'Users must be logged-in to view replies and media on user profiles.' }, + singleUserModeLabel: { id: 'soapbox_config.single_user_mode_label', defaultMessage: 'Single user mode' }, + singleUserModeHint: { id: 'soapbox_config.single_user_mode_hint', defaultMessage: 'Front page will redirect to a given user profile.' }, + singleUserModeProfileLabel: { id: 'soapbox_config.single_user_mode_profile_label', defaultMessage: 'Main user handle' }, + singleUserModeProfileHint: { id: 'soapbox_config.single_user_mode_profile_hint', defaultMessage: '@handle' }, +}); + +type ValueGetter = (e: React.ChangeEvent) => any; +type ColorValueGetter = (color: ColorResult, event: React.ChangeEvent) => any; +type Template = ImmutableMap; +type ConfigPath = Array; + +const templates: Record = { + promoPanelItem: ImmutableMap({ icon: '', text: '', url: '' }), + footerItem: ImmutableMap({ title: '', url: '' }), + cryptoAddress: ImmutableMap({ ticker: '', address: '', note: '' }), +}; + +const SoapboxConfig: React.FC = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const initialData = useAppSelector(state => state.soapbox); + + const [isLoading, setLoading] = useState(false); + const [data, setData] = useState(initialData); + const [jsonEditorExpanded, setJsonEditorExpanded] = useState(false); + const [rawJSON, setRawJSON] = useState(JSON.stringify(initialData, null, 2)); + const [jsonValid, setJsonValid] = useState(true); + + const soapbox = useMemo(() => { + return normalizeSoapboxConfig(data); + }, [data]); + + const setConfig = (path: ConfigPath, value: any) => { + const newData = data.setIn(path, value); + setData(newData); + setJsonValid(true); + }; + + const putConfig = (newData: any) => { + setData(newData); + setJsonValid(true); + }; + + const getParams = () => { + return [{ + group: ':pleroma', + key: ':frontend_configurations', + value: [{ + tuple: [':soapbox_fe', data.toJS()], + }], + }]; + }; + + const handleSubmit: React.FormEventHandler = (e) => { + dispatch(updateConfig(getParams())).then(() => { + setLoading(false); + dispatch(snackbar.success(intl.formatMessage(messages.saved))); + }).catch(() => { + setLoading(false); + }); + setLoading(true); + e.preventDefault(); + }; + + const handleChange = (path: ConfigPath, getValue: ValueGetter): React.ChangeEventHandler => { + return e => { + setConfig(path, getValue(e)); + }; + }; + + const handleColorChange = (path: ConfigPath, getValue: ColorValueGetter): ColorChangeHandler => { + return (color, event) => { + setConfig(path, getValue(color, event)); + }; + }; + + const handleFileChange = (path: ConfigPath): React.ChangeEventHandler => { + return e => { + const data = new FormData(); + const file = e.target.files?.item(0); + + if (file) { + data.append('file', file); + + dispatch(uploadMedia(data)).then(({ data }: any) => { + handleChange(path, () => data.url)(e); + }).catch(console.error); + } + }; + }; + + const handleAddItem = (path: ConfigPath, template: ImmutableMap) => { + const value = (soapbox.getIn(path) || ImmutableList()) as ImmutableList; + + return () => { + setConfig( + path, + value.push(template), + ); + }; + }; + + const handleDeleteItem = (path: ConfigPath) => { + return () => { + const newData = data.deleteIn(path); + setData(newData); + }; + }; + + const handleItemChange = ( + path: Array, + key: string, + field: ImmutableMap, + template: Template, + getValue: ValueGetter = e => e.target.value, + ) => { + return handleChange( + path, (e) => + template + .merge(field) + .set(key, getValue(e)), + ); + }; + + const handlePromoItemChange = (index: number, key: string, field: any, getValue?: ValueGetter) => { + return handleItemChange( + ['promoPanel', 'items', index], key, field, templates.promoPanelItem, getValue, + ); + }; + + const handleHomeFooterItemChange = (index: number, key: string, field: any, getValue?: ValueGetter) => { + return handleItemChange( + ['navlinks', 'homeFooter', index], key, field, templates.footerItem, getValue, + ); + }; + + const handleCryptoAdressItemChange = (index: number, key: string, field: any, getValue?: ValueGetter) => { + return handleItemChange( + ['cryptoAddresses', index], key, field, templates.cryptoAddress, getValue, + ); + }; + + const handleEditJSON: React.ChangeEventHandler = e => { + setRawJSON(e.target.value); + }; + + const toggleJSONEditor = (expanded: boolean) => setJsonEditorExpanded(expanded); + + useEffect(() => { + putConfig(initialData); + }, [initialData]); + + useEffect(() => { + setRawJSON(JSON.stringify(data, null, 2)); + }, [data]); + + useEffect(() => { + try { + const data = fromJS(JSON.parse(rawJSON)); + putConfig(data); + } catch { + setJsonValid(false); + } + }, [rawJSON]); + + return ( + + +
+ + +
+
+ } + value={soapbox.brandColor} + onChange={handleColorChange(['brandColor'], (color) => color.hex)} + /> + } + value={soapbox.accentColor} + onChange={handleColorChange(['accentColor'], (color) => color.hex)} + /> +
+
+ + + {/* value)} + themeMode={soapbox.defaultSettings.get('themeMode')} + intl={intl} + /> */} +
+
+
+
+ } + name='logo' + hint={
+
+
+ + e.target.value)} + /> + + + e.target.checked)} + /> + e.target.checked)} + /> + e.target.checked)} + /> + e.target.checked)} + /> + e.target.checked)} + /> + {soapbox.get('singleUserMode') && ( + e.target.value)} + /> + )} + + +
+ + + + + + {intl.formatMessage(messages.promoPanelIconsLink)} }} /> + + { + soapbox.promoPanel.items.map((field, i) => ( +
+ val.id)} + /> + + + +
+ )) + } +
+
+ + +
+
+
+
+ +
+ + + + + { + soapbox.navlinks.get('homeFooter')?.map((field, i) => ( +
+ + + +
+ )) + } +
+
+ + +
+
+
+
+ +
+ + + + + { + soapbox.cryptoAddresses.map((address, i) => ( +
+ + + + +
+ )) + } +
+
+ + +
+
+
+
+ + Number(e.target.value))} + /> + + +
+ +
+
+
+
+ +
+
+
+ ); +}; + +export default SoapboxConfig;