diff --git a/.env.example b/.env.example index eade709c69..ea00adf9d9 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,6 @@ MAILGUN_KEY= ML5_LIBRARY_USERNAME=ml5 ML5_LIBRARY_EMAIL=examples@ml5js.org ML5_LIBRARY_PASS=helloml5 -MOBILE_ENABLED=true MONGO_URL=mongodb://localhost:27017/p5js-web-editor PORT=8000 PREVIEW_PORT=8002 diff --git a/README.md b/README.md index 9a60a21a4c..75345a98e3 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ If you have found a bug in the p5.js Web Editor, you can file it under the ["iss ### How Do I Know My Issue or Pull Request is Getting Reviewed? -To see which pull requests and issues are currently being reviewed, check the [PR Review Board](https://github.com/processing/p5.js-web-editor/projects/9) or the following Milestones: [PATCH Release](https://github.com/processing/p5.js-web-editor/milestone/9), [MINOR Release](https://github.com/processing/p5.js-web-editor/milestone/8). +To see which pull requests and issues are currently being reviewed, check the [PR Review Board](https://github.com/processing/p5.js-web-editor/projects/9) or the following Milestones: [MINOR Release](https://github.com/processing/p5.js-web-editor/milestone/8). Issues and Pull Requests categorized under the PATCH or MINOR Release Milestones will be prioritized since they are planned to be merged for the next release to Production. Please feel free to [comment on this pinned issue](https://github.com/processing/p5.js-web-editor/issues/2534) if you would like your issue to be considered for the next release! @@ -38,11 +38,7 @@ Issues and Pull Requests categorized under the PATCH or MINOR Release Milestones We will aim to deploy on a 1-2 month basis. Here are some dates we’re working towards: -MINOR Release for [p5.js version 1.8.0](https://github.com/processing/p5.js/releases/tag/v1.8.0): By October 27, 2023 - -PATCH Release: By November 2, 2023 - -MINOR Release: By November 30, 2023 +2.11.0 MINOR Release: By January 16, 2023 [You can read more about Semantic Versioning and the differences between a MINOR and PATCH release](https://semver.org/). diff --git a/client/common/Button.jsx b/client/common/Button.jsx index d6dd18491e..516536fb2b 100644 --- a/client/common/Button.jsx +++ b/client/common/Button.jsx @@ -21,7 +21,8 @@ const displays = { const StyledButton = styled.button` &&& { font-weight: bold; - display: flex; + display: ${({ display }) => + display === displays.inline ? 'inline-flex' : 'flex'}; justify-content: center; align-items: center; @@ -107,57 +108,6 @@ const StyledInlineButton = styled.button` } `; -const StyledIconButton = styled.button` - &&& { - display: flex; - justify-content: center; - align-items: center; - - width: ${remSize(32)}px; - height: ${remSize(32)}px; - text-decoration: none; - - color: ${({ kind }) => prop(`Button.${kind}.default.foreground`)}; - background-color: ${({ kind }) => prop(`Button.${kind}.hover.background`)}; - cursor: pointer; - border: 1px solid transparent; - border-radius: 50%; - padding: ${remSize(8)} ${remSize(25)}; - line-height: 1; - - &:hover:not(:disabled) { - color: ${({ kind }) => prop(`Button.${kind}.hover.foreground`)}; - background-color: ${({ kind }) => - prop(`Button.${kind}.hover.background`)}; - - svg * { - fill: ${({ kind }) => prop(`Button.${kind}.hover.foreground`)}; - } - } - - &:active:not(:disabled) { - color: ${({ kind }) => prop(`Button.${kind}.active.foreground`)}; - background-color: ${({ kind }) => - prop(`Button.${kind}.active.background`)}; - - svg * { - fill: ${({ kind }) => prop(`Button.${kind}.active.foreground`)}; - } - } - - &:disabled { - color: ${({ kind }) => prop(`Button.${kind}.disabled.foreground`)}; - background-color: ${({ kind }) => - prop(`Button.${kind}.disabled.background`)}; - cursor: not-allowed; - } - - > * + * { - margin-left: ${remSize(8)}; - } - } -`; - /** * A Button performs an primary action */ @@ -184,12 +134,8 @@ const Button = ({ ); let StyledComponent = StyledButton; - if (display === displays.inline) { - StyledComponent = StyledInlineButton; - } - if (iconOnly) { - StyledComponent = StyledIconButton; + StyledComponent = StyledInlineButton; } if (href) { @@ -265,7 +211,7 @@ Button.propTypes = { /** * The display type of the button—inline or block */ - display: PropTypes.string, + display: PropTypes.oneOf(Object.values(displays)), /** * SVG icon to place after child content */ @@ -286,7 +232,7 @@ Button.propTypes = { * Specifying an href will use an to link to the URL */ href: PropTypes.string, - /* + /** * An ARIA Label used for accessibility */ 'aria-label': PropTypes.string, diff --git a/client/components/mobile/IconButton.jsx b/client/common/IconButton.jsx similarity index 88% rename from client/components/mobile/IconButton.jsx rename to client/common/IconButton.jsx index f7b737ebbe..8cf732f91d 100644 --- a/client/components/mobile/IconButton.jsx +++ b/client/common/IconButton.jsx @@ -1,8 +1,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import styled from 'styled-components'; -import Button from '../../common/Button'; -import { remSize } from '../../theme'; +import Button from './Button'; +import { remSize } from '../theme'; const ButtonWrapper = styled(Button)` width: ${remSize(48)}; @@ -19,6 +19,7 @@ const IconButton = (props) => { return ( } + iconOnly display={Button.displays.inline} focusable="false" {...otherProps} diff --git a/client/common/RouterTab.jsx b/client/common/RouterTab.jsx new file mode 100644 index 0000000000..d08c839855 --- /dev/null +++ b/client/common/RouterTab.jsx @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { NavLink } from 'react-router-dom'; + +/** + * Wraps the react-router `NavLink` with dashboard-header__tab styling. + */ +const Tab = ({ children, to }) => ( +
  • + + {children} + +
  • +); + +Tab.propTypes = { + children: PropTypes.string.isRequired, + to: PropTypes.string.isRequired +}; + +export default Tab; diff --git a/client/modules/IDE/hooks/useKeyDownHandlers.js b/client/common/useKeyDownHandlers.js similarity index 98% rename from client/modules/IDE/hooks/useKeyDownHandlers.js rename to client/common/useKeyDownHandlers.js index 3f5c1941ad..ce57ab5925 100644 --- a/client/modules/IDE/hooks/useKeyDownHandlers.js +++ b/client/common/useKeyDownHandlers.js @@ -1,4 +1,4 @@ -import mapKeys from 'lodash/mapKeys'; +import { mapKeys } from 'lodash'; import PropTypes from 'prop-types'; import { useCallback, useEffect, useRef } from 'react'; diff --git a/client/common/useModalClose.js b/client/common/useModalClose.js new file mode 100644 index 0000000000..2bab24b5de --- /dev/null +++ b/client/common/useModalClose.js @@ -0,0 +1,45 @@ +import { useEffect, useRef } from 'react'; +import useKeyDownHandlers from './useKeyDownHandlers'; + +/** + * Common logic for Modal, Overlay, etc. + * + * Pass in the `onClose` handler. + * + * Can optionally pass in a ref, in case the `onClose` function needs to use the ref. + * + * Calls the provided `onClose` function on: + * - Press Escape key. + * - Click outside the element. + * + * Returns a ref to attach to the outermost element of the modal. + * + * @param {() => void} onClose + * @param {React.MutableRefObject} [passedRef] + * @return {React.MutableRefObject} + */ +export default function useModalClose(onClose, passedRef) { + const createdRef = useRef(null); + const modalRef = passedRef || createdRef; + + useEffect(() => { + modalRef.current?.focus(); + + function handleClick(e) { + // ignore clicks on the component itself + if (modalRef.current && !modalRef.current.contains(e.target)) { + onClose?.(); + } + } + + document.addEventListener('click', handleClick, false); + + return () => { + document.removeEventListener('click', handleClick, false); + }; + }, [onClose, modalRef]); + + useKeyDownHandlers({ escape: onClose }); + + return modalRef; +} diff --git a/client/components/Dropdown.jsx b/client/components/Dropdown.jsx index 9a91d54cd2..c04f961d75 100644 --- a/client/components/Dropdown.jsx +++ b/client/components/Dropdown.jsx @@ -2,9 +2,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import styled from 'styled-components'; import { remSize, prop } from '../theme'; -import IconButton from './mobile/IconButton'; +import IconButton from '../common/IconButton'; -const DropdownWrapper = styled.ul` +export const DropdownWrapper = styled.ul` background-color: ${prop('Modal.background')}; border: 1px solid ${prop('Modal.border')}; box-shadow: 0 0 18px 0 ${prop('shadowColor')}; @@ -52,6 +52,7 @@ const DropdownWrapper = styled.ul` & button span, & a { padding: ${remSize(8)} ${remSize(16)}; + font-size: ${remSize(12)}; } * { diff --git a/client/components/Dropdown/DropdownMenu.jsx b/client/components/Dropdown/DropdownMenu.jsx new file mode 100644 index 0000000000..da41b30101 --- /dev/null +++ b/client/components/Dropdown/DropdownMenu.jsx @@ -0,0 +1,96 @@ +import PropTypes from 'prop-types'; +import React, { forwardRef, useCallback, useRef, useState } from 'react'; +import useModalClose from '../../common/useModalClose'; +import DownArrowIcon from '../../images/down-filled-triangle.svg'; +import { DropdownWrapper } from '../Dropdown'; + +// TODO: enable arrow keys to navigate options from list + +const DropdownMenu = forwardRef( + ( + { children, anchor, 'aria-label': ariaLabel, align, className, classes }, + ref + ) => { + // Note: need to use a ref instead of a state to avoid stale closures. + const focusedRef = useRef(false); + + const [isOpen, setIsOpen] = useState(false); + + const close = useCallback(() => setIsOpen(false), [setIsOpen]); + + const anchorRef = useModalClose(close, ref); + + const toggle = useCallback(() => { + setIsOpen((prevState) => !prevState); + }, [setIsOpen]); + + const handleFocus = () => { + focusedRef.current = true; + }; + + const handleBlur = () => { + focusedRef.current = false; + setTimeout(() => { + if (!focusedRef.current) { + close(); + } + }, 200); + }; + + return ( +
    + + {isOpen && ( + { + setTimeout(close, 0); + }} + onBlur={handleBlur} + onFocus={handleFocus} + > + {children} + + )} +
    + ); + } +); + +DropdownMenu.propTypes = { + /** + * Provide elements as children to control the contents of the menu. + */ + children: PropTypes.node.isRequired, + /** + * Can optionally override the contents of the button which opens the menu. + * Defaults to + */ + anchor: PropTypes.node, + 'aria-label': PropTypes.string.isRequired, + align: PropTypes.oneOf(['left', 'right']), + className: PropTypes.string, + classes: PropTypes.shape({ + button: PropTypes.string, + list: PropTypes.string + }) +}; + +DropdownMenu.defaultProps = { + anchor: null, + align: 'right', + className: '', + classes: {} +}; + +export default DropdownMenu; diff --git a/client/components/Dropdown/MenuItem.jsx b/client/components/Dropdown/MenuItem.jsx new file mode 100644 index 0000000000..8b6f6d7247 --- /dev/null +++ b/client/components/Dropdown/MenuItem.jsx @@ -0,0 +1,35 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import ButtonOrLink from '../../common/ButtonOrLink'; + +// TODO: combine with NavMenuItem + +function MenuItem({ hideIf, ...rest }) { + if (hideIf) { + return null; + } + + return ( +
  • + +
  • + ); +} + +MenuItem.propTypes = { + ...ButtonOrLink.propTypes, + onClick: PropTypes.func, + value: PropTypes.string, + /** + * Provides a way to deal with optional items. + */ + hideIf: PropTypes.bool +}; + +MenuItem.defaultProps = { + onClick: null, + value: null, + hideIf: false +}; + +export default MenuItem; diff --git a/client/components/Dropdown/TableDropdown.jsx b/client/components/Dropdown/TableDropdown.jsx new file mode 100644 index 0000000000..d4db78f963 --- /dev/null +++ b/client/components/Dropdown/TableDropdown.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { useMediaQuery } from 'react-responsive'; +import styled from 'styled-components'; +import { prop, remSize } from '../../theme'; +import DropdownMenu from './DropdownMenu'; + +import DownFilledTriangleIcon from '../../images/down-filled-triangle.svg'; +import MoreIconSvg from '../../images/more.svg'; + +const DotsHorizontal = styled(MoreIconSvg)` + transform: rotate(90deg); +`; + +const TableDropdownIcon = () => { + // TODO: centralize breakpoints + const isMobile = useMediaQuery({ maxWidth: 770 }); + + return isMobile ? ( +
    -
    -
    { - this.node = node; - }} - className="overlay__body" - > -
    -

    {title}

    -
    - {actions} - -
    -
    - {children} - this.close() }} /> -
    -
    + return ( +
    +
    +
    +
    +

    {title}

    +
    + {actions} + +
    +
    + {children} +
    - ); - } -} +
    + ); +}; Overlay.propTypes = { children: PropTypes.element, @@ -94,9 +77,7 @@ Overlay.propTypes = { closeOverlay: PropTypes.func, title: PropTypes.string, ariaLabel: PropTypes.string, - previousPath: PropTypes.string, - isFixedHeight: PropTypes.bool, - t: PropTypes.func.isRequired + isFixedHeight: PropTypes.bool }; Overlay.defaultProps = { @@ -105,8 +86,7 @@ Overlay.defaultProps = { title: 'Modal', closeOverlay: null, ariaLabel: 'modal', - previousPath: '/', isFixedHeight: false }; -export default withTranslation()(Overlay); +export default Overlay; diff --git a/client/modules/IDE/actions/assets.js b/client/modules/IDE/actions/assets.js index 0a7a13e3a0..66e03e835c 100644 --- a/client/modules/IDE/actions/assets.js +++ b/client/modules/IDE/actions/assets.js @@ -1,14 +1,9 @@ import apiClient from '../../../utils/apiClient'; import * as ActionTypes from '../../../constants'; import { startLoader, stopLoader } from './loader'; +import { assetsActions } from '../reducers/assets'; -function setAssets(assets, totalSize) { - return { - type: ActionTypes.SET_ASSETS, - assets, - totalSize - }; -} +const { setAssets, deleteAsset } = assetsActions; export function getAssets() { return async (dispatch) => { @@ -26,13 +21,6 @@ export function getAssets() { }; } -export function deleteAsset(assetKey) { - return { - type: ActionTypes.DELETE_ASSET, - key: assetKey - }; -} - export function deleteAssetRequest(assetKey) { return async (dispatch) => { try { diff --git a/client/modules/IDE/actions/collections.js b/client/modules/IDE/actions/collections.js index 5a9218520b..e8bda9623f 100644 --- a/client/modules/IDE/actions/collections.js +++ b/client/modules/IDE/actions/collections.js @@ -6,7 +6,6 @@ import { setToastText, showToast } from './toast'; const TOAST_DISPLAY_TIME_MS = 1500; -// eslint-disable-next-line export function getCollections(username) { return (dispatch) => { dispatch(startLoader()); @@ -16,8 +15,7 @@ export function getCollections(username) { } else { url = '/collections'; } - console.log(url); - apiClient + return apiClient .get(url) .then((response) => { dispatch({ @@ -27,10 +25,9 @@ export function getCollections(username) { dispatch(stopLoader()); }) .catch((error) => { - const { response } = error; dispatch({ type: ActionTypes.ERROR, - error: response.data + error: error?.response?.data }); dispatch(stopLoader()); }); @@ -59,11 +56,9 @@ export function createCollection(collection) { browserHistory.push(location); }) .catch((error) => { - const { response } = error; - console.error('Error creating collection', response.data); dispatch({ type: ActionTypes.ERROR, - error: response.data + error: error?.response?.data }); dispatch(stopLoader()); }); @@ -91,14 +86,11 @@ export function addToCollection(collectionId, projectId) { return response.data; }) .catch((error) => { - const { response } = error; dispatch({ type: ActionTypes.ERROR, - error: response.data + error: error?.response?.data }); dispatch(stopLoader()); - - return response.data; }); }; } @@ -124,14 +116,11 @@ export function removeFromCollection(collectionId, projectId) { return response.data; }) .catch((error) => { - const { response } = error; dispatch({ type: ActionTypes.ERROR, - error: response.data + error: error?.response?.data }); dispatch(stopLoader()); - - return response.data; }); }; } @@ -149,13 +138,10 @@ export function editCollection(collectionId, { name, description }) { return response.data; }) .catch((error) => { - const { response } = error; dispatch({ type: ActionTypes.ERROR, - error: response.data + error: error?.response?.data }); - - return response.data; }); }; } @@ -174,13 +160,10 @@ export function deleteCollection(collectionId) { return response.data; }) .catch((error) => { - const { response } = error; dispatch({ type: ActionTypes.ERROR, - error: response.data + error: error?.response?.data }); - - return response.data; }); }; } diff --git a/client/modules/IDE/actions/preferences.js b/client/modules/IDE/actions/preferences.js index 700f676bdd..e0473bd995 100644 --- a/client/modules/IDE/actions/preferences.js +++ b/client/modules/IDE/actions/preferences.js @@ -7,10 +7,9 @@ function updatePreferences(formParams, dispatch) { .put('/preferences', formParams) .then(() => {}) .catch((error) => { - const { response } = error; dispatch({ type: ActionTypes.ERROR, - error: response.data + error: error?.response?.data }); }); } diff --git a/client/modules/IDE/actions/project.js b/client/modules/IDE/actions/project.js index 3c2aca691a..1d8943336b 100644 --- a/client/modules/IDE/actions/project.js +++ b/client/modules/IDE/actions/project.js @@ -1,6 +1,6 @@ import objectID from 'bson-objectid'; import each from 'async/each'; -import isEqual from 'lodash/isEqual'; +import { isEqual } from 'lodash'; import browserHistory from '../../../browserHistory'; import apiClient from '../../../utils/apiClient'; import getConfig from '../../../utils/getConfig'; @@ -61,10 +61,9 @@ export function getProject(id, username) { dispatch(setUnsavedChanges(false)); }) .catch((error) => { - const { response } = error; dispatch({ type: ActionTypes.ERROR, - error: response.data + error: error?.response?.data }); }); }; @@ -270,9 +269,7 @@ export function resetProject() { } export function newProject() { - setTimeout(() => { - browserHistory.push('/'); - }, 0); + browserHistory.push('/', { confirmed: true }); return resetProject(); } @@ -340,10 +337,9 @@ export function cloneProject(project) { dispatch(setNewProject(response.data)); }) .catch((error) => { - const { response } = error; dispatch({ type: ActionTypes.PROJECT_SAVE_FAIL, - error: response.data + error: error?.response?.data }); }); } @@ -378,10 +374,9 @@ export function changeProjectName(id, newName) { } }) .catch((error) => { - const { response } = error; dispatch({ type: ActionTypes.PROJECT_SAVE_FAIL, - error: response.data + error: error?.response?.data }); }); }; diff --git a/client/modules/IDE/actions/projects.js b/client/modules/IDE/actions/projects.js index 4072429af4..eb9984cf54 100644 --- a/client/modules/IDE/actions/projects.js +++ b/client/modules/IDE/actions/projects.js @@ -22,10 +22,9 @@ export function getProjects(username) { dispatch(stopLoader()); }) .catch((error) => { - const { response } = error; dispatch({ type: ActionTypes.ERROR, - error: response.data + error: error?.response?.data }); dispatch(stopLoader()); }); diff --git a/client/modules/IDE/components/AddToCollectionList.jsx b/client/modules/IDE/components/AddToCollectionList.jsx index fc5c161fdc..ecb020ce6f 100644 --- a/client/modules/IDE/components/AddToCollectionList.jsx +++ b/client/modules/IDE/components/AddToCollectionList.jsx @@ -1,23 +1,19 @@ import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Helmet } from 'react-helmet'; -import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; -import { withTranslation } from 'react-i18next'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; import styled from 'styled-components'; -import * as ProjectActions from '../actions/project'; -import * as ProjectsActions from '../actions/projects'; -import * as CollectionsActions from '../actions/collections'; -import * as ToastActions from '../actions/toast'; -import * as SortingActions from '../actions/sorting'; -import getSortedCollections from '../selectors/collections'; import Loader from '../../App/components/loader'; +import { + addToCollection, + getCollections, + removeFromCollection +} from '../actions/collections'; +import getSortedCollections from '../selectors/collections'; import QuickAddList from './QuickAddList'; import { remSize } from '../../../theme'; -const projectInCollection = (project, collection) => - collection.items.find((item) => item.projectId === project.id) != null; - export const CollectionAddSketchWrapper = styled.div` width: ${remSize(600)}; max-width: 100%; @@ -31,166 +27,67 @@ export const QuickAddWrapper = styled.div` height: 100%; `; -class CollectionList extends React.Component { - constructor(props) { - super(props); +const AddToCollectionList = ({ projectId }) => { + const { t } = useTranslation(); - if (props.projectId) { - props.getProject(props.projectId); - } + const dispatch = useDispatch(); - this.props.getCollections(this.props.username); + const username = useSelector((state) => state.user.username); - this.state = { - hasLoadedData: false - }; - } + const collections = useSelector(getSortedCollections); - componentDidUpdate(prevProps) { - if (prevProps.loading === true && this.props.loading === false) { - // eslint-disable-next-line react/no-did-update-set-state - this.setState({ - hasLoadedData: true - }); - } - } + // TODO: improve loading state + const loading = useSelector((state) => state.loading); + const [hasLoadedData, setHasLoadedData] = useState(false); + const showLoader = loading && !hasLoadedData; - getTitle() { - if (this.props.username === this.props.user.username) { - return this.props.t('AddToCollectionList.Title'); - } - return this.props.t('AddToCollectionList.AnothersTitle', { - anotheruser: this.props.username - }); - } + useEffect(() => { + dispatch(getCollections(username)).then(() => setHasLoadedData(true)); + }, [dispatch, username]); - handleCollectionAdd = (collection) => { - this.props.addToCollection(collection.id, this.props.project.id); + const handleCollectionAdd = (collection) => { + dispatch(addToCollection(collection.id, projectId)); }; - handleCollectionRemove = (collection) => { - this.props.removeFromCollection(collection.id, this.props.project.id); + const handleCollectionRemove = (collection) => { + dispatch(removeFromCollection(collection.id, projectId)); }; - render() { - const { collections, project } = this.props; - const hasCollections = collections.length > 0; - const collectionWithSketchStatus = collections.map((collection) => ({ - ...collection, - url: `/${collection.owner.username}/collections/${collection.id}`, - isAdded: projectInCollection(project, collection) - })); - - let content = null; - - if (this.props.loading && !this.state.hasLoadedData) { - content = ; - } else if (hasCollections) { - content = ( - - ); - } else { - content = this.props.t('AddToCollectionList.Empty'); + const collectionWithSketchStatus = collections.map((collection) => ({ + ...collection, + url: `/${collection.owner.username}/collections/${collection.id}`, + isAdded: collection.items.some((item) => item.projectId === projectId) + })); + + const getContent = () => { + if (showLoader) { + return ; + } else if (collections.length === 0) { + return t('AddToCollectionList.Empty'); } - return ( - - - - {this.getTitle()} - - {content} - - + ); - } -} - -const ProjectShape = PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - createdAt: PropTypes.string.isRequired, - updatedAt: PropTypes.string.isRequired, - user: PropTypes.shape({ - username: PropTypes.string.isRequired - }).isRequired -}); - -const ItemsShape = PropTypes.shape({ - createdAt: PropTypes.string.isRequired, - updatedAt: PropTypes.string.isRequired, - project: ProjectShape -}); + }; -CollectionList.propTypes = { - user: PropTypes.shape({ - username: PropTypes.string, - authenticated: PropTypes.bool.isRequired - }).isRequired, - projectId: PropTypes.string.isRequired, - getCollections: PropTypes.func.isRequired, - getProject: PropTypes.func.isRequired, - addToCollection: PropTypes.func.isRequired, - removeFromCollection: PropTypes.func.isRequired, - collections: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - description: PropTypes.string, - createdAt: PropTypes.string.isRequired, - updatedAt: PropTypes.string.isRequired, - items: PropTypes.arrayOf(ItemsShape) - }) - ).isRequired, - username: PropTypes.string, - loading: PropTypes.bool.isRequired, - project: PropTypes.shape({ - id: PropTypes.string, - owner: PropTypes.shape({ - id: PropTypes.string - }) - }), - t: PropTypes.func.isRequired + return ( + + + + {t('AddToCollectionList.Title')} + + {getContent()} + + + ); }; -CollectionList.defaultProps = { - project: { - id: undefined, - owner: undefined - }, - username: undefined +AddToCollectionList.propTypes = { + projectId: PropTypes.string.isRequired }; -function mapStateToProps(state, ownProps) { - return { - user: state.user, - collections: getSortedCollections(state), - sorting: state.sorting, - loading: state.loading, - project: ownProps.project || state.project, - projectId: ownProps && ownProps.params ? ownProps.prams.project_id : null - }; -} - -function mapDispatchToProps(dispatch) { - return bindActionCreators( - Object.assign( - {}, - CollectionsActions, - ProjectsActions, - ProjectActions, - ToastActions, - SortingActions - ), - dispatch - ); -} - -export default withTranslation()( - connect(mapStateToProps, mapDispatchToProps)(CollectionList) -); +export default AddToCollectionList; diff --git a/client/modules/IDE/components/AddToCollectionSketchList.jsx b/client/modules/IDE/components/AddToCollectionSketchList.jsx index f6cdf3abbf..eb70c2ed71 100644 --- a/client/modules/IDE/components/AddToCollectionSketchList.jsx +++ b/client/modules/IDE/components/AddToCollectionSketchList.jsx @@ -4,7 +4,7 @@ import { Helmet } from 'react-helmet'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import { withTranslation } from 'react-i18next'; -// import find from 'lodash/find'; +// import { find } from 'lodash'; import * as ProjectsActions from '../actions/projects'; import * as CollectionsActions from '../actions/collections'; import * as ToastActions from '../actions/toast'; diff --git a/client/modules/IDE/components/AssetList.jsx b/client/modules/IDE/components/AssetList.jsx index 559f60c580..720f21734b 100644 --- a/client/modules/IDE/components/AssetList.jsx +++ b/client/modules/IDE/components/AssetList.jsx @@ -1,129 +1,68 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { connect } from 'react-redux'; +import { connect, useDispatch } from 'react-redux'; import { bindActionCreators } from 'redux'; import { Link } from 'react-router-dom'; import { Helmet } from 'react-helmet'; import prettyBytes from 'pretty-bytes'; -import { withTranslation } from 'react-i18next'; +import { useTranslation, withTranslation } from 'react-i18next'; +import MenuItem from '../../../components/Dropdown/MenuItem'; +import TableDropdown from '../../../components/Dropdown/TableDropdown'; import Loader from '../../App/components/loader'; +import { deleteAssetRequest } from '../actions/assets'; import * as AssetActions from '../actions/assets'; -import DownFilledTriangleIcon from '../../../images/down-filled-triangle.svg'; -class AssetListRowBase extends React.Component { - constructor(props) { - super(props); - this.state = { - isFocused: false, - optionsOpen: false - }; - } - - onFocusComponent = () => { - this.setState({ isFocused: true }); - }; - - onBlurComponent = () => { - this.setState({ isFocused: false }); - setTimeout(() => { - if (!this.state.isFocused) { - this.closeOptions(); - } - }, 200); - }; - - openOptions = () => { - this.setState({ - optionsOpen: true - }); - }; +const AssetMenu = ({ item: asset }) => { + const { t } = useTranslation(); - closeOptions = () => { - this.setState({ - optionsOpen: false - }); - }; + const dispatch = useDispatch(); - toggleOptions = () => { - if (this.state.optionsOpen) { - this.closeOptions(); - } else { - this.openOptions(); + const handleAssetDelete = () => { + const { key, name } = asset; + if (window.confirm(t('Common.DeleteConfirmation', { name }))) { + dispatch(deleteAssetRequest(key)); } }; - handleDropdownOpen = () => { - this.closeOptions(); - this.openOptions(); - }; + return ( + + {t('AssetList.Delete')} + + {t('AssetList.OpenNewTab')} + + + ); +}; - handleAssetDelete = () => { - const { key, name } = this.props.asset; - this.closeOptions(); - if (window.confirm(this.props.t('Common.DeleteConfirmation', { name }))) { - this.props.deleteAssetRequest(key); - } - }; +AssetMenu.propTypes = { + item: PropTypes.shape({ + key: PropTypes.string.isRequired, + url: PropTypes.string.isRequired, + name: PropTypes.string.isRequired + }).isRequired +}; - render() { - const { asset, username, t } = this.props; - const { optionsOpen } = this.state; - return ( - - - - {asset.name} - - - {prettyBytes(asset.size)} - - {asset.sketchId && ( - - {asset.sketchName} - - )} - - - - {optionsOpen && ( -
      -
    • - -
    • -
    • - - {t('AssetList.OpenNewTab')} - -
    • -
    - )} - - - ); - } -} +const AssetListRowBase = ({ asset, username }) => ( + + +
    + {asset.name} + + + {prettyBytes(asset.size)} + + {asset.sketchId && ( + + {asset.sketchName} + + )} + + + + + +); AssetListRowBase.propTypes = { asset: PropTypes.shape({ @@ -134,9 +73,7 @@ AssetListRowBase.propTypes = { name: PropTypes.string.isRequired, size: PropTypes.number.isRequired }).isRequired, - deleteAssetRequest: PropTypes.func.isRequired, - username: PropTypes.string.isRequired, - t: PropTypes.func.isRequired + username: PropTypes.string.isRequired }; function mapStateToPropsAssetListRow(state) { diff --git a/client/modules/IDE/components/CollectionList/CollectionList.jsx b/client/modules/IDE/components/CollectionList/CollectionList.jsx index 646b9824b5..e3c910881e 100644 --- a/client/modules/IDE/components/CollectionList/CollectionList.jsx +++ b/client/modules/IDE/components/CollectionList/CollectionList.jsx @@ -5,7 +5,7 @@ import { withTranslation } from 'react-i18next'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import classNames from 'classnames'; -import find from 'lodash/find'; +import { find } from 'lodash'; import * as ProjectActions from '../../actions/project'; import * as ProjectsActions from '../../actions/projects'; import * as CollectionsActions from '../../actions/collections'; diff --git a/client/modules/IDE/components/CollectionList/CollectionListRow.jsx b/client/modules/IDE/components/CollectionList/CollectionListRow.jsx index 16421f7a6b..dafbe21517 100644 --- a/client/modules/IDE/components/CollectionList/CollectionListRow.jsx +++ b/client/modules/IDE/components/CollectionList/CollectionListRow.jsx @@ -1,299 +1,192 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { connect } from 'react-redux'; -import { Link } from 'react-router-dom'; -import { bindActionCreators } from 'redux'; -import { withTranslation } from 'react-i18next'; -import * as ProjectActions from '../../actions/project'; -import * as CollectionsActions from '../../actions/collections'; -import * as IdeActions from '../../actions/ide'; -import * as ToastActions from '../../actions/toast'; -import dates from '../../../../utils/formatDate'; - -import DownFilledTriangleIcon from '../../../../images/down-filled-triangle.svg'; -import MoreIconSvg from '../../../../images/more.svg'; - -const formatDateCell = (date, mobile = false) => - dates.format(date, { showTime: !mobile }); - -class CollectionListRowBase extends React.Component { - static projectInCollection(project, collection) { - return ( - collection.items.find((item) => item.project.id === project.id) != null - ); - } - - constructor(props) { - super(props); - this.state = { - optionsOpen: false, - isFocused: false, - renameOpen: false, - renameValue: '' - }; - this.renameInput = React.createRef(); - } - - onFocusComponent = () => { - this.setState({ isFocused: true }); - }; - - onBlurComponent = () => { - this.setState({ isFocused: false }); - setTimeout(() => { - if (!this.state.isFocused) { - this.closeAll(); - } - }, 200); - }; - - openOptions = () => { - this.setState({ - optionsOpen: true - }); - }; - - closeOptions = () => { - this.setState({ - optionsOpen: false - }); - }; - - toggleOptions = () => { - if (this.state.optionsOpen) { - this.closeOptions(); - } else { - this.openOptions(); - } - }; - - closeAll = () => { - this.setState({ - optionsOpen: false, - renameOpen: false - }); - }; - - handleAddSketches = () => { - this.closeAll(); - this.props.onAddSketches(); - }; - - handleDropdownOpen = () => { - this.closeAll(); - this.openOptions(); - }; - - handleCollectionDelete = () => { - this.closeAll(); - if ( - window.confirm( - this.props.t('Common.DeleteConfirmation', { - name: this.props.collection.name - }) - ) - ) { - this.props.deleteCollection(this.props.collection.id); - } - }; - - handleRenameOpen = () => { - this.closeAll(); - this.setState( - { - renameOpen: true, - renameValue: this.props.collection.name - }, - () => this.renameInput.current.focus() - ); - }; - - handleRenameChange = (e) => { - this.setState({ - renameValue: e.target.value - }); - }; - - handleRenameEnter = (e) => { - if (e.key === 'Enter') { - this.updateName(); - this.closeAll(); - } - }; - - handleRenameBlur = () => { - this.updateName(); - this.closeAll(); - }; - - updateName = () => { - const isValid = this.state.renameValue.trim().length !== 0; - if (isValid) { - this.props.editCollection(this.props.collection.id, { - name: this.state.renameValue.trim() - }); - } - }; - - renderActions = () => { - const { optionsOpen } = this.state; - const userIsOwner = this.props.user.username === this.props.username; - - return ( - - - {optionsOpen && ( -
      -
    • - -
    • - {userIsOwner && ( -
    • - -
    • - )} - {userIsOwner && ( -
    • - -
    • - )} -
    - )} -
    - ); - }; - - renderCollectionName = () => { - const { collection, username } = this.props; - const { renameOpen, renameValue } = this.state; - - return ( - - - {renameOpen ? '' : collection.name} - - {renameOpen && ( - e.stopPropagation()} - ref={this.renameInput} - /> - )} - - ); - }; - - render() { - const { collection, mobile } = this.props; - - return ( - - - - {this.renderCollectionName()} - - - {formatDateCell(collection.createdAt, mobile)} - {formatDateCell(collection.updatedAt, mobile)} - - {mobile && 'sketches: '} - {(collection.items || []).length} - - {this.renderActions()} - - ); - } -} - -CollectionListRowBase.propTypes = { - collection: PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - owner: PropTypes.shape({ - username: PropTypes.string.isRequired - }).isRequired, - createdAt: PropTypes.string.isRequired, - updatedAt: PropTypes.string.isRequired, - items: PropTypes.arrayOf( - PropTypes.shape({ - project: PropTypes.shape({ - id: PropTypes.string.isRequired - }) - }) - ) - }).isRequired, - username: PropTypes.string.isRequired, - user: PropTypes.shape({ - username: PropTypes.string, - authenticated: PropTypes.bool.isRequired - }).isRequired, - deleteCollection: PropTypes.func.isRequired, - editCollection: PropTypes.func.isRequired, - onAddSketches: PropTypes.func.isRequired, - mobile: PropTypes.bool, - t: PropTypes.func.isRequired -}; - -CollectionListRowBase.defaultProps = { - mobile: false -}; - -function mapDispatchToPropsSketchListRow(dispatch) { - return bindActionCreators( - Object.assign( - {}, - CollectionsActions, - ProjectActions, - IdeActions, - ToastActions - ), - dispatch - ); -} - -export default withTranslation()( - connect(null, mapDispatchToPropsSketchListRow)(CollectionListRowBase) -); +import PropTypes from 'prop-types'; +import React, { useState, useRef } from 'react'; +import { connect } from 'react-redux'; +import { Link } from 'react-router-dom'; +import { bindActionCreators } from 'redux'; +import { withTranslation } from 'react-i18next'; +import MenuItem from '../../../../components/Dropdown/MenuItem'; +import TableDropdown from '../../../../components/Dropdown/TableDropdown'; +import * as ProjectActions from '../../actions/project'; +import * as CollectionsActions from '../../actions/collections'; +import * as IdeActions from '../../actions/ide'; +import * as ToastActions from '../../actions/toast'; +import dates from '../../../../utils/formatDate'; + +const formatDateCell = (date, mobile = false) => + dates.format(date, { showTime: !mobile }); + +const CollectionListRowBase = (props) => { + const [renameOpen, setRenameOpen] = useState(false); + const [renameValue, setRenameValue] = useState(''); + const renameInput = useRef(null); + + const closeAll = () => { + setRenameOpen(false); + }; + + const updateName = () => { + const isValid = renameValue.trim().length !== 0; + if (isValid) { + props.editCollection(props.collection.id, { + name: renameValue.trim() + }); + } + }; + + const handleAddSketches = () => { + closeAll(); + props.onAddSketches(); + }; + + const handleCollectionDelete = () => { + closeAll(); + if ( + window.confirm( + props.t('Common.DeleteConfirmation', { + name: props.collection.name + }) + ) + ) { + props.deleteCollection(props.collection.id); + } + }; + + const handleRenameOpen = () => { + closeAll(); + setRenameOpen(true); + setRenameValue(props.collection.name); + if (renameInput.current) { + renameInput.current.focus(); + } + }; + + const handleRenameChange = (e) => { + setRenameValue(e.target.value); + }; + + const handleRenameEnter = (e) => { + if (e.key === 'Enter') { + updateName(); + closeAll(); + } + }; + + const handleRenameBlur = () => { + updateName(); + closeAll(); + }; + + const renderActions = () => { + const userIsOwner = props.user.username === props.username; + + return ( + + + {props.t('CollectionListRow.AddSketch')} + + + {props.t('CollectionListRow.Delete')} + + + {props.t('CollectionListRow.Rename')} + + + ); + }; + + const renderCollectionName = () => { + const { collection, username } = props; + + return ( + <> + + {renameOpen ? '' : collection.name} + + {renameOpen && ( + e.stopPropagation()} + ref={renameInput} + /> + )} + + ); + }; + + const { collection, mobile } = props; + + return ( + + + {renderCollectionName()} + + {formatDateCell(collection.createdAt, mobile)} + {formatDateCell(collection.updatedAt, mobile)} + + {mobile && 'sketches: '} + {(collection.items || []).length} + + {renderActions()} + + ); +}; + +CollectionListRowBase.propTypes = { + collection: PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + owner: PropTypes.shape({ + username: PropTypes.string.isRequired + }).isRequired, + createdAt: PropTypes.string.isRequired, + updatedAt: PropTypes.string.isRequired, + items: PropTypes.arrayOf( + PropTypes.shape({ + project: PropTypes.shape({ + id: PropTypes.string.isRequired + }) + }) + ) + }).isRequired, + username: PropTypes.string.isRequired, + user: PropTypes.shape({ + username: PropTypes.string, + authenticated: PropTypes.bool.isRequired + }).isRequired, + deleteCollection: PropTypes.func.isRequired, + editCollection: PropTypes.func.isRequired, + onAddSketches: PropTypes.func.isRequired, + mobile: PropTypes.bool, + t: PropTypes.func.isRequired +}; + +CollectionListRowBase.defaultProps = { + mobile: false +}; + +function mapDispatchToPropsSketchListRow(dispatch) { + return bindActionCreators( + Object.assign( + {}, + CollectionsActions, + ProjectActions, + IdeActions, + ToastActions + ), + dispatch + ); +} + +export default withTranslation()( + connect(null, mapDispatchToPropsSketchListRow)(CollectionListRowBase) +); diff --git a/client/modules/IDE/components/CopyableInput.jsx b/client/modules/IDE/components/CopyableInput.jsx index 1fa9f6bec3..2d7b5036f2 100644 --- a/client/modules/IDE/components/CopyableInput.jsx +++ b/client/modules/IDE/components/CopyableInput.jsx @@ -1,95 +1,91 @@ import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import Clipboard from 'clipboard'; import classNames from 'classnames'; -import { withTranslation } from 'react-i18next'; +import { useTranslation } from 'react-i18next'; import ShareIcon from '../../../images/share.svg'; -class CopyableInput extends React.Component { - constructor(props) { - super(props); - this.onMouseLeaveHandler = this.onMouseLeaveHandler.bind(this); - } +const CopyableInput = ({ label, value, hasPreviewLink }) => { + const { t } = useTranslation(); - componentDidMount() { - this.clipboard = new Clipboard(this.input, { - target: () => this.input - }); + const [isCopied, setIsCopied] = useState(false); - this.clipboard.on('success', (e) => { - this.tooltip.classList.add('tooltipped'); - this.tooltip.classList.add('tooltipped-n'); - }); - } + const inputRef = useRef(null); - componentWillUnmount() { - this.clipboard.destroy(); - } + useEffect(() => { + const input = inputRef.current; - onMouseLeaveHandler() { - this.tooltip.classList.remove('tooltipped'); - this.tooltip.classList.remove('tooltipped-n'); - } + if (!input) return; // should never happen - render() { - const { label, value, hasPreviewLink } = this.props; - const copyableInputClass = classNames({ - 'copyable-input': true, - 'copyable-input--with-preview': hasPreviewLink + const clipboard = new Clipboard(input, { + target: () => input }); - return ( -
    -
    { - this.tooltip = element; - }} - onMouseLeave={this.onMouseLeaveHandler} - > - -
    - {hasPreviewLink && ( - - + + clipboard.on('success', () => { + setIsCopied(true); + }); + + // eslint-disable-next-line consistent-return + return () => { + clipboard.destroy(); + }; + }, [inputRef, setIsCopied]); + + return ( +
    +
    setIsCopied(false)} + > +
    - ); - } -} + {hasPreviewLink && ( + + + )} +
    + ); +}; CopyableInput.propTypes = { label: PropTypes.string.isRequired, value: PropTypes.string.isRequired, - hasPreviewLink: PropTypes.bool, - t: PropTypes.func.isRequired + hasPreviewLink: PropTypes.bool }; CopyableInput.defaultProps = { hasPreviewLink: false }; -export default withTranslation()(CopyableInput); +export default CopyableInput; diff --git a/client/modules/IDE/components/Editor/index.jsx b/client/modules/IDE/components/Editor/index.jsx index 54f7316890..cd060d3bb5 100644 --- a/client/modules/IDE/components/Editor/index.jsx +++ b/client/modules/IDE/components/Editor/index.jsx @@ -23,6 +23,7 @@ import 'codemirror/addon/fold/comment-fold'; import 'codemirror/addon/fold/foldcode'; import 'codemirror/addon/fold/foldgutter'; import 'codemirror/addon/fold/indent-fold'; +import 'codemirror/addon/fold/xml-fold'; import 'codemirror/addon/comment/comment'; import 'codemirror/keymap/sublime'; import 'codemirror/addon/search/searchcursor'; @@ -70,7 +71,7 @@ import EditorAccessibility from '../EditorAccessibility'; import UnsavedChangesIndicator from '../UnsavedChangesIndicator'; import { EditorContainer, EditorHolder } from './MobileEditor'; import { FolderIcon } from '../../../../common/icons'; -import IconButton from '../../../../components/mobile/IconButton'; +import IconButton from '../../../../common/IconButton'; emmet(CodeMirror); diff --git a/client/modules/IDE/components/FloatingActionButton.jsx b/client/modules/IDE/components/FloatingActionButton.jsx index e4ce3aaca7..52d2827523 100644 --- a/client/modules/IDE/components/FloatingActionButton.jsx +++ b/client/modules/IDE/components/FloatingActionButton.jsx @@ -38,17 +38,20 @@ const Button = styled.button` } `; -const FloatingActionButton = (props) => { +const FloatingActionButton = ({ syncFileContent, offsetBottom }) => { const isPlaying = useSelector((state) => state.ide.isPlaying); const dispatch = useDispatch(); return ( - {optionsOpen && ( -
      - {userIsOwner && ( -
    • - -
    • - )} -
    • - -
    • - {this.props.user.authenticated && ( -
    • - -
    • - )} - {this.props.user.authenticated && ( -
    • - -
    • - )} - {/*
    • - -
    • */} - {userIsOwner && ( -
    • - -
    • - )} -
    - )} + + + {this.props.t('SketchList.DropdownRename')} + + + {this.props.t('SketchList.DropdownDownload')} + + + {this.props.t('SketchList.DropdownDuplicate')} + + { + this.props.onAddToCollection(); + }} + > + {this.props.t('SketchList.DropdownAddToCollection')} + + + {/* + + Share + + */} + + {this.props.t('SketchList.DropdownDelete')} + + ); }; @@ -544,9 +418,7 @@ class SketchList extends React.Component { } > )} diff --git a/client/modules/IDE/components/Toast.jsx b/client/modules/IDE/components/Toast.jsx index 0f16282220..882d101748 100644 --- a/client/modules/IDE/components/Toast.jsx +++ b/client/modules/IDE/components/Toast.jsx @@ -13,7 +13,7 @@ export default function Toast() { return null; } return ( -
    +

    {t(text)}

    @@ -483,6 +306,7 @@ class Collection extends React.Component { } Collection.propTypes = { + collectionId: PropTypes.string.isRequired, user: PropTypes.shape({ username: PropTypes.string, authenticated: PropTypes.bool.isRequired @@ -501,7 +325,6 @@ Collection.propTypes = { username: PropTypes.string, loading: PropTypes.bool.isRequired, toggleDirectionForField: PropTypes.func.isRequired, - editCollection: PropTypes.func.isRequired, resetSorting: PropTypes.func.isRequired, sorting: PropTypes.shape({ field: PropTypes.string.isRequired, diff --git a/client/modules/User/components/CollectionMetadata.jsx b/client/modules/User/components/CollectionMetadata.jsx new file mode 100644 index 0000000000..4b1be7ff5f --- /dev/null +++ b/client/modules/User/components/CollectionMetadata.jsx @@ -0,0 +1,126 @@ +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { Link } from 'react-router-dom'; +import Button from '../../../common/Button'; +import Overlay from '../../App/components/Overlay'; +import { editCollection } from '../../IDE/actions/collections'; +import AddToCollectionSketchList from '../../IDE/components/AddToCollectionSketchList'; +import EditableInput from '../../IDE/components/EditableInput'; +import { SketchSearchbar } from '../../IDE/components/Searchbar'; +import { getCollection } from '../../IDE/selectors/collections'; +import ShareURL from './CollectionShareButton'; + +function CollectionMetadata({ collectionId }) { + const { t } = useTranslation(); + + const dispatch = useDispatch(); + + const collection = useSelector((state) => getCollection(state, collectionId)); + const currentUsername = useSelector((state) => state.user.username); + + const [isAddingSketches, setIsAddingSketches] = useState(false); + + if (!collection) { + return null; + } + + const { id, name, description, items, owner } = collection; + const { username } = owner; + const isOwner = !!currentUsername && currentUsername === username; + + const hostname = window.location.origin; + + const handleEditCollectionName = (value) => { + if (value === name) { + return; + } + dispatch(editCollection(id, { name: value })); + }; + + const handleEditCollectionDescription = (value) => { + if (value === description) { + return; + } + dispatch(editCollection(id, { description: value })); + }; + + // TODO: Implement UI for editing slug + + return ( +
    +
    +
    +

    + {isOwner ? ( + value !== ''} + /> + ) : ( + name + )} +

    + +

    + {isOwner ? ( + + ) : ( + description + )} +

    + +

    + {t('Collection.By')} + {username} +

    + +

    + {t('Collection.NumSketches', { count: items.length })} +

    +
    + +
    + + {isOwner && ( + + )} +
    +
    + {isAddingSketches && ( + } + closeOverlay={() => setIsAddingSketches(false)} + isFixedHeight + > + + + )} +
    + ); +} + +CollectionMetadata.propTypes = { + collectionId: PropTypes.string.isRequired +}; + +export default CollectionMetadata; diff --git a/client/modules/User/components/CollectionShareButton.jsx b/client/modules/User/components/CollectionShareButton.jsx new file mode 100644 index 0000000000..c4fd06bcb6 --- /dev/null +++ b/client/modules/User/components/CollectionShareButton.jsx @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import Button from '../../../common/Button'; +import { DropdownArrowIcon } from '../../../common/icons'; +import useModalClose from '../../../common/useModalClose'; +import CopyableInput from '../../IDE/components/CopyableInput'; + +const ShareURL = ({ value }) => { + const [showURL, setShowURL] = useState(false); + const { t } = useTranslation(); + const close = useCallback(() => setShowURL(false), [setShowURL]); + const ref = useModalClose(close); + + return ( +
    + + {showURL && ( +
    + +
    + )} +
    + ); +}; + +ShareURL.propTypes = { + value: PropTypes.string.isRequired +}; + +export default ShareURL; diff --git a/client/modules/User/components/CookieConsent.jsx b/client/modules/User/components/CookieConsent.jsx index 4d21cba2e0..1e2e116e9d 100644 --- a/client/modules/User/components/CookieConsent.jsx +++ b/client/modules/User/components/CookieConsent.jsx @@ -6,7 +6,7 @@ import ReactGA from 'react-ga'; import { Transition } from 'react-transition-group'; import { Link } from 'react-router-dom'; import { Trans, useTranslation } from 'react-i18next'; -import { PropTypes } from 'prop-types'; +import PropTypes from 'prop-types'; import getConfig from '../../../utils/getConfig'; import { setUserCookieConsent } from '../actions'; import { remSize, prop, device } from '../../../theme'; @@ -15,11 +15,12 @@ import Button from '../../../common/Button'; const CookieConsentContainer = styled.div` position: fixed; transition: 1.6s cubic-bezier(0.165, 0.84, 0.44, 1); - bottom: ${({ state }) => { + bottom: 0; + transform: ${({ state }) => { if (state === 'entered') { - return '0'; + return 'translateY(0)'; } - return remSize(-300); + return 'translateY(105%)'; }}; left: 0; right: 0; diff --git a/client/modules/User/components/DashboardTabSwitcher.jsx b/client/modules/User/components/DashboardTabSwitcher.jsx index 21003c5f40..c3c288d3c8 100644 --- a/client/modules/User/components/DashboardTabSwitcher.jsx +++ b/client/modules/User/components/DashboardTabSwitcher.jsx @@ -4,9 +4,9 @@ import { useTranslation } from 'react-i18next'; import MediaQuery from 'react-responsive'; import { useDispatch } from 'react-redux'; import styled from 'styled-components'; -import { Link } from 'react-router-dom'; import { FilterIcon } from '../../../common/icons'; -import IconButton from '../../../components/mobile/IconButton'; +import IconButton from '../../../common/IconButton'; +import RouterTab from '../../../common/RouterTab'; import { Options } from '../../IDE/components/Header/MobileNav'; import { toggleDirectionForField } from '../../IDE/actions/sorting'; @@ -16,28 +16,6 @@ export const TabKey = { sketches: 'sketches' }; -const Tab = ({ children, isSelected, to }) => { - const selectedClassName = 'dashboard-header__tab--selected'; - - const location = { pathname: to, state: { skipSavingPath: true } }; - const content = isSelected ? ( - {children} - ) : ( - {children} - ); - return ( -
  • -

    {content}

    -
  • - ); -}; - -Tab.propTypes = { - children: PropTypes.string.isRequired, - isSelected: PropTypes.bool.isRequired, - to: PropTypes.string.isRequired -}; - // It is good for right now, because we need to separate the nav dropdown logic from the navBar before we can use it here const FilterOptions = styled(Options)` > div > button:focus + ul, @@ -52,29 +30,20 @@ const DashboardTabSwitcher = ({ currentTab, isOwner, username }) => { const dispatch = useDispatch(); return ( -
      -
      - +
      +
        + {t('DashboardTabSwitcher.Sketches')} - - + + {t('DashboardTabSwitcher.Collections')} - + {isOwner && ( - + {t('DashboardTabSwitcher.Assets')} - + )} -
      +
    {(mobile) => mobile && @@ -125,7 +94,7 @@ const DashboardTabSwitcher = ({ currentTab, isOwner, username }) => { ) } - +
    ); }; diff --git a/client/modules/User/components/SignupForm.jsx b/client/modules/User/components/SignupForm.jsx index b80f53f107..c497981c1e 100644 --- a/client/modules/User/components/SignupForm.jsx +++ b/client/modules/User/components/SignupForm.jsx @@ -8,8 +8,9 @@ import Button from '../../../common/Button'; import apiClient from '../../../utils/apiClient'; function asyncValidate(fieldToValidate, value) { - if (!value || value.trim().length === 0) - return `Please enter a ${fieldToValidate}.`; + if (!value || value.trim().length === 0) { + return ''; + } const queryParams = {}; queryParams[fieldToValidate] = value; queryParams.check_type = fieldToValidate; diff --git a/client/modules/User/pages/CollectionView.jsx b/client/modules/User/pages/CollectionView.jsx index 39e27fee4e..e52eb2c279 100644 --- a/client/modules/User/pages/CollectionView.jsx +++ b/client/modules/User/pages/CollectionView.jsx @@ -1,92 +1,21 @@ -import PropTypes from 'prop-types'; import React from 'react'; -import { connect } from 'react-redux'; -import { withTranslation } from 'react-i18next'; - +import { useParams } from 'react-router-dom'; import Nav from '../../IDE/components/Header/Nav'; import RootPage from '../../../components/RootPage'; - -import CollectionCreate from '../components/CollectionCreate'; import Collection from '../components/Collection'; -class CollectionView extends React.Component { - static defaultProps = { - user: null - }; - - ownerName() { - if (this.props.params.username) { - return this.props.params.username; - } - - return this.props.user.username; - } - - pageTitle() { - if (this.isCreatePage()) { - return this.props.t('CollectionView.TitleCreate'); - } - - return this.props.t('CollectionView.TitleDefault'); - } - - isOwner() { - return this.props.user.username === this.props.params.username; - } +const CollectionView = () => { + const params = useParams(); - isCreatePage() { - const path = this.props.location.pathname; - return /create$/.test(path); - } - - renderContent() { - if (this.isCreatePage() && this.isOwner()) { - return ; - } - - return ( + return ( + +