From 1bd81a2f0f33351921c7c595a8d6231e88130a48 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 5 May 2022 13:08:45 -0500 Subject: [PATCH 01/29] SoapboxConfig: break extraneous components out into their own files --- .../components/color-picker.tsx | 49 +++++++ .../components/color-with-picker.tsx | 61 +++++++++ .../soapbox_config/components/icon-picker.tsx | 24 ++++ app/soapbox/features/soapbox_config/index.js | 127 +----------------- package.json | 1 + yarn.lock | 15 +++ 6 files changed, 152 insertions(+), 125 deletions(-) create mode 100644 app/soapbox/features/soapbox_config/components/color-picker.tsx create mode 100644 app/soapbox/features/soapbox_config/components/color-with-picker.tsx create mode 100644 app/soapbox/features/soapbox_config/components/icon-picker.tsx diff --git a/app/soapbox/features/soapbox_config/components/color-picker.tsx b/app/soapbox/features/soapbox_config/components/color-picker.tsx new file mode 100644 index 000000000..8b203a901 --- /dev/null +++ b/app/soapbox/features/soapbox_config/components/color-picker.tsx @@ -0,0 +1,49 @@ +import { supportsPassiveEvents } from 'detect-passive-events'; +import React, { useEffect, useRef } from 'react'; +import { SketchPicker, ColorChangeHandler } from 'react-color'; + +import { isMobile } from 'soapbox/is_mobile'; + +const listenerOptions = supportsPassiveEvents ? { passive: true } : false; + +interface IColorPicker { + style?: React.CSSProperties, + value: string, + onChange: ColorChangeHandler, + onClose: () => void, +} + +const ColorPicker: React.FC = ({ style, value, onClose, onChange }) => { + const node = useRef(null); + + const handleDocumentClick = (e: MouseEvent | TouchEvent) => { + if (node.current && !node.current.contains(e.target as HTMLElement)) { + onClose(); + } + }; + + useEffect(() => { + document.addEventListener('click', handleDocumentClick, false); + document.addEventListener('touchend', handleDocumentClick, listenerOptions); + + return () => { + document.removeEventListener('click', handleDocumentClick, false); + document.removeEventListener('touchend', handleDocumentClick); + }; + }); + + const pickerStyle: React.CSSProperties = { + ...style, + marginLeft: isMobile(window.innerWidth) ? '20px' : '12px', + position: 'absolute', + zIndex: 1000, + }; + + return ( +
+ +
+ ); +}; + +export default ColorPicker; diff --git a/app/soapbox/features/soapbox_config/components/color-with-picker.tsx b/app/soapbox/features/soapbox_config/components/color-with-picker.tsx new file mode 100644 index 000000000..6178140e6 --- /dev/null +++ b/app/soapbox/features/soapbox_config/components/color-with-picker.tsx @@ -0,0 +1,61 @@ +import React, { useState, useRef } from 'react'; +// @ts-ignore: TODO: upgrade react-overlays. v3.1 and above have TS definitions +import Overlay from 'react-overlays/lib/Overlay'; + +import { isMobile } from 'soapbox/is_mobile'; + +import ColorPicker from './color-picker'; + +import type { ColorChangeHandler } from 'react-color'; + +interface IColorWithPicker { + buttonId: string, + label: React.ReactNode, + value: string, + onChange: ColorChangeHandler, +} + +const ColorWithPicker: React.FC = ({ buttonId, label, value, onChange }) => { + const node = useRef(null); + const [active, setActive] = useState(false); + const [placement, setPlacement] = useState(null); + + const hidePicker = () => { + setActive(false); + }; + + const showPicker = () => { + setActive(true); + setPlacement(isMobile(window.innerWidth) ? 'bottom' : 'right'); + }; + + const onToggle: React.MouseEventHandler = () => { + if (active) { + hidePicker(); + } else { + showPicker(); + } + }; + + return ( +
+ + + + ); +}; + +export default ColorWithPicker; diff --git a/app/soapbox/features/soapbox_config/components/icon-picker.tsx b/app/soapbox/features/soapbox_config/components/icon-picker.tsx new file mode 100644 index 000000000..bb0c18eff --- /dev/null +++ b/app/soapbox/features/soapbox_config/components/icon-picker.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import IconPickerDropdown from './icon_picker_dropdown'; + +interface IIconPicker { + label: React.ReactNode, + value: string, + onChange: React.ChangeEventHandler, +} + +const IconPicker: React.FC = ({ onChange, value, label }) => { + return ( +
+
+ {label && ()} +
+ +
+
+
+ ); +}; + +export default IconPicker; diff --git a/app/soapbox/features/soapbox_config/index.js b/app/soapbox/features/soapbox_config/index.js index 44317a1b7..b6fe7f17f 100644 --- a/app/soapbox/features/soapbox_config/index.js +++ b/app/soapbox/features/soapbox_config/index.js @@ -1,12 +1,9 @@ -import { supportsPassiveEvents } from 'detect-passive-events'; import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; import PropTypes from 'prop-types'; import React from 'react'; -import { SketchPicker } from 'react-color'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import Overlay from 'react-overlays/lib/Overlay'; import { connect } from 'react-redux'; import { updateConfig } from 'soapbox/actions/admin'; @@ -24,12 +21,12 @@ import { Checkbox, } from 'soapbox/features/forms'; import ThemeToggle from 'soapbox/features/ui/components/theme-toggle'; -import { isMobile } from 'soapbox/is_mobile'; import { normalizeSoapboxConfig } from 'soapbox/normalizers'; import Accordion from '../ui/components/accordion'; -import IconPickerDropdown from './components/icon_picker_dropdown'; +import ColorWithPicker from './components/color-with-picker'; +import IconPicker from './components/icon-picker'; import SitePreview from './components/site_preview'; const messages = defineMessages({ @@ -60,8 +57,6 @@ const messages = defineMessages({ singleUserModeProfileHint: { id: 'soapbox_config.single_user_mode_profile_hint', defaultMessage: '@handle' }, }); -const listenerOptions = supportsPassiveEvents ? { passive: true } : false; - const templates = { promoPanelItem: ImmutableMap({ icon: '', text: '', url: '' }), footerItem: ImmutableMap({ title: '', url: '' }), @@ -461,121 +456,3 @@ class SoapboxConfig extends ImmutablePureComponent { } } - -class ColorPicker extends React.PureComponent { - - static propTypes = { - style: PropTypes.object, - value: PropTypes.string.isRequired, - onChange: PropTypes.func.isRequired, - onClose: PropTypes.func, - } - - handleDocumentClick = e => { - if (this.node && !this.node.contains(e.target)) { - this.props.onClose(); - } - } - - componentDidMount() { - document.addEventListener('click', this.handleDocumentClick, false); - document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); - } - - componentWillUnmount() { - document.removeEventListener('click', this.handleDocumentClick, false); - document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); - } - - setRef = c => { - this.node = c; - } - - render() { - const { style, value, onChange } = this.props; - const margin_left_picker = isMobile(window.innerWidth) ? '20px' : '12px'; - - return ( -
- -
- ); - } - -} - -class ColorWithPicker extends ImmutablePureComponent { - - static propTypes = { - buttonId: PropTypes.string.isRequired, - label: PropTypes.node, - value: PropTypes.string.isRequired, - onChange: PropTypes.func.isRequired, - } - - onToggle = (e) => { - if (!e.key || e.key === 'Enter') { - if (this.state.active) { - this.onHidePicker(); - } else { - this.onShowPicker(e); - } - } - } - - state = { - active: false, - placement: null, - } - - onHidePicker = () => { - this.setState({ active: false }); - } - - onShowPicker = ({ target }) => { - this.setState({ active: true }); - this.setState({ placement: isMobile(window.innerWidth) ? 'bottom' : 'right' }); - } - - render() { - const { buttonId, label, value, onChange } = this.props; - const { active, placement } = this.state; - - return ( -
- - - ); - } - -} - -export class IconPicker extends ImmutablePureComponent { - - static propTypes = { - icons: PropTypes.object, - label: PropTypes.node, - value: PropTypes.string, - onChange: PropTypes.func.isRequired, - } - - render() { - const { onChange, value, label } = this.props; - - return ( -
-
- {label && ()} -
- -
-
-
- ); - } - -} diff --git a/package.json b/package.json index 5eed7b932..5d19be83b 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "@types/object-assign": "^4.0.30", "@types/object-fit-images": "^3.2.3", "@types/qrcode.react": "^1.0.2", + "@types/react-color": "^3.0.6", "@types/react-datepicker": "^4.4.0", "@types/react-helmet": "^6.1.5", "@types/react-motion": "^0.0.32", diff --git a/yarn.lock b/yarn.lock index 39d12627f..9445a64a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2184,6 +2184,14 @@ dependencies: "@types/react" "*" +"@types/react-color@^3.0.6": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/react-color/-/react-color-3.0.6.tgz#602fed023802b2424e7cd6ff3594ccd3d5055f9a" + integrity sha512-OzPIO5AyRmLA7PlOyISlgabpYUa3En74LP8mTMa0veCA719SvYQov4WLMsHvCgXP+L+KI9yGhYnqZafVGG0P4w== + dependencies: + "@types/react" "*" + "@types/reactcss" "*" + "@types/react-datepicker@^4.4.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@types/react-datepicker/-/react-datepicker-4.4.0.tgz#0072e18536ad305fd57786f9b6f9e499eed2b475" @@ -2272,6 +2280,13 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/reactcss@*": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@types/reactcss/-/reactcss-1.2.6.tgz#133c1e7e896f2726370d1d5a26bf06a30a038bcc" + integrity sha512-qaIzpCuXNWomGR1Xq8SCFTtF4v8V27Y6f+b9+bzHiv087MylI/nTCqqdChNeWS7tslgROmYB7yeiruWX7WnqNg== + dependencies: + "@types/react" "*" + "@types/redux-mock-store@^1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@types/redux-mock-store/-/redux-mock-store-1.0.3.tgz#895de4a364bc4836661570aec82f2eef5989d1fb" From 748d1f6c21771f05308de3d72a0ee5fc5845b63e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 5 May 2022 13:23:25 -0500 Subject: [PATCH 02/29] SitePreview: convert to tsx --- .../{site_preview.js => site-preview.tsx} | 28 +++++++++++-------- app/soapbox/features/soapbox_config/index.js | 2 +- .../normalizers/soapbox/soapbox_config.ts | 2 +- 3 files changed, 18 insertions(+), 14 deletions(-) rename app/soapbox/features/soapbox_config/components/{site_preview.js => site-preview.tsx} (64%) diff --git a/app/soapbox/features/soapbox_config/components/site_preview.js b/app/soapbox/features/soapbox_config/components/site-preview.tsx similarity index 64% rename from app/soapbox/features/soapbox_config/components/site_preview.js rename to app/soapbox/features/soapbox_config/components/site-preview.tsx index 3d52d7200..87706f3d8 100644 --- a/app/soapbox/features/soapbox_config/components/site_preview.js +++ b/app/soapbox/features/soapbox_config/components/site-preview.tsx @@ -1,13 +1,19 @@ import classNames from 'classnames'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; +import React, { useMemo } from 'react'; import { defaultSettings } from 'soapbox/actions/settings'; +import { normalizeSoapboxConfig } from 'soapbox/normalizers'; import { generateThemeCss } from 'soapbox/utils/theme'; -export default function SitePreview({ soapbox }) { +interface ISitePreview { + /** Raw Soapbox configuration. */ + soapbox: any, +} - const settings = defaultSettings.mergeDeep(soapbox.get('defaultSettings')); +/** Renders a preview of the website's style with the configuration applied. */ +const SitePreview: React.FC = ({ soapbox }) => { + const soapboxConfig = useMemo(() => normalizeSoapboxConfig(soapbox), [soapbox]); + const settings = defaultSettings.mergeDeep(soapboxConfig.defaultSettings); const bodyClass = classNames('site-preview', `theme-mode-${settings.get('themeMode')}`, { 'no-reduce-motion': !settings.get('reduceMotion'), @@ -18,7 +24,7 @@ export default function SitePreview({ soapbox }) { return (
- +
@@ -26,15 +32,15 @@ export default function SitePreview({ soapbox }) {
@@ -49,8 +55,6 @@ export default function SitePreview({ soapbox }) {
); -} - -SitePreview.propTypes = { - soapbox: ImmutablePropTypes.record.isRequired, }; + +export default SitePreview; diff --git a/app/soapbox/features/soapbox_config/index.js b/app/soapbox/features/soapbox_config/index.js index b6fe7f17f..da108e92d 100644 --- a/app/soapbox/features/soapbox_config/index.js +++ b/app/soapbox/features/soapbox_config/index.js @@ -27,7 +27,7 @@ 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 SitePreview from './components/site-preview'; const messages = defineMessages({ heading: { id: 'column.soapbox_config', defaultMessage: 'Soapbox config' }, diff --git a/app/soapbox/normalizers/soapbox/soapbox_config.ts b/app/soapbox/normalizers/soapbox/soapbox_config.ts index e37a4faad..07fd5aff3 100644 --- a/app/soapbox/normalizers/soapbox/soapbox_config.ts +++ b/app/soapbox/normalizers/soapbox/soapbox_config.ts @@ -89,7 +89,7 @@ export const SoapboxConfigRecord = ImmutableRecord({ colors: ImmutableMap(), copyright: `♥${new Date().getFullYear()}. Copying is an act of love. Please copy and share.`, customCss: ImmutableList(), - defaultSettings: ImmutableMap(), + defaultSettings: ImmutableMap(), extensions: ImmutableMap(), greentext: false, promoPanel: PromoPanelRecord(), From eaeeed29c0d11b5e28833aa80331f6e7f1f191db Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 5 May 2022 14:25:57 -0500 Subject: [PATCH 03/29] SoapboxConfig: convert to TSX --- app/soapbox/features/forms/index.tsx | 23 +- app/soapbox/features/soapbox_config/index.js | 458 ----------------- app/soapbox/features/soapbox_config/index.tsx | 464 ++++++++++++++++++ 3 files changed, 486 insertions(+), 459 deletions(-) delete mode 100644 app/soapbox/features/soapbox_config/index.js create mode 100644 app/soapbox/features/soapbox_config/index.tsx 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; From 0f341eee6e8f93f94e38f8303a142e4c1bea467d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 5 May 2022 14:41:35 -0500 Subject: [PATCH 04/29] Streamfield: export generic StreamfieldComponent type --- app/soapbox/components/ui/streamfield/streamfield.tsx | 8 +++++++- app/soapbox/features/edit_profile/index.tsx | 9 ++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/app/soapbox/components/ui/streamfield/streamfield.tsx b/app/soapbox/components/ui/streamfield/streamfield.tsx index 90651fb78..ff4f619f8 100644 --- a/app/soapbox/components/ui/streamfield/streamfield.tsx +++ b/app/soapbox/components/ui/streamfield/streamfield.tsx @@ -12,6 +12,12 @@ const messages = defineMessages({ remove: { id: 'streamfield.remove', defaultMessage: 'Remove' }, }); +/** Type of the inner Streamfield input component. */ +export type StreamfieldComponent = React.ComponentType<{ + value: T, + onChange: (value: T) => void, +}>; + interface IStreamfield { /** Array of values for the streamfield. */ values: any[], @@ -26,7 +32,7 @@ interface IStreamfield { /** Callback when values are changed. */ onChange: (values: any[]) => void, /** Input to render for each value. */ - component: React.ComponentType<{ onChange: (value: any) => void, value: any }>, + component: StreamfieldComponent, /** Maximum number of allowed inputs. */ maxItems?: number, } diff --git a/app/soapbox/features/edit_profile/index.tsx b/app/soapbox/features/edit_profile/index.tsx index 6a174a022..b2e0df172 100644 --- a/app/soapbox/features/edit_profile/index.tsx +++ b/app/soapbox/features/edit_profile/index.tsx @@ -12,7 +12,7 @@ import { normalizeAccount } from 'soapbox/normalizers'; import resizeImage from 'soapbox/utils/resize_image'; import { Button, Column, Form, FormActions, FormGroup, Input, Textarea, HStack } from '../../components/ui'; -import Streamfield from '../../components/ui/streamfield/streamfield'; +import Streamfield, { StreamfieldComponent } from '../../components/ui/streamfield/streamfield'; import ProfilePreview from './components/profile-preview'; @@ -147,12 +147,7 @@ const accountToCredentials = (account: Account): AccountCredentials => { }; }; -interface IProfileField { - value: AccountCredentialsField, - onChange: (field: AccountCredentialsField) => void, -} - -const ProfileField: React.FC = ({ value, onChange }) => { +const ProfileField: StreamfieldComponent = ({ value, onChange }) => { const intl = useIntl(); const handleChange = (key: string): React.ChangeEventHandler => { From 9bd18b13bd8c44007e328ae54994516c72ffd7a2 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 5 May 2022 14:57:19 -0500 Subject: [PATCH 05/29] Streamfield: labelText, hintText --> label, hint --- .../components/ui/streamfield/streamfield.tsx | 12 ++++++------ app/soapbox/features/edit_profile/index.tsx | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/soapbox/components/ui/streamfield/streamfield.tsx b/app/soapbox/components/ui/streamfield/streamfield.tsx index ff4f619f8..48e8d43ba 100644 --- a/app/soapbox/components/ui/streamfield/streamfield.tsx +++ b/app/soapbox/components/ui/streamfield/streamfield.tsx @@ -22,9 +22,9 @@ interface IStreamfield { /** Array of values for the streamfield. */ values: any[], /** Input label message. */ - labelText?: React.ReactNode, + label?: React.ReactNode, /** Input hint message. */ - hintText?: React.ReactNode, + hint?: React.ReactNode, /** Callback to add an item. */ onAddItem?: () => void, /** Callback to remove an item by index. */ @@ -40,8 +40,8 @@ interface IStreamfield { /** List of inputs that can be added or removed. */ const Streamfield: React.FC = ({ values, - labelText, - hintText, + label, + hint, onAddItem, onRemoveItem, onChange, @@ -61,8 +61,8 @@ const Streamfield: React.FC = ({ return ( - {labelText && {labelText}} - {hintText && {hintText}} + {label && {label}} + {hint && {hint}} diff --git a/app/soapbox/features/edit_profile/index.tsx b/app/soapbox/features/edit_profile/index.tsx index b2e0df172..e7c607a6a 100644 --- a/app/soapbox/features/edit_profile/index.tsx +++ b/app/soapbox/features/edit_profile/index.tsx @@ -452,8 +452,8 @@ const EditProfile: React.FC = () => { {features.profileFields && ( } - hintText={} + label={} + hint={} values={data.fields_attributes || []} onChange={handleFieldsChange} onAddItem={handleAddField} From 2588bdd4ffa652ce661cdaacf43c44f6b4040167 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 5 May 2022 15:06:52 -0500 Subject: [PATCH 06/29] SoapboxConfig: use Streamfield for cryptoAddresses --- app/soapbox/features/soapbox_config/index.tsx | 106 ++++++++++-------- 1 file changed, 62 insertions(+), 44 deletions(-) diff --git a/app/soapbox/features/soapbox_config/index.tsx b/app/soapbox/features/soapbox_config/index.tsx index 10ee37e5b..d6e2b4615 100644 --- a/app/soapbox/features/soapbox_config/index.tsx +++ b/app/soapbox/features/soapbox_config/index.tsx @@ -6,7 +6,8 @@ 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 { Column, HStack, Input } from 'soapbox/components/ui'; +import Streamfield, { StreamfieldComponent } from 'soapbox/components/ui/streamfield/streamfield'; import { SimpleForm, FieldsGroup, @@ -27,6 +28,7 @@ import IconPicker from './components/icon-picker'; import SitePreview from './components/site-preview'; import type { ColorChangeHandler, ColorResult } from 'react-color'; +import type { CryptoAddress } from 'soapbox/types/soapbox'; const messages = defineMessages({ heading: { id: 'column.soapbox_config', defaultMessage: 'Soapbox config' }, @@ -67,6 +69,42 @@ const templates: Record = { cryptoAddress: ImmutableMap({ ticker: '', address: '', note: '' }), }; +const CryptoAddressInput: StreamfieldComponent = ({ value, onChange }) => { + const intl = useIntl(); + + const handleChange = (key: 'ticker' | 'address' | 'note'): React.ChangeEventHandler => { + return e => { + onChange(value.set(key, e.currentTarget.value)); + }; + }; + + return ( + + + + + + ); +}; + const SoapboxConfig: React.FC = () => { const intl = useIntl(); const dispatch = useAppDispatch(); @@ -187,10 +225,18 @@ const SoapboxConfig: React.FC = () => { ); }; - const handleCryptoAdressItemChange = (index: number, key: string, field: any, getValue?: ValueGetter) => { - return handleItemChange( - ['cryptoAddresses', index], key, field, templates.cryptoAddress, getValue, - ); + const handleCryptoAdressChange = (values: CryptoAddress[]) => { + setConfig(['cryptoAddresses'], ImmutableList(values)); + }; + + const addCryptoAddress = () => { + const cryptoAddresses = data.get('cryptoAddresses'); + setConfig(['cryptoAddresses'], cryptoAddresses.push(templates.cryptoAddress)); + }; + + const removeCryptoAddress = (i: number) => { + const cryptoAddresses = data.get('cryptoAddresses'); + setConfig(['cryptoAddresses'], cryptoAddresses.delete(i)); }; const handleEditJSON: React.ChangeEventHandler = e => { @@ -385,45 +431,17 @@ const SoapboxConfig: React.FC = () => {
- -
- - - - - { - soapbox.cryptoAddresses.map((address, i) => ( -
- - - - -
- )) - } -
-
- - -
-
-
-
+ + } + hint={} + component={CryptoAddressInput} + values={soapbox.cryptoAddresses.toArray()} + onChange={handleCryptoAdressChange} + onAddItem={addCryptoAddress} + onRemoveItem={removeCryptoAddress} + /> + Date: Thu, 5 May 2022 15:12:38 -0500 Subject: [PATCH 07/29] Move CryptoAddressInput into its own file --- .../components/crypto-address-input.tsx | 51 +++++++++++++++++++ app/soapbox/features/soapbox_config/index.tsx | 40 +-------------- 2 files changed, 52 insertions(+), 39 deletions(-) create mode 100644 app/soapbox/features/soapbox_config/components/crypto-address-input.tsx diff --git a/app/soapbox/features/soapbox_config/components/crypto-address-input.tsx b/app/soapbox/features/soapbox_config/components/crypto-address-input.tsx new file mode 100644 index 000000000..75ffec27c --- /dev/null +++ b/app/soapbox/features/soapbox_config/components/crypto-address-input.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { useIntl, defineMessages } from 'react-intl'; + +import { HStack, Input } from 'soapbox/components/ui'; +import { StreamfieldComponent } from 'soapbox/components/ui/streamfield/streamfield'; + +import type { CryptoAddress } from 'soapbox/types/soapbox'; + +const messages = defineMessages({ + ticker: { id: 'soapbox_config.crypto_address.meta_fields.ticker_placeholder', defaultMessage: 'Ticker' }, + address: { id: 'soapbox_config.crypto_address.meta_fields.address_placeholder', defaultMessage: 'Address' }, + note: { id: 'soapbox_config.crypto_address.meta_fields.note_placeholder', defaultMessage: 'Note (optional)' }, +}); + +const CryptoAddressInput: StreamfieldComponent = ({ value, onChange }) => { + const intl = useIntl(); + + const handleChange = (key: 'ticker' | 'address' | 'note'): React.ChangeEventHandler => { + return e => { + onChange(value.set(key, e.currentTarget.value)); + }; + }; + + return ( + + + + + + ); +}; + +export default CryptoAddressInput; diff --git a/app/soapbox/features/soapbox_config/index.tsx b/app/soapbox/features/soapbox_config/index.tsx index d6e2b4615..c3ca22147 100644 --- a/app/soapbox/features/soapbox_config/index.tsx +++ b/app/soapbox/features/soapbox_config/index.tsx @@ -24,6 +24,7 @@ import { normalizeSoapboxConfig } from 'soapbox/normalizers'; import Accordion from '../ui/components/accordion'; import ColorWithPicker from './components/color-with-picker'; +import CryptoAddressInput from './components/crypto-address-input'; import IconPicker from './components/icon-picker'; import SitePreview from './components/site-preview'; @@ -39,9 +40,6 @@ const messages = defineMessages({ 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' }, @@ -69,42 +67,6 @@ const templates: Record = { cryptoAddress: ImmutableMap({ ticker: '', address: '', note: '' }), }; -const CryptoAddressInput: StreamfieldComponent = ({ value, onChange }) => { - const intl = useIntl(); - - const handleChange = (key: 'ticker' | 'address' | 'note'): React.ChangeEventHandler => { - return e => { - onChange(value.set(key, e.currentTarget.value)); - }; - }; - - return ( - - - - - - ); -}; - const SoapboxConfig: React.FC = () => { const intl = useIntl(); const dispatch = useAppDispatch(); From 91c52cdde704869c6e89f70df924bee06295f22b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 5 May 2022 15:52:25 -0500 Subject: [PATCH 08/29] SoapboxConfig: use Streamfield for PromoPanelInput, refactor getter/setters --- app/soapbox/features/soapbox_config/index.tsx | 158 +++++++++--------- 1 file changed, 81 insertions(+), 77 deletions(-) diff --git a/app/soapbox/features/soapbox_config/index.tsx b/app/soapbox/features/soapbox_config/index.tsx index c3ca22147..676656e5c 100644 --- a/app/soapbox/features/soapbox_config/index.tsx +++ b/app/soapbox/features/soapbox_config/index.tsx @@ -6,10 +6,9 @@ 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, HStack, Input } from 'soapbox/components/ui'; +import { Column, Form, FormActions, Button, HStack, Input } from 'soapbox/components/ui'; import Streamfield, { StreamfieldComponent } from 'soapbox/components/ui/streamfield/streamfield'; import { - SimpleForm, FieldsGroup, TextInput, SimpleInput, @@ -29,7 +28,7 @@ import IconPicker from './components/icon-picker'; import SitePreview from './components/site-preview'; import type { ColorChangeHandler, ColorResult } from 'react-color'; -import type { CryptoAddress } from 'soapbox/types/soapbox'; +import type { CryptoAddress, PromoPanelItem } from 'soapbox/types/soapbox'; const messages = defineMessages({ heading: { id: 'column.soapbox_config', defaultMessage: 'Soapbox config' }, @@ -67,6 +66,43 @@ const templates: Record = { cryptoAddress: ImmutableMap({ ticker: '', address: '', note: '' }), }; +const PromoPanelInput: StreamfieldComponent = ({ value, onChange }) => { + const intl = useIntl(); + + const handleIconChange = (icon: any) => { + onChange(value.set('icon', icon.id)); + }; + + const handleChange = (key: 'text' | 'url'): React.ChangeEventHandler => { + return e => { + onChange(value.set(key, e.currentTarget.value)); + }; + }; + + return ( + + + + + + + ); +}; + const SoapboxConfig: React.FC = () => { const intl = useIntl(); const dispatch = useAppDispatch(); @@ -153,13 +189,13 @@ const SoapboxConfig: React.FC = () => { }; }; - const handleDeleteItem = (path: ConfigPath) => { - return () => { - const newData = data.deleteIn(path); - setData(newData); - }; + const deleteItem = (path: ConfigPath) => { + const newData = data.deleteIn(path); + setData(newData); }; + const handleDeleteItem = (path: ConfigPath) => () => deleteItem(path); + const handleItemChange = ( path: Array, key: string, @@ -175,10 +211,23 @@ const SoapboxConfig: React.FC = () => { ); }; - const handlePromoItemChange = (index: number, key: string, field: any, getValue?: ValueGetter) => { - return handleItemChange( - ['promoPanel', 'items', index], key, field, templates.promoPanelItem, getValue, - ); + const handleStreamItemChange = (path: ConfigPath) => { + return (values: any[]) => { + setConfig(path, ImmutableList(values)); + }; + }; + + const addStreamItem = (path: ConfigPath, template: Template) => { + return () => { + const items = data.getIn(path); + setConfig(path, items.push(template)); + }; + }; + + const deleteStreamItem = (path: ConfigPath) => { + return (i: number) => { + deleteItem([...path, i]); + }; }; const handleHomeFooterItemChange = (index: number, key: string, field: any, getValue?: ValueGetter) => { @@ -187,20 +236,6 @@ const SoapboxConfig: React.FC = () => { ); }; - const handleCryptoAdressChange = (values: CryptoAddress[]) => { - setConfig(['cryptoAddresses'], ImmutableList(values)); - }; - - const addCryptoAddress = () => { - const cryptoAddresses = data.get('cryptoAddresses'); - setConfig(['cryptoAddresses'], cryptoAddresses.push(templates.cryptoAddress)); - }; - - const removeCryptoAddress = (i: number) => { - const cryptoAddresses = data.get('cryptoAddresses'); - setConfig(['cryptoAddresses'], cryptoAddresses.delete(i)); - }; - const handleEditJSON: React.ChangeEventHandler = e => { setRawJSON(e.target.value); }; @@ -226,7 +261,7 @@ const SoapboxConfig: React.FC = () => { return ( - +
@@ -318,48 +353,17 @@ const SoapboxConfig: React.FC = () => { /> )} - -
- - - - - - {intl.formatMessage(messages.promoPanelIconsLink)} }} /> - - { - soapbox.promoPanel.items.map((field, i) => ( -
- val.id)} - /> - - - -
- )) - } -
-
- - -
-
-
-
+ + } + hint={} + component={PromoPanelInput} + values={soapbox.promoPanel.items.toArray()} + onChange={handleStreamItemChange(['promoPanel', 'items'])} + onAddItem={addStreamItem(['promoPanel', 'items'], templates.promoPanel)} + onRemoveItem={deleteStreamItem(['promoPanel', 'items'])} + /> +
@@ -399,9 +403,9 @@ const SoapboxConfig: React.FC = () => { hint={} component={CryptoAddressInput} values={soapbox.cryptoAddresses.toArray()} - onChange={handleCryptoAdressChange} - onAddItem={addCryptoAddress} - onRemoveItem={removeCryptoAddress} + onChange={handleStreamItemChange(['cryptoAddresses'])} + onAddItem={addStreamItem(['cryptoAddresses'], templates.cryptoAddress)} + onRemoveItem={deleteStreamItem(['cryptoAddresses'])} /> @@ -431,12 +435,12 @@ const SoapboxConfig: React.FC = () => {
-
- -
- + + +
); }; From af61ae9d354ff7eee5c9906203c67037e9a2f846 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 5 May 2022 15:54:06 -0500 Subject: [PATCH 09/29] PromoPanelInput: remove icon label --- app/soapbox/features/soapbox_config/components/icon-picker.tsx | 2 +- app/soapbox/features/soapbox_config/index.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/soapbox/features/soapbox_config/components/icon-picker.tsx b/app/soapbox/features/soapbox_config/components/icon-picker.tsx index bb0c18eff..7fa060b54 100644 --- a/app/soapbox/features/soapbox_config/components/icon-picker.tsx +++ b/app/soapbox/features/soapbox_config/components/icon-picker.tsx @@ -3,7 +3,7 @@ import React from 'react'; import IconPickerDropdown from './icon_picker_dropdown'; interface IIconPicker { - label: React.ReactNode, + label?: React.ReactNode, value: string, onChange: React.ChangeEventHandler, } diff --git a/app/soapbox/features/soapbox_config/index.tsx b/app/soapbox/features/soapbox_config/index.tsx index 676656e5c..673e4f018 100644 --- a/app/soapbox/features/soapbox_config/index.tsx +++ b/app/soapbox/features/soapbox_config/index.tsx @@ -82,7 +82,6 @@ const PromoPanelInput: StreamfieldComponent = ({ value, onChange return ( From 9abe924e92a47ada502a3d19aff21290ff07a054 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 5 May 2022 15:57:30 -0500 Subject: [PATCH 10/29] SoapboxConfig: move PromoPanelInput into its own file --- .../components/promo-panel-input.tsx | 53 +++++++++++++++++++ app/soapbox/features/soapbox_config/index.tsx | 44 +-------------- 2 files changed, 55 insertions(+), 42 deletions(-) create mode 100644 app/soapbox/features/soapbox_config/components/promo-panel-input.tsx diff --git a/app/soapbox/features/soapbox_config/components/promo-panel-input.tsx b/app/soapbox/features/soapbox_config/components/promo-panel-input.tsx new file mode 100644 index 000000000..d48370e7a --- /dev/null +++ b/app/soapbox/features/soapbox_config/components/promo-panel-input.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { useIntl, defineMessages } from 'react-intl'; + +import { HStack, Input } from 'soapbox/components/ui'; +import { StreamfieldComponent } from 'soapbox/components/ui/streamfield/streamfield'; + +import IconPicker from './icon-picker'; + +import type { PromoPanelItem } from 'soapbox/types/soapbox'; + +const messages = defineMessages({ + icon: { id: 'soapbox_config.promo_panel.meta_fields.icon_placeholder', defaultMessage: 'Icon' }, + label: { id: 'soapbox_config.promo_panel.meta_fields.label_placeholder', defaultMessage: 'Label' }, + url: { id: 'soapbox_config.promo_panel.meta_fields.url_placeholder', defaultMessage: 'URL' }, +}); + +const PromoPanelInput: StreamfieldComponent = ({ value, onChange }) => { + const intl = useIntl(); + + const handleIconChange = (icon: any) => { + onChange(value.set('icon', icon.id)); + }; + + const handleChange = (key: 'text' | 'url'): React.ChangeEventHandler => { + return e => { + onChange(value.set(key, e.currentTarget.value)); + }; + }; + + return ( + + + + + + + ); +}; + +export default PromoPanelInput; diff --git a/app/soapbox/features/soapbox_config/index.tsx b/app/soapbox/features/soapbox_config/index.tsx index 673e4f018..c74e69d98 100644 --- a/app/soapbox/features/soapbox_config/index.tsx +++ b/app/soapbox/features/soapbox_config/index.tsx @@ -6,7 +6,7 @@ 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, Form, FormActions, Button, HStack, Input } from 'soapbox/components/ui'; +import { Column, Form, FormActions, Button } from 'soapbox/components/ui'; import Streamfield, { StreamfieldComponent } from 'soapbox/components/ui/streamfield/streamfield'; import { FieldsGroup, @@ -24,19 +24,15 @@ import Accordion from '../ui/components/accordion'; import ColorWithPicker from './components/color-with-picker'; import CryptoAddressInput from './components/crypto-address-input'; -import IconPicker from './components/icon-picker'; +import PromoPanelInput from './components/promo-panel-input'; import SitePreview from './components/site-preview'; import type { ColorChangeHandler, ColorResult } from 'react-color'; -import type { CryptoAddress, PromoPanelItem } from 'soapbox/types/soapbox'; 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' }, cryptoDonatePanelLimitLabel: { id: 'soapbox_config.crypto_donate_panel_limit.meta_fields.limit_placeholder', defaultMessage: 'Number of items to display in the crypto homepage widget' }, @@ -66,42 +62,6 @@ const templates: Record = { cryptoAddress: ImmutableMap({ ticker: '', address: '', note: '' }), }; -const PromoPanelInput: StreamfieldComponent = ({ value, onChange }) => { - const intl = useIntl(); - - const handleIconChange = (icon: any) => { - onChange(value.set('icon', icon.id)); - }; - - const handleChange = (key: 'text' | 'url'): React.ChangeEventHandler => { - return e => { - onChange(value.set(key, e.currentTarget.value)); - }; - }; - - return ( - - - - - - - ); -}; - const SoapboxConfig: React.FC = () => { const intl = useIntl(); const dispatch = useAppDispatch(); From b401af2950c891170110317cfcd35f75a75591c8 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 5 May 2022 15:59:36 -0500 Subject: [PATCH 11/29] Streamfield: don't render values container unless it has values --- .../components/ui/streamfield/streamfield.tsx | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/app/soapbox/components/ui/streamfield/streamfield.tsx b/app/soapbox/components/ui/streamfield/streamfield.tsx index 48e8d43ba..902ae2873 100644 --- a/app/soapbox/components/ui/streamfield/streamfield.tsx +++ b/app/soapbox/components/ui/streamfield/streamfield.tsx @@ -65,22 +65,24 @@ const Streamfield: React.FC = ({ {hint && {hint}} - - {values.map((value, i) => ( - - - {onRemoveItem && ( - onRemoveItem(i)} - title={intl.formatMessage(messages.remove)} - /> - )} - - ))} - + {(values.length > 0) && ( + + {values.map((value, i) => ( + + + {onRemoveItem && ( + onRemoveItem(i)} + title={intl.formatMessage(messages.remove)} + /> + )} + + ))} + + )} {onAddItem && (
- - + e.target.value)} /> - + + { onChange={handleChange(['singleUserMode'], (e) => e.target.checked)} /> {soapbox.get('singleUserMode') && ( - e.target.value)} - /> + + e.target.value)} + /> + )} @@ -303,18 +303,17 @@ const SoapboxConfig: React.FC = () => { onRemoveItem={deleteStreamItem(['cryptoAddresses'])} /> - - + Number(e.target.value))} /> - + + Date: Thu, 5 May 2022 16:56:34 -0500 Subject: [PATCH 15/29] SoapboxConfig: improve layout --- app/soapbox/features/edit_profile/index.tsx | 8 +- app/soapbox/features/soapbox_config/index.tsx | 79 ++++++++++--------- app/styles/forms.scss | 4 - 3 files changed, 45 insertions(+), 46 deletions(-) diff --git a/app/soapbox/features/edit_profile/index.tsx b/app/soapbox/features/edit_profile/index.tsx index e7c607a6a..647bb98e3 100644 --- a/app/soapbox/features/edit_profile/index.tsx +++ b/app/soapbox/features/edit_profile/index.tsx @@ -11,7 +11,9 @@ import { useAppSelector, useAppDispatch, useOwnAccount, useFeatures } from 'soap import { normalizeAccount } from 'soapbox/normalizers'; import resizeImage from 'soapbox/utils/resize_image'; -import { Button, Column, Form, FormActions, FormGroup, Input, Textarea, HStack } from '../../components/ui'; +import { Button, Column, Form, FormActions, FormGroup, Input, Textarea } from '../../components/ui'; +import HStack from '../../components/ui/hstack/hstack'; +import Stack from '../../components/ui/stack/stack'; import Streamfield, { StreamfieldComponent } from '../../components/ui/streamfield/streamfield'; import ProfilePreview from './components/profile-preview'; @@ -394,7 +396,7 @@ const EditProfile: React.FC = () => { {/* HACK: wrap these checkboxes in a .simple_form container so they get styled (for now) */} {/* Need a either move, replace, or refactor these checkboxes. */} -
+ {features.followRequests && ( } @@ -448,7 +450,7 @@ const EditProfile: React.FC = () => { onChange={handleCheckboxChange('accepts_email_list')} /> )} -
+ {features.profileFields && ( { 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} - /> */} -
+ + + + } + 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={
-
- + + + + } + name='logo' + hint={ + { /> - + { /> )} - + } diff --git a/app/styles/forms.scss b/app/styles/forms.scss index 6c82a4739..901853323 100644 --- a/app/styles/forms.scss +++ b/app/styles/forms.scss @@ -36,10 +36,6 @@ code { .simple_form { .input { - + .input { - margin-top: 20px; - } - &.hidden { margin: 0; } From bec8f0fffe882c40616741926d4c9eb9773b7cf9 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 5 May 2022 17:07:54 -0500 Subject: [PATCH 16/29] Restore icon picker styles --- app/styles/emoji_picker.scss | 37 +++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/app/styles/emoji_picker.scss b/app/styles/emoji_picker.scss index f1a3ea40d..7bef34d27 100644 --- a/app/styles/emoji_picker.scss +++ b/app/styles/emoji_picker.scss @@ -176,7 +176,7 @@ } .emoji-mart-emoji-native { - font-family: "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji"; + font-family: "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji", sans-serif; } .emoji-mart-no-results { @@ -260,3 +260,38 @@ height: 22px; } } + +.font-icon-picker { + .emoji-mart-search { + // Search doesn't work. Hide it for now. + display: none; + padding: 10px !important; + } + + .emoji-mart-category-label > span { + padding: 9px 6px 5px; + } + + .emoji-mart-scroll { + border-radius: 4px; + } + + .emoji-mart-search-icon { + right: 18px; + } + + .emoji-mart-bar { + display: none; + } + + .fa { + font-size: 18px; + width: 22px; + height: 22px; + text-align: center; + } + + .fa-hack { + margin: 0 auto; + } +} From 65beeedb5944ef082a45711e0ce81ca1dc034f8d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 5 May 2022 17:29:24 -0500 Subject: [PATCH 17/29] SoapboxConfig: improve style of IconPicker --- .../soapbox_config/components/icon-picker.tsx | 12 +++------- .../components/icon_picker_dropdown.js | 14 ++++++++++-- .../components/promo-panel-input.tsx | 2 +- app/styles/forms.scss | 22 ------------------- 4 files changed, 16 insertions(+), 34 deletions(-) diff --git a/app/soapbox/features/soapbox_config/components/icon-picker.tsx b/app/soapbox/features/soapbox_config/components/icon-picker.tsx index 7fa060b54..2531eca0a 100644 --- a/app/soapbox/features/soapbox_config/components/icon-picker.tsx +++ b/app/soapbox/features/soapbox_config/components/icon-picker.tsx @@ -3,20 +3,14 @@ import React from 'react'; import IconPickerDropdown from './icon_picker_dropdown'; interface IIconPicker { - label?: React.ReactNode, value: string, onChange: React.ChangeEventHandler, } -const IconPicker: React.FC = ({ onChange, value, label }) => { +const IconPicker: React.FC = ({ value, onChange }) => { return ( -
-
- {label && ()} -
- -
-
+
+
); }; diff --git a/app/soapbox/features/soapbox_config/components/icon_picker_dropdown.js b/app/soapbox/features/soapbox_config/components/icon_picker_dropdown.js index 56258f5ad..8c2bdf473 100644 --- a/app/soapbox/features/soapbox_config/components/icon_picker_dropdown.js +++ b/app/soapbox/features/soapbox_config/components/icon_picker_dropdown.js @@ -215,8 +215,18 @@ class IconPickerDropdown extends React.PureComponent { const forkAwesomeIcons = require('../forkawesome.json'); return ( -
-
+
+
diff --git a/app/soapbox/features/soapbox_config/components/promo-panel-input.tsx b/app/soapbox/features/soapbox_config/components/promo-panel-input.tsx index d48370e7a..3bda1f1d8 100644 --- a/app/soapbox/features/soapbox_config/components/promo-panel-input.tsx +++ b/app/soapbox/features/soapbox_config/components/promo-panel-input.tsx @@ -28,7 +28,7 @@ const PromoPanelInput: StreamfieldComponent = ({ value, onChange }; return ( - + Date: Thu, 5 May 2022 17:37:25 -0500 Subject: [PATCH 18/29] SoapboxConfig: improve ColorPicker styles --- .../features/soapbox_config/components/color-with-picker.tsx | 4 ++-- app/soapbox/features/soapbox_config/index.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/soapbox/features/soapbox_config/components/color-with-picker.tsx b/app/soapbox/features/soapbox_config/components/color-with-picker.tsx index 6178140e6..711883807 100644 --- a/app/soapbox/features/soapbox_config/components/color-with-picker.tsx +++ b/app/soapbox/features/soapbox_config/components/color-with-picker.tsx @@ -38,13 +38,13 @@ const ColorWithPicker: React.FC = ({ buttonId, label, value, o }; return ( -
+