diff --git a/.env.example b/.env.example index ea00adf9d9..0a7df9314f 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,7 @@ MAILGUN_KEY=<your-mailgun-api-key> ML5_LIBRARY_USERNAME=ml5 ML5_LIBRARY_EMAIL=examples@ml5js.org ML5_LIBRARY_PASS=helloml5 -MONGO_URL=mongodb://localhost:27017/p5js-web-editor +MONGO_URL=mongodb://localhost:27017/local PORT=8000 PREVIEW_PORT=8002 EDITOR_URL=http://localhost:8000 diff --git a/client/constants.js b/client/constants.js index 57c85f4e85..4470d8eb29 100644 --- a/client/constants.js +++ b/client/constants.js @@ -41,6 +41,8 @@ export const DELETE_COLLECTION = 'DELETE_COLLECTION'; export const ADD_TO_COLLECTION = 'ADD_TO_COLLECTION'; export const REMOVE_FROM_COLLECTION = 'REMOVE_FROM_COLLECTION'; export const EDIT_COLLECTION = 'EDIT_COLLECTION'; +export const CHANGE_VISIBILITY = 'CHANGE_VISIBILITY'; +export const SET_PROJECT_VISIBILITY = 'SET_PROJECT_VISIBILITY'; export const DELETE_PROJECT = 'DELETE_PROJECT'; diff --git a/client/modules/IDE/actions/project.js b/client/modules/IDE/actions/project.js index 1d8943336b..7b7e4fa9df 100644 --- a/client/modules/IDE/actions/project.js +++ b/client/modules/IDE/actions/project.js @@ -410,3 +410,45 @@ export function deleteProject(id) { }); }; } +export function changeVisibility(projectId, projectName, visibility) { + return (dispatch, getState) => { + const state = getState(); + + apiClient + .patch('/project/visibility', { projectId, visibility }) + .then((response) => { + if (response.status === 200) { + const { visibility: newVisibility } = response.data; + + dispatch({ + type: ActionTypes.CHANGE_VISIBILITY, + payload: { + id: response.data.id, + visibility: newVisibility + } + }); + + if (state.project.id === response.data.id) { + dispatch({ + type: ActionTypes.SET_PROJECT_VISIBILITY, + visibility: newVisibility + }); + dispatch({ + type: ActionTypes.SET_PROJECT_NAME, + name: response.data.name + }); + } + dispatch( + setToastText(`${projectName} is now ${newVisibility.toLowerCase()}`) + ); + dispatch(showToast(2000)); + } + }) + .catch((error) => { + dispatch({ + type: ActionTypes.ERROR, + error: error?.response?.data + }); + }); + }; +} diff --git a/client/modules/IDE/components/Header/MobileNav.jsx b/client/modules/IDE/components/Header/MobileNav.jsx index 349d3d1709..a24a9f9737 100644 --- a/client/modules/IDE/components/Header/MobileNav.jsx +++ b/client/modules/IDE/components/Header/MobileNav.jsx @@ -35,6 +35,7 @@ import { setLanguage } from '../../actions/preferences'; import Overlay from '../../../App/components/Overlay'; import ProjectName from './ProjectName'; import CollectionCreate from '../../../User/components/CollectionCreate'; +import { changeVisibility } from '../../actions/project'; const Nav = styled(Menubar)` background: ${prop('MobilePanel.default.background')}; @@ -75,6 +76,13 @@ const Title = styled.div` margin: 0; } + > section { + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + } + > h5 { font-size: ${remSize(13)}; font-weight: normal; @@ -203,6 +211,7 @@ const LanguageSelect = styled.div` const MobileNav = () => { const project = useSelector((state) => state.project); const user = useSelector((state) => state.user); + const dispatch = useDispatch(); const { t } = useTranslation(); @@ -228,21 +237,53 @@ const MobileNav = () => { } const title = useMemo(resolveTitle, [pageName, project.name]); + const userIsOwner = user?.username === project.owner?.username; const Logo = AsteriskIcon; + + const toggleVisibility = (e) => { + try { + const isChecked = e.target.checked; + + dispatch( + changeVisibility( + project.id, + project.name, + isChecked ? 'Private' : 'Public' + ) + ); + } catch (error) { + console.log(error); + } + }; return ( <Nav> <LogoContainer> <Logo /> </LogoContainer> <Title> - <h1>{title === project.name ? <ProjectName /> : title}</h1> - {project?.owner && title === project.name && ( - <Link to={`/${project.owner.username}/sketches`}> - by {project?.owner?.username} - </Link> - )} + <h1>{title === project?.name ? <ProjectName /> : title}</h1> + {(() => { + if (project?.owner && title === project.name && userIsOwner) { + return ( + <main className="toolbar__makeprivate"> + <p>Private</p> + <input + className="toolbar__togglevisibility" + type="checkbox" + onChange={toggleVisibility} + defaultChecked={project.visibility === 'Private'} + /> + </main> + ); + } + if (project?.owner && title === project.name) { + return <h5>by {project?.owner?.username}</h5>; + } + return null; + })()} </Title> + {/* check if the user is in login page */} {pageName === 'login' || pageName === 'signup' ? ( // showing the CrossIcon diff --git a/client/modules/IDE/components/Header/Toolbar.jsx b/client/modules/IDE/components/Header/Toolbar.jsx index dd6e175d36..e1a4c09e09 100644 --- a/client/modules/IDE/components/Header/Toolbar.jsx +++ b/client/modules/IDE/components/Header/Toolbar.jsx @@ -15,7 +15,7 @@ import { setGridOutput, setTextOutput } from '../../actions/preferences'; - +import { changeVisibility } from '../../actions/project'; import PlayIcon from '../../../../images/play.svg'; import StopIcon from '../../../../images/stop.svg'; import PreferencesIcon from '../../../../images/preferences.svg'; @@ -27,11 +27,27 @@ const Toolbar = (props) => { (state) => state.ide ); const project = useSelector((state) => state.project); + const user = useSelector((state) => state.user); const autorefresh = useSelector((state) => state.preferences.autorefresh); const dispatch = useDispatch(); - const { t } = useTranslation(); + const userIsOwner = user?.username === project.owner?.username; + const toggleVisibility = (e) => { + try { + const isChecked = e.target.checked; + dispatch( + changeVisibility( + project.id, + project.name, + isChecked ? 'Private' : 'Public' + ) + ); + } catch (error) { + console.log(error); + } + }; + const playButtonClass = classNames({ 'toolbar__play-button': true, 'toolbar__play-button--selected': isPlaying @@ -101,9 +117,22 @@ const Toolbar = (props) => { <div className="toolbar__project-name-container"> <ProjectName /> {(() => { - if (project.owner) { + if (project?.owner && userIsOwner) { + return ( + <main className="toolbar__makeprivate"> + <p>Private</p> + <input + type="checkbox" + className="toolbar__togglevisibility" + defaultChecked={project.visibility === 'Private'} + onChange={toggleVisibility} + /> + </main> + ); + } + if (project?.owner && !userIsOwner) { return ( - <p className="toolbar__project-project.owner"> + <p className="toolbar__project-owner"> {t('Toolbar.By')}{' '} <Link to={`/${project.owner.username}/sketches`}> {project.owner.username} @@ -113,8 +142,8 @@ const Toolbar = (props) => { } return null; })()} + <VersionIndicator /> </div> - <VersionIndicator /> <div style={{ flex: 1 }} /> <button className={preferencesButtonClass} diff --git a/client/modules/IDE/components/Header/__snapshots__/Nav.unit.test.jsx.snap b/client/modules/IDE/components/Header/__snapshots__/Nav.unit.test.jsx.snap index 290528582a..61f88d49c1 100644 --- a/client/modules/IDE/components/Header/__snapshots__/Nav.unit.test.jsx.snap +++ b/client/modules/IDE/components/Header/__snapshots__/Nav.unit.test.jsx.snap @@ -152,6 +152,22 @@ exports[`Nav renders dashboard version for mobile 1`] = ` margin: 0; } +.c2 > section { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + gap: 5px; +} + .c2 > h5 { font-size: 1.0833333333333333rem; font-weight: normal; @@ -853,6 +869,22 @@ exports[`Nav renders editor version for mobile 1`] = ` margin: 0; } +.c2 > section { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + gap: 5px; +} + .c2 > h5 { font-size: 1.0833333333333333rem; font-weight: normal; diff --git a/client/modules/IDE/components/SketchList.jsx b/client/modules/IDE/components/SketchList.jsx index 3846fe278b..f91f9e2d47 100644 --- a/client/modules/IDE/components/SketchList.jsx +++ b/client/modules/IDE/components/SketchList.jsx @@ -1,3 +1,4 @@ +/* eslint-disable max-len */ import PropTypes from 'prop-types'; import classNames from 'classnames'; import React, { useEffect, useState, useMemo, useCallback } from 'react'; @@ -13,9 +14,9 @@ import getSortedSketches from '../selectors/projects'; import Loader from '../../App/components/loader'; import Overlay from '../../App/components/Overlay'; import AddToCollectionList from './AddToCollectionList'; -import SketchListRowBase from './SketchListRowBase'; import ArrowUpIcon from '../../../images/sort-arrow-up.svg'; import ArrowDownIcon from '../../../images/sort-arrow-down.svg'; +import SketchListRowBase from './SketchListRowBase'; const SketchList = ({ user, @@ -60,7 +61,9 @@ const SketchList = ({ const renderEmptyTable = () => { if (!isLoading() && sketches.length === 0) { return ( - <p className="sketches-table__empty">{t('SketchList.NoSketches')}</p> + <p className="sketches-table curative__empty"> + {t('SketchList.NoSketches')} + </p> ); } return null; @@ -118,6 +121,8 @@ const SketchList = ({ [sorting, getButtonLabel, toggleDirectionForField, t] ); + const userIsOwner = user.username === username; + return ( <article className="sketches-table-container"> <Helmet> @@ -145,6 +150,7 @@ const SketchList = ({ context: mobile ? 'mobile' : '' }) )} + {userIsOwner && renderFieldHeader('makePrivate', 'Make Private')} <th scope="col"></th> </tr> </thead> @@ -187,7 +193,8 @@ SketchList.propTypes = { id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, createdAt: PropTypes.string.isRequired, - updatedAt: PropTypes.string.isRequired + updatedAt: PropTypes.string.isRequired, + visibility: PropTypes.string }) ).isRequired, username: PropTypes.string, diff --git a/client/modules/IDE/components/SketchListRowBase.jsx b/client/modules/IDE/components/SketchListRowBase.jsx index 08dc185885..d20d0a9a4c 100644 --- a/client/modules/IDE/components/SketchListRowBase.jsx +++ b/client/modules/IDE/components/SketchListRowBase.jsx @@ -23,6 +23,8 @@ const SketchListRowBase = ({ changeProjectName, cloneProject, deleteProject, + showShareModal, + changeVisibility, t, mobile, onAddToCollection @@ -75,12 +77,67 @@ const SketchListRowBase = ({ }; const handleSketchDuplicate = () => cloneProject(sketch); + + // const handleSketchShare = () => { + // showShareModal(sketch.id, sketch.name, username); + // }; + const handleSketchDelete = () => { if (window.confirm(t('Common.DeleteConfirmation', { name: sketch.name }))) { deleteProject(sketch.id); } }; + const handleToggleVisibilityChange = (e) => { + const isChecked = e.target.checked; + const newVisibility = isChecked ? 'Private' : 'Public'; + changeVisibility(sketch.id, sketch.name, newVisibility); + }; + + const renderToggleVisibility = () => ( + <div> + <input + checked={sketch.visibility === 'Private'} + type="checkbox" + className="visibility__toggle-checkbox" + id={`toggle-${sketch.id}`} + onChange={handleToggleVisibilityChange} + /> + <label + htmlFor={`toggle-${sketch.id}`} + className="visibility__toggle-label" + > + <svg + width="8" + height="11" + viewBox="0 0 8 11" + fill="none" + xmlns="http://www.w3.org/2000/svg" + className="lock" + > + <path + d="M8 5.68627V10.0784C8 10.5882 7.54067 11 7.00478 11H0.995215C0.440191 11 0 10.5686 0 10.0784V5.68627C0 5.17647 0.440191 4.7451 0.995215 4.7451C1.09035 4.7451 1.16746 4.66798 1.16746 4.57285V2.90196C1.16746 1.29412 2.43062 0 3.98086 0C5.55024 0 6.8134 1.29412 6.8134 2.90196V4.55371C6.8134 4.65941 6.89908 4.7451 7.00478 4.7451C7.54067 4.7451 8 5.15686 8 5.68627ZM2.33716 3.11732C2.34653 4.01904 3.08017 4.7451 3.98194 4.7451C4.89037 4.7451 5.62679 4.00867 5.62679 3.10024V2.90196C5.62679 1.96078 4.89952 1.21569 3.98086 1.21569C3.10048 1.21569 2.33493 1.96078 2.33493 2.90196L2.33716 3.11732Z" + fill="white" + fillOpacity="0.4" + /> + </svg> + <svg + width="10" + height="10" + viewBox="0 0 10 10" + fill="none" + xmlns="http://www.w3.org/2000/svg" + className="earth" + > + <path + d="M10 5C10 5.42308 9.96154 5.80769 9.86539 6.15385C9.32692 8.34615 7.34615 10 5 10C2.57692 10 0.538462 8.25 0.0961538 5.96154C0.0384615 5.65385 0 5.34615 0 5V4.98077C0 4.84615 7.26432e-08 4.73077 0.0192308 4.63461C0.125111 3.25818 0.781552 2.01128 1.78053 1.1614C1.91355 1.04823 2.15362 1.13705 2.32692 1.11538C2.67308 1.03846 2.90385 0.788462 3.07692 0.846154C3.26923 0.903846 3.42308 1.11538 3.19231 1.26923C2.94231 1.40385 2.88462 1.63462 3.01923 1.75C3.15385 1.86538 3.34615 1.63462 3.61538 1.63462C3.88462 1.63462 4.21154 1.96154 4.19231 2.21154C4.15385 2.55769 4.15385 3 4.30769 3.28846C4.46154 3.57692 4.80769 4.01923 5.23077 4.13462C5.61539 4.23077 6.26923 4.32692 6.34615 4.34615C6.63419 4.45588 6.57131 4.6983 6.33349 4.89437C6.21892 4.98883 6.11852 5.09107 6.09615 5.17308C5.86539 5.88462 6.84615 6.11538 6.67308 6.59615C6.55769 6.94231 6.17308 7.28846 6.03846 7.63462C5.95671 7.84484 5.9246 8.14727 5.98523 8.37391C6.02693 8.52981 6.28488 8.43597 6.40385 8.32692C6.63462 8.13462 7.11539 7.44231 7.44231 7.21154C7.78846 6.98077 8.57692 6.78846 8.82692 6.01923C8.96154 5.59615 8.94231 5.21154 8.36539 4.88462C7.78846 4.55769 8.17308 4.15385 7.67308 4.15385C7.17308 4.15385 7.15385 4.34615 6.78846 4.21154C5.53846 3.71154 5.90385 3.23077 6.21154 3.21154C6.34615 3.19231 6.48077 3.25 6.65385 3.32692C6.82692 3.42308 6.88462 3.32692 6.84615 3.05769C6.80769 2.78846 6.86539 2.42308 7 1.98077C7.21154 1.26923 6.80769 0.634615 6.19231 0.557692C5.61539 0.442308 5.57692 0.653846 5.19231 1.07692C4.90385 1.40385 4.34615 1.13462 3.88462 0.807692C3.61454 0.611276 3.90971 0.105968 4.2399 0.0560849C4.48198 0.019514 4.72894 0 4.98077 0C7.69649 0 9.91771 2.18723 9.97993 4.91613C9.9806 4.94511 10 4.97101 10 5Z" + fill="#929292" + /> + </svg> + </label> + </div> + ); + const userIsOwner = user.username === username; let url = `/${username}/sketches/${sketch.id}`; @@ -110,6 +167,7 @@ const SketchListRowBase = ({ <th scope="row">{name}</th> <td>{formatDateCell(sketch.createdAt, mobile)}</td> <td>{formatDateCell(sketch.updatedAt, mobile)}</td> + <td hidden={!userIsOwner}>{renderToggleVisibility()}</td> <td className="sketch-list__dropdown-column"> <TableDropdown aria-label={t('SketchList.ToggleLabelARIA')}> <MenuItem hideIf={!userIsOwner} onClick={openRename}> @@ -127,6 +185,9 @@ const SketchListRowBase = ({ <MenuItem hideIf={!user.authenticated} onClick={onAddToCollection}> {t('SketchList.DropdownAddToCollection')} </MenuItem> + {/* <MenuItem onClick={handleSketchShare}> + {t('SketchList.DropdownShare')} + </MenuItem> */} <MenuItem hideIf={!userIsOwner} onClick={handleSketchDelete}> {t('SketchList.DropdownDelete')} </MenuItem> @@ -141,7 +202,8 @@ SketchListRowBase.propTypes = { id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, createdAt: PropTypes.string.isRequired, - updatedAt: PropTypes.string.isRequired + updatedAt: PropTypes.string.isRequired, + visibility: PropTypes.string }).isRequired, username: PropTypes.string.isRequired, user: PropTypes.shape({ @@ -151,6 +213,8 @@ SketchListRowBase.propTypes = { deleteProject: PropTypes.func.isRequired, cloneProject: PropTypes.func.isRequired, changeProjectName: PropTypes.func.isRequired, + showShareModal: PropTypes.func.isRequired, + changeVisibility: PropTypes.func.isRequired, onAddToCollection: PropTypes.func.isRequired, mobile: PropTypes.bool, t: PropTypes.func.isRequired @@ -162,7 +226,7 @@ SketchListRowBase.defaultProps = { function mapDispatchToPropsSketchListRow(dispatch) { return bindActionCreators( - Object.assign({}, ProjectActions, IdeActions), // Binding both ProjectActions and IdeActions + Object.assign({}, ProjectActions, IdeActions), dispatch ); } diff --git a/client/modules/IDE/components/__snapshots__/SketchList.unit.test.jsx.snap b/client/modules/IDE/components/__snapshots__/SketchList.unit.test.jsx.snap index fbb3c2e171..8167a9e863 100644 --- a/client/modules/IDE/components/__snapshots__/SketchList.unit.test.jsx.snap +++ b/client/modules/IDE/components/__snapshots__/SketchList.unit.test.jsx.snap @@ -79,6 +79,20 @@ exports[`<Sketchlist /> snapshot testing 1`] = ` </span> </button> </th> + <th + scope="col" + > + <button + aria-label="Sort by Make Private descending." + class="sketch-list__sort-button" + > + <span + class="sketches-table__header" + > + Make Private + </span> + </button> + </th> <th scope="col" /> @@ -103,6 +117,47 @@ exports[`<Sketchlist /> snapshot testing 1`] = ` <td> Feb 26, 2021, 4:58:29 AM </td> + <td> + <div> + <input + class="visibility__toggle-checkbox" + id="toggle-testid1" + type="checkbox" + /> + <label + class="visibility__toggle-label" + for="toggle-testid1" + > + <svg + class="lock" + fill="none" + height="11" + viewBox="0 0 8 11" + width="8" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M8 5.68627V10.0784C8 10.5882 7.54067 11 7.00478 11H0.995215C0.440191 11 0 10.5686 0 10.0784V5.68627C0 5.17647 0.440191 4.7451 0.995215 4.7451C1.09035 4.7451 1.16746 4.66798 1.16746 4.57285V2.90196C1.16746 1.29412 2.43062 0 3.98086 0C5.55024 0 6.8134 1.29412 6.8134 2.90196V4.55371C6.8134 4.65941 6.89908 4.7451 7.00478 4.7451C7.54067 4.7451 8 5.15686 8 5.68627ZM2.33716 3.11732C2.34653 4.01904 3.08017 4.7451 3.98194 4.7451C4.89037 4.7451 5.62679 4.00867 5.62679 3.10024V2.90196C5.62679 1.96078 4.89952 1.21569 3.98086 1.21569C3.10048 1.21569 2.33493 1.96078 2.33493 2.90196L2.33716 3.11732Z" + fill="white" + fill-opacity="0.4" + /> + </svg> + <svg + class="earth" + fill="none" + height="10" + viewBox="0 0 10 10" + width="10" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M10 5C10 5.42308 9.96154 5.80769 9.86539 6.15385C9.32692 8.34615 7.34615 10 5 10C2.57692 10 0.538462 8.25 0.0961538 5.96154C0.0384615 5.65385 0 5.34615 0 5V4.98077C0 4.84615 7.26432e-08 4.73077 0.0192308 4.63461C0.125111 3.25818 0.781552 2.01128 1.78053 1.1614C1.91355 1.04823 2.15362 1.13705 2.32692 1.11538C2.67308 1.03846 2.90385 0.788462 3.07692 0.846154C3.26923 0.903846 3.42308 1.11538 3.19231 1.26923C2.94231 1.40385 2.88462 1.63462 3.01923 1.75C3.15385 1.86538 3.34615 1.63462 3.61538 1.63462C3.88462 1.63462 4.21154 1.96154 4.19231 2.21154C4.15385 2.55769 4.15385 3 4.30769 3.28846C4.46154 3.57692 4.80769 4.01923 5.23077 4.13462C5.61539 4.23077 6.26923 4.32692 6.34615 4.34615C6.63419 4.45588 6.57131 4.6983 6.33349 4.89437C6.21892 4.98883 6.11852 5.09107 6.09615 5.17308C5.86539 5.88462 6.84615 6.11538 6.67308 6.59615C6.55769 6.94231 6.17308 7.28846 6.03846 7.63462C5.95671 7.84484 5.9246 8.14727 5.98523 8.37391C6.02693 8.52981 6.28488 8.43597 6.40385 8.32692C6.63462 8.13462 7.11539 7.44231 7.44231 7.21154C7.78846 6.98077 8.57692 6.78846 8.82692 6.01923C8.96154 5.59615 8.94231 5.21154 8.36539 4.88462C7.78846 4.55769 8.17308 4.15385 7.67308 4.15385C7.17308 4.15385 7.15385 4.34615 6.78846 4.21154C5.53846 3.71154 5.90385 3.23077 6.21154 3.21154C6.34615 3.19231 6.48077 3.25 6.65385 3.32692C6.82692 3.42308 6.88462 3.32692 6.84615 3.05769C6.80769 2.78846 6.86539 2.42308 7 1.98077C7.21154 1.26923 6.80769 0.634615 6.19231 0.557692C5.61539 0.442308 5.57692 0.653846 5.19231 1.07692C4.90385 1.40385 4.34615 1.13462 3.88462 0.807692C3.61454 0.611276 3.90971 0.105968 4.2399 0.0560849C4.48198 0.019514 4.72894 0 4.98077 0C7.69649 0 9.91771 2.18723 9.97993 4.91613C9.9806 4.94511 10 4.97101 10 5Z" + fill="#929292" + /> + </svg> + </label> + </div> + </td> <td class="sketch-list__dropdown-column" > @@ -140,6 +195,47 @@ exports[`<Sketchlist /> snapshot testing 1`] = ` <td> Feb 23, 2021, 5:40:43 PM </td> + <td> + <div> + <input + class="visibility__toggle-checkbox" + id="toggle-testid2" + type="checkbox" + /> + <label + class="visibility__toggle-label" + for="toggle-testid2" + > + <svg + class="lock" + fill="none" + height="11" + viewBox="0 0 8 11" + width="8" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M8 5.68627V10.0784C8 10.5882 7.54067 11 7.00478 11H0.995215C0.440191 11 0 10.5686 0 10.0784V5.68627C0 5.17647 0.440191 4.7451 0.995215 4.7451C1.09035 4.7451 1.16746 4.66798 1.16746 4.57285V2.90196C1.16746 1.29412 2.43062 0 3.98086 0C5.55024 0 6.8134 1.29412 6.8134 2.90196V4.55371C6.8134 4.65941 6.89908 4.7451 7.00478 4.7451C7.54067 4.7451 8 5.15686 8 5.68627ZM2.33716 3.11732C2.34653 4.01904 3.08017 4.7451 3.98194 4.7451C4.89037 4.7451 5.62679 4.00867 5.62679 3.10024V2.90196C5.62679 1.96078 4.89952 1.21569 3.98086 1.21569C3.10048 1.21569 2.33493 1.96078 2.33493 2.90196L2.33716 3.11732Z" + fill="white" + fill-opacity="0.4" + /> + </svg> + <svg + class="earth" + fill="none" + height="10" + viewBox="0 0 10 10" + width="10" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M10 5C10 5.42308 9.96154 5.80769 9.86539 6.15385C9.32692 8.34615 7.34615 10 5 10C2.57692 10 0.538462 8.25 0.0961538 5.96154C0.0384615 5.65385 0 5.34615 0 5V4.98077C0 4.84615 7.26432e-08 4.73077 0.0192308 4.63461C0.125111 3.25818 0.781552 2.01128 1.78053 1.1614C1.91355 1.04823 2.15362 1.13705 2.32692 1.11538C2.67308 1.03846 2.90385 0.788462 3.07692 0.846154C3.26923 0.903846 3.42308 1.11538 3.19231 1.26923C2.94231 1.40385 2.88462 1.63462 3.01923 1.75C3.15385 1.86538 3.34615 1.63462 3.61538 1.63462C3.88462 1.63462 4.21154 1.96154 4.19231 2.21154C4.15385 2.55769 4.15385 3 4.30769 3.28846C4.46154 3.57692 4.80769 4.01923 5.23077 4.13462C5.61539 4.23077 6.26923 4.32692 6.34615 4.34615C6.63419 4.45588 6.57131 4.6983 6.33349 4.89437C6.21892 4.98883 6.11852 5.09107 6.09615 5.17308C5.86539 5.88462 6.84615 6.11538 6.67308 6.59615C6.55769 6.94231 6.17308 7.28846 6.03846 7.63462C5.95671 7.84484 5.9246 8.14727 5.98523 8.37391C6.02693 8.52981 6.28488 8.43597 6.40385 8.32692C6.63462 8.13462 7.11539 7.44231 7.44231 7.21154C7.78846 6.98077 8.57692 6.78846 8.82692 6.01923C8.96154 5.59615 8.94231 5.21154 8.36539 4.88462C7.78846 4.55769 8.17308 4.15385 7.67308 4.15385C7.17308 4.15385 7.15385 4.34615 6.78846 4.21154C5.53846 3.71154 5.90385 3.23077 6.21154 3.21154C6.34615 3.19231 6.48077 3.25 6.65385 3.32692C6.82692 3.42308 6.88462 3.32692 6.84615 3.05769C6.80769 2.78846 6.86539 2.42308 7 1.98077C7.21154 1.26923 6.80769 0.634615 6.19231 0.557692C5.61539 0.442308 5.57692 0.653846 5.19231 1.07692C4.90385 1.40385 4.34615 1.13462 3.88462 0.807692C3.61454 0.611276 3.90971 0.105968 4.2399 0.0560849C4.48198 0.019514 4.72894 0 4.98077 0C7.69649 0 9.91771 2.18723 9.97993 4.91613C9.9806 4.94511 10 4.97101 10 5Z" + fill="#929292" + /> + </svg> + </label> + </div> + </td> <td class="sketch-list__dropdown-column" > diff --git a/client/modules/IDE/pages/IDEView.jsx b/client/modules/IDE/pages/IDEView.jsx index a9cc72f169..fd22b73472 100644 --- a/client/modules/IDE/pages/IDEView.jsx +++ b/client/modules/IDE/pages/IDEView.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; -import { useLocation, Prompt, useParams } from 'react-router-dom'; +import { useLocation, Prompt, useParams, useHistory } from 'react-router-dom'; import { useDispatch, useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; import { Helmet } from 'react-helmet'; @@ -10,7 +10,6 @@ import PreviewFrame from '../components/PreviewFrame'; import Console from '../components/Console'; import Toast from '../components/Toast'; import { updateFileContent } from '../actions/files'; - import { autosaveProject, clearPersistedState, @@ -97,6 +96,7 @@ const IDEView = () => { const project = useSelector((state) => state.project); const isUserOwner = useSelector(getIsUserOwner); const dispatch = useDispatch(); + const { t } = useTranslation(); const params = useParams(); @@ -105,7 +105,7 @@ const IDEView = () => { const [sidebarSize, setSidebarSize] = useState(160); const [isOverlayVisible, setIsOverlayVisible] = useState(false); const [MaxSize, setMaxSize] = useState(window.innerWidth); - + const history = useHistory(); const cmRef = useRef({}); const autosaveIntervalRef = useRef(null); diff --git a/client/modules/IDE/reducers/project.js b/client/modules/IDE/reducers/project.js index 89a03529e6..a41a0615b0 100644 --- a/client/modules/IDE/reducers/project.js +++ b/client/modules/IDE/reducers/project.js @@ -8,7 +8,8 @@ const initialState = () => { return { name: generatedName, updatedAt: '', - isSaving: false + isSaving: false, + visibility: 'Public' }; }; @@ -19,13 +20,21 @@ const project = (state, action) => { switch (action.type) { case ActionTypes.SET_PROJECT_NAME: return Object.assign({}, { ...state }, { name: action.name }); + case ActionTypes.SET_PROJECT_VISIBILITY: + return state.map((sketch) => { + if (sketch.id === action.id) { + return { ...sketch, visibility: action.visibility }; + } + return sketch; + }); case ActionTypes.NEW_PROJECT: return { id: action.project.id, name: action.project.name, updatedAt: action.project.updatedAt, owner: action.owner, - isSaving: false + isSaving: false, + visibility: action.project.visibility }; case ActionTypes.SET_PROJECT: return { @@ -33,7 +42,8 @@ const project = (state, action) => { name: action.project.name, updatedAt: action.project.updatedAt, owner: action.owner, - isSaving: false + isSaving: false, + visibility: action.project.visibility }; case ActionTypes.RESET_PROJECT: return initialState(); diff --git a/client/modules/IDE/reducers/projects.js b/client/modules/IDE/reducers/projects.js index 5950a042fa..8fe005c478 100644 --- a/client/modules/IDE/reducers/projects.js +++ b/client/modules/IDE/reducers/projects.js @@ -6,12 +6,20 @@ const sketches = (state = [], action) => { return action.projects; case ActionTypes.DELETE_PROJECT: return state.filter((sketch) => sketch.id !== action.id); + case ActionTypes.CHANGE_VISIBILITY: { + return state.map((sketch) => { + if (sketch.id === action.payload.id) { + return { ...sketch, visibility: action.payload.visibility }; + } + return sketch; + }); + } case ActionTypes.RENAME_PROJECT: { return state.map((sketch) => { if (sketch.id === action.payload.id) { return { ...sketch, name: action.payload.name }; } - return { ...sketch }; + return sketch; }); } default: diff --git a/client/modules/User/components/Collection.jsx b/client/modules/User/components/Collection.jsx index 197104561f..7f28eb7d2e 100644 --- a/client/modules/User/components/Collection.jsx +++ b/client/modules/User/components/Collection.jsx @@ -1,6 +1,7 @@ import PropTypes from 'prop-types'; import React, { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { Link } from 'react-router-dom'; import { Helmet } from 'react-helmet'; import { useTranslation } from 'react-i18next'; import classNames from 'classnames'; @@ -11,7 +12,88 @@ import Loader from '../../App/components/loader'; import ArrowUpIcon from '../../../images/sort-arrow-up.svg'; import ArrowDownIcon from '../../../images/sort-arrow-down.svg'; import CollectionMetadata from './CollectionMetadata'; -import CollectionItemRow from './CollectionItemRow'; +import dates from '../../../utils/formatDate'; +import RemoveIcon from '../../../images/close.svg'; + +const CollectionItemRow = ({ item, isOwner, collection, user }) => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const formatDateCell = (date, mobile = false) => + dates.format(date, { showTime: !mobile }); + const handleSketchRemove = () => { + dispatch( + CollectionsActions.removeFromCollection(collection.id, item.projectId) + ); + }; + + const projectIsDeleted = item.isDeleted; + const projectIsPrivate = + !item.isDeleted && !isOwner && item.project?.visibility === 'Private'; + + let name; + if (projectIsDeleted) { + name = <span>{t('Collection.SketchDeleted')}</span>; + } else if (projectIsPrivate) { + name = <span>Sketch is Private</span>; + } else { + name = ( + <Link to={`/${item.project.user.username}/sketches/${item.projectId}`}> + {item.project.name} + </Link> + ); + } + + const sketchOwnerUsername = + projectIsDeleted || projectIsPrivate ? null : item.project.user.username; + + return ( + <tr + className={`sketches-table__row ${ + projectIsDeleted || projectIsPrivate ? 'is-deleted-or-private' : '' + }`} + > + <th scope="row">{name}</th> + <td>{formatDateCell(item.createdAt)}</td> + <td>{sketchOwnerUsername}</td> + <td className="collection-row__action-column"> + {isOwner && ( + <button + className="collection-row__remove-button" + onClick={handleSketchRemove} + aria-label={t('Collection.SketchRemoveARIA')} + > + <RemoveIcon focusable="false" aria-hidden="true" /> + </button> + )} + </td> + </tr> + ); +}; + +CollectionItemRow.propTypes = { + collection: PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired + }).isRequired, + item: PropTypes.shape({ + createdAt: PropTypes.string.isRequired, + projectId: PropTypes.string.isRequired, + isDeleted: PropTypes.bool.isRequired, + project: PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + visibility: PropTypes.string, + user: PropTypes.shape({ + username: PropTypes.string.isRequired + }) + }).isRequired + }).isRequired, + isOwner: PropTypes.bool.isRequired, + user: PropTypes.shape({ + username: PropTypes.string, + authenticated: PropTypes.bool.isRequired + }).isRequired +}; const Collection = ({ collectionId, username }) => { const { t } = useTranslation(); @@ -95,6 +177,7 @@ const Collection = ({ collectionId, username }) => { ) : ( <ArrowDownIcon role="img" + IST aria-label={t('Collection.DirectionDescendingARIA')} /> ))} diff --git a/client/protected-route.jsx b/client/protected-route.jsx new file mode 100644 index 0000000000..c6e6c1cd19 --- /dev/null +++ b/client/protected-route.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Route, Redirect } from 'react-router-dom'; +import { useSelector } from 'react-redux'; +import { getIsUserOwner } from './modules/IDE/selectors/users'; + +// eslint-disable-next-line react/prop-types +const ProtectedSketchRoute = ({ component: Component, ...rest }) => { + const project = useSelector((state) => state.project); + const isUserOwner = useSelector(getIsUserOwner); + + console.log(project.id); + return ( + <Route + {...rest} + render={(props) => { + // Allow access if user is owner or sketch is public + if (isUserOwner || project.visibility !== 'Private') { + return <Component {...props} />; + } + + // Redirect if not so + return ( + <Redirect + to={{ + pathname: '/' + }} + /> + ); + }} + /> + ); +}; + +export default ProtectedSketchRoute; diff --git a/client/routes.jsx b/client/routes.jsx index 26de7e4e6c..cbe790cd1b 100644 --- a/client/routes.jsx +++ b/client/routes.jsx @@ -19,6 +19,7 @@ import AccountView from './modules/User/pages/AccountView'; import CollectionView from './modules/User/pages/CollectionView'; import DashboardView from './modules/User/pages/DashboardView'; import { getUser } from './modules/User/actions'; +import ProtectedSketchRoute from './protected-route'; /** * `params` is no longer a top-level route component prop in v4. @@ -55,15 +56,21 @@ const routes = ( <Route path="/reset-password" component={ResetPasswordView} /> <Route path="/verify" component={EmailVerificationView} /> <Route path="/projects/:project_id" component={IDEView} /> - <Route path="/:username/full/:project_id" component={FullView} /> - <Route path="/full/:project_id" component={FullView} /> + <ProtectedSketchRoute + path="/:username/full/:project_id" + component={FullView} + /> + <ProtectedSketchRoute path="/full/:project_id" component={FullView} /> <Route path="/:username/assets" component={DashboardView} /> <Route path="/:username/sketches/:project_id/add-to-collection" component={IDEView} /> - <Route path="/:username/sketches/:project_id" component={IDEView} /> + <ProtectedSketchRoute + path="/:username/sketches/:project_id" + component={IDEView} + /> <Route path="/:username/sketches" component={DashboardView} /> <Route path="/:username/collections/:collection_id" diff --git a/client/styles/components/_sketch-list.scss b/client/styles/components/_sketch-list.scss index 19700bf4db..f2b7134d39 100644 --- a/client/styles/components/_sketch-list.scss +++ b/client/styles/components/_sketch-list.scss @@ -1,5 +1,58 @@ @use "sass:math"; +.sketch-visibility__title { + @include themify() { + color: getThemifyVariable('hint-arrow-background-color'); + } +} + +.sketch-visibility__icons { + height: 30px; + width: 30px; + display: flex; + gap: 5px; +} + +.sketch-visibility { + padding: 30px; + display: flex; + flex-direction: column; + align-items: center; +} + +.sketch-visibility hr { + border: none; + height: 1px; + background: linear-gradient(to right, transparent, white, transparent); +} + +.sketch-visibility_ul { + list-style-type: none; + padding: 0; + margin: 0; + margin-bottom: 7px; +} + +.sketch-visibility_ul li { + margin-bottom: 7px; + display: flex; + align-items: center; +} + +.sketch-visibility_ul li::before { + content: "\2022"; + + @include themify() { + color: getThemifyVariable('hint-arrow-background-color'); + } + + font-size: 34px; + margin-right: 10px; +} + + + + .sketches-table-container { overflow-y: auto; max-width: 100%; @@ -26,6 +79,8 @@ flex-direction: column; gap: #{math.div(12, $base-font-size)}rem; + + .sketches-table__row { margin: 0; position: relative; @@ -40,14 +95,15 @@ background-color: getThemifyVariable("search-background-color") !important; } - > th { - padding-left: 0; - width: 100%; + .sketches-table_name { + display: flex; + gap: 5px; font-weight: bold; - margin-bottom: #{math.div(6, $base-font-size)}rem; + align-items: center; } - > td { + + >td { padding-left: 0; width: 30%; font-size: #{math.div(14, $base-font-size)}rem; @@ -57,6 +113,13 @@ } } + .sketches-table__rowname { + display: flex; + gap: 5px; + justify-content: center; + align-items: center; + } + .sketch-list__dropdown-column { position: absolute; top: 0; @@ -75,6 +138,7 @@ max-height: 100%; border-spacing: 0; + & .sketch-list__dropdown-column { width: #{math.div(60, $base-font-size)}rem; position: relative; @@ -86,6 +150,7 @@ position: sticky; top: 0; z-index: 1; + @include themify() { background-color: getThemifyVariable("background-color"); } @@ -111,6 +176,7 @@ .sketches-table__header { border-bottom: 2px dashed transparent; padding: #{math.div(3, $base-font-size)}rem 0; + @include themify() { color: getThemifyVariable("inactive-text-color"); } @@ -138,11 +204,11 @@ } } -.sketches-table__row > th:nth-child(1) { +.sketches-table__row>th:nth-child(1) { padding-left: #{math.div(12, $base-font-size)}rem; } -.sketches-table__row > td { +.sketches-table__row>td { padding-left: #{math.div(8, $base-font-size)}rem; } @@ -152,12 +218,13 @@ } } -.sketches-table__row.is-deleted > * { +.sketches-table__row.is-deleted-or-private>* { font-style: italic; } .sketches-table thead { font-size: #{math.div(12, $base-font-size)}rem; + @include themify() { color: getThemifyVariable("inactive-text-color"); } diff --git a/client/styles/components/_toggle.scss b/client/styles/components/_toggle.scss new file mode 100644 index 0000000000..8d4d73370d --- /dev/null +++ b/client/styles/components/_toggle.scss @@ -0,0 +1,63 @@ +.visibility__toggle-checkbox { + display: none; +} + +.visibility__toggle-label { + position: relative; + cursor: pointer; + width: 50px; + height: 20px; + background: grey; + border-radius: 10px; + transition: background-color 0.3s; + display: flex; + justify-content: center; + align-items: center; +} + +.lock, +.earth { + position: absolute; + height: 12px; + width: 12px; +} + +.lock { + left: 4px; +} + +.earth { + right: 4px; +} + +.visibility__toggle-label::after { + content: ''; + position: absolute; + top: 1px; + left: 1px; + width: 18px; + height: 18px; + background: #fff; + border-radius: 50%; + transition: all 0.3s; + display: flex; + justify-content: center; + align-items: center; +} + +.visibility__toggle-checkbox:checked+.visibility__toggle-label { + background: #ED225D; +} + +.visibility__toggle-checkbox:checked+.visibility__toggle-label::after { + left: calc(100% - 1px); + transform: translateX(-100%); +} + +.visibility__toggle-label:active:after { + width: 30px; +} + +.visibility__toggle-checkbox:checked+.visibility__toggle-label::after { + animation: slideText 0.3s ease-in-out forwards; +} \ No newline at end of file diff --git a/client/styles/components/_toolbar.scss b/client/styles/components/_toolbar.scss index dcb344fd9c..28d95ea51a 100644 --- a/client/styles/components/_toolbar.scss +++ b/client/styles/components/_toolbar.scss @@ -7,23 +7,32 @@ justify-content: center; align-items: center; padding: 0 0 0 #{math.div(3, $base-font-size)}rem; + &--selected { @extend %toolbar-button--selected; } + &:disabled { cursor: auto; - & g, & path { + + & g, + & path { fill: getThemifyVariable('button-border-color'); } + &:hover { background-color: getThemifyVariable('toolbar-button-background-color'); - & g, & path { + + & g, + & path { fill: getThemifyVariable('button-border-color'); } } - } + } } + margin-right: #{math.div(15, $base-font-size)}rem; + span { padding-left: #{math.div(4, $base-font-size)}rem; display: flex; @@ -46,10 +55,12 @@ align-items: center; margin-right: #{math.div(15, $base-font-size)}rem; padding: 0; + &--selected { @extend %toolbar-button--selected; } } + span { display: flex; align-items: center; @@ -66,10 +77,14 @@ justify-content: center; align-items: center; padding: 0; + &--selected { @extend %toolbar-button--selected; } } + + margin-left: auto; + & span { padding-left: #{math.div(1, $base-font-size)}rem; display: flex; @@ -82,8 +97,10 @@ .toolbar__logo { margin-right: #{math.div(30, $base-font-size)}rem; + @include themify() { - & g, & path { + & g, + & path { fill: getThemifyVariable('logo-color'); } } @@ -93,21 +110,42 @@ padding: #{math.div(10, $base-font-size)}rem #{math.div(20, $base-font-size)}rem; display: flex; align-items: center; + @include themify() { border-bottom: 1px dashed map-get($theme-map, 'nav-border-color'); } } +.lock-icon { + height: 60%; + width: 60%; +} + +.unlock-icon { + height: 60%; + width: 60%; +} + .toolbar__project-name-container { margin-left: #{math.div(10, $base-font-size)}rem; padding-left: #{math.div(10, $base-font-size)}rem; - display: flex; - align-items: center; + display: flex; + align-items: center; + gap: #{math.div(16, $base-font-size)}rem; + + > section { + display: flex; + align-items: center; + justify-content: center; + height: 30px; + width: 30px; + } } .toolbar .editable-input__label { @include themify() { color: getThemifyVariable('secondary-text-color'); + & path { fill: getThemifyVariable('secondary-text-color'); } @@ -119,7 +157,7 @@ } .toolbar__project-owner { - margin-left: #{math.div(5, $base-font-size)}rem; + margin: 0; @include themify() { color: getThemifyVariable('secondary-text-color'); } @@ -127,12 +165,15 @@ .toolbar__autorefresh-label { cursor: pointer; + @include themify() { color: getThemifyVariable('secondary-text-color'); + &:hover { color: getThemifyVariable('logo-color'); } } + margin-left: #{math.div(5, $base-font-size)}rem; font-size: #{math.div(12, $base-font-size)}rem; } @@ -142,9 +183,24 @@ align-items: center; } -.checkbox__autorefresh{ +.checkbox__autorefresh { cursor: pointer; - @include themify(){ - accent-color:getThemifyVariable('logo-color'); + + @include themify() { + accent-color: getThemifyVariable('logo-color'); } } + +.toolbar__makeprivate { + display: flex; + align-items: center; + gap: #{math.div(4, $base-font-size)}rem; +} + +.toolbar__togglevisibility { + cursor: pointer; + + @include themify() { + accent-color: getThemifyVariable('logo-color'); + } +} \ No newline at end of file diff --git a/client/styles/main.scss b/client/styles/main.scss index 91e2d93483..b7414a32e1 100644 --- a/client/styles/main.scss +++ b/client/styles/main.scss @@ -52,9 +52,10 @@ @import 'components/collection'; @import 'components/collection-create'; @import 'components/quick-add'; +@import 'components/toggle'; @import 'components/skip-link'; @import 'components/stars'; @import 'components/admonition'; @import 'layout/dashboard'; -@import 'layout/ide'; +@import 'layout/ide'; \ No newline at end of file diff --git a/server/controllers/collection.controller/addProjectToCollection.js b/server/controllers/collection.controller/addProjectToCollection.js index 74a4c3db38..532d6fd328 100644 --- a/server/controllers/collection.controller/addProjectToCollection.js +++ b/server/controllers/collection.controller/addProjectToCollection.js @@ -60,7 +60,7 @@ export default function addProjectToCollection(req, res) { { path: 'owner', select: ['id', 'username'] }, { path: 'items.project', - select: ['id', 'name', 'slug'], + select: ['id', 'name', 'slug', 'visibility'], populate: { path: 'user', select: ['username'] diff --git a/server/controllers/collection.controller/createCollection.js b/server/controllers/collection.controller/createCollection.js index 61838ccd87..0332bebbb3 100644 --- a/server/controllers/collection.controller/createCollection.js +++ b/server/controllers/collection.controller/createCollection.js @@ -24,7 +24,7 @@ export default function createCollection(req, res) { { path: 'owner', select: ['id', 'username'] }, { path: 'items.project', - select: ['id', 'name', 'slug'], + select: ['id', 'name', 'slug', 'visibility'], populate: { path: 'user', select: ['username'] diff --git a/server/controllers/collection.controller/listCollections.js b/server/controllers/collection.controller/listCollections.js index 6920bcf2e1..11faf490a2 100644 --- a/server/controllers/collection.controller/listCollections.js +++ b/server/controllers/collection.controller/listCollections.js @@ -34,7 +34,7 @@ export default async function listCollections(req, res) { { path: 'owner', select: ['id', 'username'] }, { path: 'items.project', - select: ['id', 'name', 'slug'], + select: ['id', 'name', 'slug', 'visibility'], populate: { path: 'user', select: ['username'] diff --git a/server/controllers/collection.controller/removeProjectFromCollection.js b/server/controllers/collection.controller/removeProjectFromCollection.js index f652d1b76c..e5679f254f 100644 --- a/server/controllers/collection.controller/removeProjectFromCollection.js +++ b/server/controllers/collection.controller/removeProjectFromCollection.js @@ -41,7 +41,7 @@ export default function removeProjectFromCollection(req, res) { { path: 'owner', select: ['id', 'username'] }, { path: 'items.project', - select: ['id', 'name', 'slug'], + select: ['id', 'name', 'slug', 'visibility'], populate: { path: 'user', select: ['username'] diff --git a/server/controllers/collection.controller/updateCollection.js b/server/controllers/collection.controller/updateCollection.js index 5df1178d7d..9497b05228 100644 --- a/server/controllers/collection.controller/updateCollection.js +++ b/server/controllers/collection.controller/updateCollection.js @@ -43,7 +43,7 @@ export default function createCollection(req, res) { { path: 'owner', select: ['id', 'username'] }, { path: 'items.project', - select: ['id', 'name', 'slug'], + select: ['id', 'name', 'slug', 'visibility'], populate: { path: 'user', select: ['username'] diff --git a/server/controllers/project.controller.js b/server/controllers/project.controller.js index af8e8e9e9e..a7b5f6a2f8 100644 --- a/server/controllers/project.controller.js +++ b/server/controllers/project.controller.js @@ -288,3 +288,40 @@ export async function downloadProjectAsZip(req, res) { // save project to some path buildZip(project, req, res); } + +export async function changeProjectVisibility(req, res) { + try { + const { projectId, visibility: newVisibility } = req.body; + + const project = await Project.findOne({ + $or: [{ _id: projectId }, { slug: projectId }] + }); + + if (!project) { + return res + .status(404) + .json({ success: false, message: 'No project found.' }); + } + + if (newVisibility !== 'Private' && newVisibility !== 'Public') { + return res.status(400).json({ success: false, message: 'Invalid data.' }); + } + + const updatedProject = await Project.findByIdAndUpdate( + projectId, + { + visibility: newVisibility + }, + { + new: true, + runValidators: true + } + ) + .populate('user', 'username') + .exec(); + + return res.status(200).json(updatedProject); + } catch (error) { + return res.status(500).json(error); + } +} diff --git a/server/controllers/project.controller/getProjectsForUser.js b/server/controllers/project.controller/getProjectsForUser.js index a811ecd55c..072ae3b50b 100644 --- a/server/controllers/project.controller/getProjectsForUser.js +++ b/server/controllers/project.controller/getProjectsForUser.js @@ -10,10 +10,12 @@ import { toApi as toApiProjectObject } from '../../domain-objects/Project'; const createCoreHandler = (mapProjectsToResponse) => async (req, res) => { try { const { username } = req.params; + if (!username) { res.status(422).json({ message: 'Username not provided' }); return; } + const user = await User.findByUsername(username); if (!user) { res @@ -21,13 +23,22 @@ const createCoreHandler = (mapProjectsToResponse) => async (req, res) => { .json({ message: 'User with that username does not exist.' }); return; } - const projects = await Project.find({ user: user._id }) + + const canViewPrivate = req.user && req.user._id.equals(user._id); + + const filter = { user: user._id }; + if (!canViewPrivate) { + filter.visibility = { $ne: 'Private' }; + } + + const projects = await Project.find(filter) .sort('-createdAt') - .select('name files id createdAt updatedAt') + .select('name files id createdAt updatedAt visibility') .exec(); + const response = mapProjectsToResponse(projects); res.json(response); - } catch (e) { + } catch (error) { res.status(500).json({ message: 'Error fetching projects' }); } }; diff --git a/server/models/project.js b/server/models/project.js index a909591f1f..f9555862ce 100644 --- a/server/models/project.js +++ b/server/models/project.js @@ -38,6 +38,11 @@ const projectSchema = new Schema( serveSecure: { type: Boolean, default: false }, files: { type: [fileSchema] }, _id: { type: String, default: shortid.generate }, + visibility: { + type: String, + enum: ['Private', 'Public'], + default: 'Public' + }, slug: { type: String } }, { timestamps: true } @@ -55,7 +60,7 @@ projectSchema.pre('save', function generateSlug(next) { if (!this.slug) { this.slug = slugify(this.name, '_'); } - next(); + return next(); }); /** diff --git a/server/routes/project.routes.js b/server/routes/project.routes.js index 469eb43e92..0610f929ab 100644 --- a/server/routes/project.routes.js +++ b/server/routes/project.routes.js @@ -26,4 +26,6 @@ router.get('/:username/projects', ProjectController.getProjectsForUser); router.get('/projects/:project_id/zip', ProjectController.downloadProjectAsZip); +router.patch('/project/visibility', ProjectController.changeProjectVisibility); + export default router;