1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-19 13:19:44 +02:00

Add project backgrounds

This commit is contained in:
Maksim Eltyshev 2020-05-26 00:46:04 +05:00
parent fb9ceb5db7
commit 2f7a244807
67 changed files with 774 additions and 210 deletions

View file

@ -14,6 +14,13 @@ export const updateCurrentProject = (data) => ({
}, },
}); });
export const updateCurrentProjectBackgroundImage = (data) => ({
type: EntryActionTypes.CURRENT_PROJECT_BACKGROUND_IMAGE_UPDATE,
payload: {
data,
},
});
export const deleteCurrentProject = () => ({ export const deleteCurrentProject = () => ({
type: EntryActionTypes.CURRENT_PROJECT_DELETE, type: EntryActionTypes.CURRENT_PROJECT_DELETE,
payload: {}, payload: {},

View file

@ -90,6 +90,28 @@ export const updateProjectReceived = (project) => ({
}, },
}); });
export const updateProjectBackgroundImageRequested = (id) => ({
type: ActionTypes.PROJECT_BACKGROUND_IMAGE_UPDATE_REQUESTED,
payload: {
id,
},
});
export const updateProjectBackgroundImageSucceeded = (project) => ({
type: ActionTypes.PROJECT_BACKGROUND_IMAGE_UPDATE_SUCCEEDED,
payload: {
project,
},
});
export const updateProjectBackgroundImageFailed = (id, error) => ({
type: ActionTypes.PROJECT_BACKGROUND_IMAGE_UPDATE_FAILED,
payload: {
id,
error,
},
});
export const deleteProjectRequested = (id) => ({ export const deleteProjectRequested = (id) => ({
type: ActionTypes.PROJECT_DELETE_REQUESTED, type: ActionTypes.PROJECT_DELETE_REQUESTED,
payload: { payload: {

View file

@ -170,11 +170,10 @@ export const updateUserEmailRequested = (id, data) => ({
}, },
}); });
export const updateUserEmailSucceeded = (id, email) => ({ export const updateUserEmailSucceeded = (user) => ({
type: ActionTypes.USER_EMAIL_UPDATE_SUCCEEDED, type: ActionTypes.USER_EMAIL_UPDATE_SUCCEEDED,
payload: { payload: {
id, user,
email,
}, },
}); });
@ -194,10 +193,10 @@ export const updateUserPasswordRequested = (id, data) => ({
}, },
}); });
export const updateUserPasswordSucceeded = (id) => ({ export const updateUserPasswordSucceeded = (user) => ({
type: ActionTypes.USER_PASSWORD_UPDATE_SUCCEEDED, type: ActionTypes.USER_PASSWORD_UPDATE_SUCCEEDED,
payload: { payload: {
id, user,
}, },
}); });
@ -217,11 +216,10 @@ export const updateUserUsernameRequested = (id, data) => ({
}, },
}); });
export const updateUserUsernameSucceeded = (id, username) => ({ export const updateUserUsernameSucceeded = (user) => ({
type: ActionTypes.USER_USERNAME_UPDATE_SUCCEEDED, type: ActionTypes.USER_USERNAME_UPDATE_SUCCEEDED,
payload: { payload: {
id, user,
username,
}, },
}); });
@ -240,11 +238,10 @@ export const updateUserAvatarRequested = (id) => ({
}, },
}); });
export const updateUserAvatarSucceeded = (id, avatarUrl) => ({ export const updateUserAvatarSucceeded = (user) => ({
type: ActionTypes.USER_AVATAR_UPDATE_SUCCEEDED, type: ActionTypes.USER_AVATAR_UPDATE_SUCCEEDED,
payload: { payload: {
id, user,
avatarUrl,
}, },
}); });

View file

@ -1,3 +1,4 @@
import http from './http';
import socket from './socket'; import socket from './socket';
/* Actions */ /* Actions */
@ -8,11 +9,15 @@ const createProject = (data, headers) => socket.post('/projects', data, headers)
const updateProject = (id, data, headers) => socket.patch(`/projects/${id}`, data, headers); const updateProject = (id, data, headers) => socket.patch(`/projects/${id}`, data, headers);
const updateProjectBackgroundImage = (id, data, headers) =>
http.post(`/projects/${id}/background-image`, data, headers);
const deleteProject = (id, headers) => socket.delete(`/projects/${id}`, undefined, headers); const deleteProject = (id, headers) => socket.delete(`/projects/${id}`, undefined, headers);
export default { export default {
getProjects, getProjects,
createProject, createProject,
updateProject, updateProject,
updateProjectBackgroundImage,
deleteProject, deleteProject,
}; };

View file

@ -19,8 +19,7 @@ const updateUserPassword = (id, data, headers) =>
const updateUserUsername = (id, data, headers) => const updateUserUsername = (id, data, headers) =>
socket.patch(`/users/${id}/username`, data, headers); socket.patch(`/users/${id}/username`, data, headers);
const updateUserAvatar = (id, data, headers) => const updateUserAvatar = (id, data, headers) => http.post(`/users/${id}/avatar`, data, headers);
http.post(`/users/${id}/update-avatar`, data, headers);
const deleteUser = (id, headers) => socket.delete(`/users/${id}`, undefined, headers); const deleteUser = (id, headers) => socket.delete(`/users/${id}`, undefined, headers);

View file

@ -23,7 +23,7 @@
} }
.wrapper { .wrapper {
background-color: #e2e4e6; background: #e2e4e6;
border-radius: 3px; border-radius: 3px;
padding: 4px; padding: 4px;
transition: opacity 40ms ease-in; transition: opacity 40ms ease-in;

View file

@ -110,6 +110,14 @@ const Board = React.memo(
prevPosition.current = null; prevPosition.current = null;
}, [prevPosition]); }, [prevPosition]);
useEffect(() => {
document.body.style.overflowX = 'auto';
return () => {
document.body.style.overflowX = null;
};
}, []);
useEffect(() => { useEffect(() => {
if (isAddListOpened) { if (isAddListOpened) {
window.scroll(document.body.scrollWidth, 0); window.scroll(document.body.scrollWidth, 0);

View file

@ -1,11 +1,11 @@
.addListButton { .addListButton {
background: hsla(0, 0%, 0%, 0.24); background: rgba(0, 0, 0, 0.24);
border: none; border: none;
border-radius: 3px; border-radius: 3px;
color: hsla(0, 0%, 100%, 0.72); color: rgba(255, 255, 255, 0.72);
cursor: pointer; cursor: pointer;
display: block; display: block;
fill: hsla(0, 0%, 100%, 0.72); fill: rgba(255, 255, 255, 0.72);
font-weight: normal; font-weight: normal;
height: 42px; height: 42px;
padding: 11px; padding: 11px;
@ -20,7 +20,7 @@
} }
.addListButton:hover { .addListButton:hover {
background: hsla(0, 0%, 0%, 0.32); background: rgba(0, 0, 0, 0.32);
} }
.addListButtonIcon { .addListButtonIcon {

View file

@ -23,7 +23,7 @@
} }
.filterLabel { .filterLabel {
background: hsla(0, 0%, 0%, 0.24); background: rgba(0, 0, 0, 0.24);
border-radius: 3px; border-radius: 3px;
color: #fff; color: #fff;
display: inline-block; display: inline-block;
@ -33,7 +33,7 @@
} }
.filterLabel:hover { .filterLabel:hover {
background: hsla(0, 0%, 0%, 0.32); background: rgba(0, 0, 0, 0.32);
} }
.filterTitle { .filterTitle {

View file

@ -24,7 +24,7 @@
} }
.editButton:hover { .editButton:hover {
background: hsla(0, 0%, 100%, 0.08) !important; background: rgba(255, 255, 255, 0.08) !important;
} }
.link { .link {
@ -45,7 +45,7 @@
} }
.tab:hover { .tab:hover {
background: hsla(0, 0%, 0%, 0.24) !important; background: rgba(0, 0, 0, 0.24) !important;
} }
.tab:hover .target { .tab:hover .target {
@ -53,11 +53,11 @@
} }
.tabActive { .tabActive {
background: hsla(0, 0%, 0%, 0.24) !important; background: rgba(0, 0, 0, 0.24) !important;
} }
.tabActive:hover { .tabActive:hover {
background: hsla(0, 0%, 0%, 0.32) !important; background: rgba(0, 0, 0, 0.32) !important;
} }
.tabWrapper { .tabWrapper {
@ -66,7 +66,7 @@
} }
.tabs { .tabs {
border-bottom: 2px solid hsla(0, 0%, 0%, 0.24); border-bottom: 2px solid rgba(0, 0, 0, 0.24);;
display: flex; display: flex;
height: 38px; height: 38px;
flex: 0 0 auto; flex: 0 0 auto;

View file

@ -49,7 +49,7 @@
} }
.card { .card {
background-color: #fff; background: #fff;
border-radius: 3px; border-radius: 3px;
box-shadow: 0 1px 0 #ccc; box-shadow: 0 1px 0 #ccc;
position: relative; position: relative;
@ -57,7 +57,7 @@
} }
.card:hover { .card:hover {
background-color: #f5f6f7; background: #f5f6f7;
border-bottom-color: rgba(9, 30, 66, 0.25); border-bottom-color: rgba(9, 30, 66, 0.25);
} }

View file

@ -10,7 +10,7 @@
} }
.fieldWrapper { .fieldWrapper {
background-color: #fff !important; background: #fff !important;
border-radius: 3px !important; border-radius: 3px !important;
box-shadow: 0 1px 0 #ccc !important; box-shadow: 0 1px 0 #ccc !important;
margin-bottom: 8px !important; margin-bottom: 8px !important;

View file

@ -22,7 +22,7 @@
} }
.text { .text {
background-color: #fff; background: #fff;
border-radius: 0px 8px 8px; border-radius: 0px 8px 8px;
box-shadow: 0 1px 2px -1px rgba(9, 30, 66, 0.25), box-shadow: 0 1px 2px -1px rgba(9, 30, 66, 0.25),
0 0 0 1px rgba(9, 30, 66, 0.08); 0 0 0 1px rgba(9, 30, 66, 0.08);

View file

@ -1,5 +1,5 @@
.divider { .divider {
background-color: #eee; background: #eee;
border: 0; border: 0;
height: 1px; height: 1px;
margin-bottom: 8px; margin-bottom: 8px;

View file

@ -60,7 +60,7 @@ const Item = React.memo(
<div <div
className={styles.thumbnail} className={styles.thumbnail}
style={{ style={{
backgroundImage: coverUrl && `url(${coverUrl}`, background: coverUrl && `url("${coverUrl}") center / cover`,
}} }}
> >
{coverUrl ? ( {coverUrl ? (

View file

@ -13,7 +13,7 @@
} }
.button:hover { .button:hover {
background-color: rgba(9, 30, 66, 0.08) !important; background: rgba(9, 30, 66, 0.08) !important;
} }
.date { .date {
@ -84,10 +84,7 @@
.thumbnail { .thumbnail {
border-radius: 3px; border-radius: 3px;
background-color: rgba(9, 30, 66, 0.04); background: rgba(9, 30, 66, 0.04);
background-position: center;
background-repeat: no-repeat;
background-size: cover;
border-radius: 3px; border-radius: 3px;
height: 80px; height: 80px;
left: 0; left: 0;
@ -112,7 +109,7 @@
} }
.wrapper:hover .details { .wrapper:hover .details {
background-color: rgba(9, 30, 66, 0.04); background: rgba(9, 30, 66, 0.04);
} }
.wrapper:hover .target { .wrapper:hover .target {
@ -120,5 +117,5 @@
} }
.wrapperSubmitting { .wrapperSubmitting {
background-color: rgba(9, 30, 66, 0.04); background: rgba(9, 30, 66, 0.04);
} }

View file

@ -100,7 +100,7 @@
} }
.descriptionButton:hover { .descriptionButton:hover {
background-color: rgba(9, 30, 66, 0.08); background: rgba(9, 30, 66, 0.08);
color: #092d42; color: #092d42;
} }

View file

@ -13,7 +13,7 @@
} }
.button:hover { .button:hover {
background-color: rgba(9, 30, 66, 0.08) !important; background: rgba(9, 30, 66, 0.08) !important;
} }
.checkboxWrapper { .checkboxWrapper {
@ -30,7 +30,7 @@
} }
.content:hover { .content:hover {
background-color: rgba(9, 30, 66, 0.04); background: rgba(9, 30, 66, 0.04);
} }
.content:hover .target { .content:hover .target {

View file

@ -20,7 +20,7 @@
} }
.taskButton:hover { .taskButton:hover {
background-color: rgba(9, 30, 66, 0.04); background: rgba(9, 30, 66, 0.04);
color: #092d42; color: #092d42;
} }

View file

@ -3,7 +3,7 @@
} }
.item:hover { .item:hover {
background: hsla(0, 0%, 0%, 0.32) !important; background: rgba(0, 0, 0, 0.32) !important;
} }
.logo { .logo {
@ -52,7 +52,7 @@
} }
.wrapper { .wrapper {
background: hsla(0, 0%, 0%, 0.24); background: rgba(0, 0, 0, 0.24);
display: flex; display: flex;
flex: 0 0 auto; flex: 0 0 auto;
} }

View file

@ -9,7 +9,7 @@
} }
.fieldWrapper { .fieldWrapper {
background-color: #fff !important; background: #fff !important;
border-radius: 3px !important; border-radius: 3px !important;
box-shadow: 0 1px 0 #ccc !important; box-shadow: 0 1px 0 #ccc !important;
margin-bottom: 8px !important; margin-bottom: 8px !important;

View file

@ -16,7 +16,7 @@
} }
.addCardButton:hover { .addCardButton:hover {
background-color: #c3cbd0 !important; background: #c3cbd0 !important;
color: #17394d !important; color: #17394d !important;
fill: #17394d !important; fill: #17394d !important;
} }
@ -70,7 +70,7 @@
} }
.headerButton:hover { .headerButton:hover {
background-color: rgba(9, 30, 66, 0.13) !important; background: rgba(9, 30, 66, 0.13) !important;
color: #516b7a !important; color: #516b7a !important;
} }

View file

@ -1,6 +1,5 @@
.cover { .cover {
background: url("../../assets/images/cover.jpg") no-repeat center center; background: url("../../assets/images/cover.jpg") center / cover !important;
background-size: cover;
} }
.descriptionSubtitle { .descriptionSubtitle {
@ -20,7 +19,7 @@
} }
.descriptionWrapperOverlay { .descriptionWrapperOverlay {
background-color: rgba(33, 33, 33, 0.5); background: rgba(33, 33, 33, 0.5);
bottom: 0; bottom: 0;
height: 100%; height: 100%;
left: 0; left: 0;
@ -37,7 +36,7 @@
} }
.fullHeight { .fullHeight {
background-color: #fff; background: #fff;
height: 100%; height: 100%;
} }

View file

@ -0,0 +1,142 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react';
import { withPopup } from '../../../lib/popup';
import { Popup } from '../../../lib/custom-ui';
import { useSteps } from '../../../hooks';
import EditNameStep from './EditNameStep';
import EditBackgroundStep from './EditBackgroundStep';
import DeleteStep from '../../DeleteStep';
import styles from './ActionsPopup.module.css';
const StepTypes = {
EDIT_NAME: 'EDIT_NAME',
EDIT_BACKGROUND: 'EDIT_BACKGROUND',
DELETE: 'DELETE',
};
const ActionsStep = React.memo(
({ project, onUpdate, onBackgroundImageUpdate, onDelete, onClose }) => {
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
const handleEditNameClick = useCallback(() => {
openStep(StepTypes.EDIT_NAME);
}, [openStep]);
const handleEditBackgroundClick = useCallback(() => {
openStep(StepTypes.EDIT_BACKGROUND);
}, [openStep]);
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [openStep]);
const handleNameUpdate = useCallback(
(newName) => {
onUpdate({
name: newName,
});
},
[onUpdate],
);
const handleBackgroundUpdate = useCallback(
(newBackground) => {
onUpdate({
background: newBackground,
});
},
[onUpdate],
);
const handleBackgroundImageDelete = useCallback(() => {
onUpdate({
background: null,
backgroundImage: null,
});
}, [onUpdate]);
if (step) {
if (step) {
switch (step.type) {
case StepTypes.EDIT_NAME:
return (
<EditNameStep
defaultValue={project.name}
onUpdate={handleNameUpdate}
onBack={handleBack}
onClose={onClose}
/>
);
case StepTypes.EDIT_BACKGROUND:
return (
<EditBackgroundStep
defaultValue={project.background}
isImageUpdating={project.isBackgroundImageUpdating}
onUpdate={handleBackgroundUpdate}
onImageUpdate={onBackgroundImageUpdate}
onImageDelete={handleBackgroundImageDelete}
onBack={handleBack}
/>
);
case StepTypes.DELETE:
return (
<DeleteStep
title={t('common.deleteProject', {
context: 'title',
})}
content={t('common.areYouSureYouWantToDeleteThisProject')}
buttonContent={t('action.deleteProject')}
onConfirm={onDelete}
onBack={handleBack}
/>
);
default:
}
}
}
return (
<>
<Popup.Header>
{t('common.projectActions', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
<Menu.Item className={styles.menuItem} onClick={handleEditNameClick}>
{t('action.editTitle', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleEditBackgroundClick}>
{t('action.editBackground', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
{t('action.deleteProject', {
context: 'title',
})}
</Menu.Item>
</Menu>
</Popup.Content>
</>
);
},
);
ActionsStep.propTypes = {
project: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
onUpdate: PropTypes.func.isRequired,
onBackgroundImageUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default withPopup(ActionsStep);

View file

@ -0,0 +1,9 @@
.menu {
margin: -7px -12px -5px !important;
width: calc(100% + 24px) !important;
}
.menuItem {
margin: 0 !important;
padding-left: 14px !important;
}

View file

@ -0,0 +1,74 @@
import React, { useCallback, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button } from 'semantic-ui-react';
import { FilePicker, Popup } from '../../../lib/custom-ui';
import styles from './EditBackgroundStep.module.css';
const EditBackgroundStep = React.memo(
({ defaultValue, isImageUpdating, onImageUpdate, onImageDelete, onBack }) => {
const [t] = useTranslation();
const field = useRef(null);
const handleFileSelect = useCallback(
(file) => {
onImageUpdate({
file,
});
},
[onImageUpdate],
);
useEffect(() => {
field.current.focus();
}, []);
return (
<>
<Popup.Header onBack={onBack}>
{t('common.editBackground', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<div className={styles.action}>
<FilePicker accept="image/*" onSelect={handleFileSelect}>
<Button
ref={field}
content={t('action.uploadNewBackground')}
loading={isImageUpdating}
disabled={isImageUpdating}
className={styles.actionButton}
/>
</FilePicker>
</div>
{defaultValue && (
<Button
negative
content={t('action.deleteBackground')}
disabled={isImageUpdating}
onClick={onImageDelete}
/>
)}
</Popup.Content>
</>
);
},
);
EditBackgroundStep.propTypes = {
defaultValue: PropTypes.object, // eslint-disable-line react/forbid-prop-types
isImageUpdating: PropTypes.bool.isRequired,
// onUpdate: PropTypes.func.isRequired,
onImageUpdate: PropTypes.func.isRequired,
onImageDelete: PropTypes.func.isRequired,
onBack: PropTypes.func.isRequired,
};
EditBackgroundStep.defaultProps = {
defaultValue: undefined,
};
export default EditBackgroundStep;

View file

@ -0,0 +1,25 @@
.action {
border: none;
display: inline-block;
height: 36px;
overflow: hidden;
position: relative;
transition: background 0.3s ease;
width: 100%;
}
.action:hover {
background: #e9e9e9 !important;
}
.actionButton {
background: transparent !important;
color: #6b808c !important;
font-weight: normal !important;
height: 36px;
line-height: 24px !important;
padding: 6px 12px !important;
text-align: left !important;
text-decoration: underline !important;
width: 100%;
}

View file

@ -0,0 +1,67 @@
import React, { useCallback, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form } from 'semantic-ui-react';
import { Input, Popup } from '../../../lib/custom-ui';
import { useField } from '../../../hooks';
import styles from './EditNameStep.module.css';
const EditNameStep = React.memo(({ defaultValue, onUpdate, onBack, onClose }) => {
const [t] = useTranslation();
const [value, handleFieldChange] = useField(defaultValue);
const field = useRef(null);
const handleSubmit = useCallback(() => {
const cleanValue = value.trim();
if (!cleanValue) {
field.current.select();
return;
}
if (cleanValue !== defaultValue) {
onUpdate(cleanValue);
}
onClose();
}, [defaultValue, onUpdate, onClose, value]);
useEffect(() => {
field.current.select();
}, []);
return (
<>
<Popup.Header onBack={onBack}>
{t('common.editTitle', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Form onSubmit={handleSubmit}>
<div className={styles.text}>{t('common.title')}</div>
<Input
fluid
ref={field}
value={value}
className={styles.field}
onChange={handleFieldChange}
/>
<Button positive content={t('action.save')} />
</Form>
</Popup.Content>
</>
);
});
EditNameStep.propTypes = {
defaultValue: PropTypes.string.isRequired,
onUpdate: PropTypes.func.isRequired,
onBack: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default EditNameStep;

View file

@ -1,10 +1,3 @@
.deleteButton {
bottom: 12px;
box-shadow: 0 1px 0 #cbcccc;
position: absolute;
right: 9px;
}
.field { .field {
margin-bottom: 8px; margin-bottom: 8px;
} }

View file

@ -0,0 +1,3 @@
import ActionsPopup from './ActionsPopup';
export default ActionsPopup;

View file

@ -1,107 +0,0 @@
import dequal from 'dequal';
import React, { useCallback, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form } from 'semantic-ui-react';
import { withPopup } from '../../lib/popup';
import { Input, Popup } from '../../lib/custom-ui';
import { useForm, useSteps } from '../../hooks';
import DeleteStep from '../DeleteStep';
import styles from './EditPopup.module.css';
const StepTypes = {
DELETE: 'DELETE',
};
const EditStep = React.memo(({ defaultData, onUpdate, onDelete, onClose }) => {
const [t] = useTranslation();
const [data, handleFieldChange] = useForm(() => ({
name: '',
...defaultData,
}));
const [step, openStep, handleBack] = useSteps();
const nameField = useRef(null);
const handleSubmit = useCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),
};
if (!cleanData.name) {
nameField.current.select();
return;
}
if (!dequal(cleanData, defaultData)) {
onUpdate(cleanData);
}
onClose();
}, [defaultData, onUpdate, onClose, data]);
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [openStep]);
useEffect(() => {
nameField.current.select();
}, []);
if (step && step.type === StepTypes.DELETE) {
return (
<DeleteStep
title={t('common.deleteProject', {
context: 'title',
})}
content={t('common.areYouSureYouWantToDeleteThisProject')}
buttonContent={t('action.deleteProject')}
onConfirm={onDelete}
onBack={handleBack}
/>
);
}
return (
<>
<Popup.Header>
{t('common.editProject', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Form onSubmit={handleSubmit}>
<div className={styles.text}>{t('common.title')}</div>
<Input
fluid
ref={nameField}
name="name"
value={data.name}
className={styles.field}
onChange={handleFieldChange}
/>
<Button positive content={t('action.save')} />
</Form>
<Button
content={t('action.delete')}
className={styles.deleteButton}
onClick={handleDeleteClick}
/>
</Popup.Content>
</>
);
});
EditStep.propTypes = {
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
onUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default withPopup(EditStep);

View file

@ -1,9 +1,9 @@
import React, { useCallback } from 'react'; import React, { useCallback, useEffect } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Button, Grid } from 'semantic-ui-react'; import { Button, Grid } from 'semantic-ui-react';
import BoardsContainer from '../../containers/BoardsContainer'; import BoardsContainer from '../../containers/BoardsContainer';
import EditPopup from './EditPopup'; import ActionsPopup from './ActionsPopup';
import AddMembershipPopup from './AddMembershipPopup'; import AddMembershipPopup from './AddMembershipPopup';
import EditMembershipPopup from './EditMembershipPopup'; import EditMembershipPopup from './EditMembershipPopup';
import User from '../User'; import User from '../User';
@ -13,10 +13,14 @@ import styles from './Project.module.css';
const Project = React.memo( const Project = React.memo(
({ ({
name, name,
background,
backgroundImage,
isBackgroundImageUpdating,
memberships, memberships,
allUsers, allUsers,
isEditable, isEditable,
onUpdate, onUpdate,
onBackgroundImageUpdate,
onDelete, onDelete,
onMembershipCreate, onMembershipCreate,
onMembershipDelete, onMembershipDelete,
@ -28,21 +32,41 @@ const Project = React.memo(
[onMembershipDelete], [onMembershipDelete],
); );
useEffect(() => {
return () => {
document.body.style.background = null;
};
}, []);
useEffect(() => {
if (background) {
if (background.type === 'image') {
document.body.style.background = `url(${backgroundImage.url}) center / cover fixed #22252a`;
}
} else {
document.body.style.background = null;
}
}, [background, backgroundImage]);
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<Grid className={styles.header}> <Grid className={styles.header}>
<Grid.Row> <Grid.Row>
<Grid.Column> <Grid.Column>
{isEditable ? ( {isEditable ? (
<EditPopup <ActionsPopup
defaultData={{ project={{
name, name,
background,
backgroundImage,
isBackgroundImageUpdating,
}} }}
onUpdate={onUpdate} onUpdate={onUpdate}
onBackgroundImageUpdate={onBackgroundImageUpdate}
onDelete={onDelete} onDelete={onDelete}
> >
<Button content={name} disabled={!isEditable} className={styles.name} /> <Button content={name} disabled={!isEditable} className={styles.name} />
</EditPopup> </ActionsPopup>
) : ( ) : (
<span className={styles.name}>{name}</span> <span className={styles.name}>{name}</span>
)} )}
@ -85,14 +109,25 @@ const Project = React.memo(
Project.propTypes = { Project.propTypes = {
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
/* eslint-disable react/forbid-prop-types */ /* eslint-disable react/forbid-prop-types */
background: PropTypes.object,
backgroundImage: PropTypes.object,
/* eslint-enable react/forbid-prop-types */
isBackgroundImageUpdating: PropTypes.bool.isRequired,
/* eslint-disable react/forbid-prop-types */
memberships: PropTypes.array.isRequired, memberships: PropTypes.array.isRequired,
allUsers: PropTypes.array.isRequired, allUsers: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */ /* eslint-enable react/forbid-prop-types */
isEditable: PropTypes.bool.isRequired, isEditable: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired, onUpdate: PropTypes.func.isRequired,
onBackgroundImageUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired,
onMembershipCreate: PropTypes.func.isRequired, onMembershipCreate: PropTypes.func.isRequired,
onMembershipDelete: PropTypes.func.isRequired, onMembershipDelete: PropTypes.func.isRequired,
}; };
Project.defaultProps = {
background: undefined,
backgroundImage: undefined,
};
export default Project; export default Project;

View file

@ -1,5 +1,5 @@
.addUser { .addUser {
background: hsla(0, 0%, 0%, 0.24) !important; background: rgba(0, 0, 0, 0.24) !important;
border-radius: 50% !important; border-radius: 50% !important;
box-shadow: none !important; box-shadow: none !important;
color: #fff !important; color: #fff !important;
@ -12,7 +12,7 @@
} }
.addUser:hover { .addUser:hover {
background: hsla(0, 0%, 0%, 0.32) !important; background: rgba(0, 0, 0, 0.32) !important;
} }
.header { .header {
@ -46,7 +46,7 @@
} }
.wrapper { .wrapper {
background: hsla(0, 0%, 0%, 0.08); background: rgba(0, 0, 0, 0.08);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;

View file

@ -25,7 +25,15 @@ const Projects = React.memo(({ items, isEditable, onAdd }) => {
: Paths.PROJECTS.replace(':id', item.id) : Paths.PROJECTS.replace(':id', item.id)
} }
> >
<div className={classNames(styles.card, styles.open)}> <div
className={classNames(styles.card, styles.open)}
style={{
background:
item.background &&
item.background.type === 'image' &&
`url("${item.backgroundImage.coverUrl}") center / cover`,
}}
>
{item.notificationsTotal > 0 && ( {item.notificationsTotal > 0 && (
<span className={styles.notification}>{item.notificationsTotal}</span> <span className={styles.notification}>{item.notificationsTotal}</span>
)} )}

View file

@ -75,7 +75,6 @@
.open { .open {
background: #555; background: #555;
background-size: cover;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.1), 0 6px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 3px 6px rgba(0, 0, 0, 0.1), 0 6px 12px rgba(0, 0, 0, 0.1);
color: #fff; color: #fff;
position: relative; position: relative;

View file

@ -1,4 +1,4 @@
import React, { useEffect } from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { useTranslation, Trans } from 'react-i18next'; import { useTranslation, Trans } from 'react-i18next';
@ -12,10 +12,6 @@ import styles from './StaticWrapper.module.css';
const StaticWrapper = ({ cardId, boardId, projectId }) => { const StaticWrapper = ({ cardId, boardId, projectId }) => {
const [t] = useTranslation(); const [t] = useTranslation();
useEffect(() => {
document.body.style.overflowX = 'auto'; // TODO: only for board
}, []);
if (projectId === undefined) { if (projectId === undefined) {
return ( return (
<div className={styles.root}> <div className={styles.root}>

View file

@ -75,7 +75,7 @@ const getColor = (name) => {
const User = React.memo(({ name, avatarUrl, size, isDisabled, onClick }) => { const User = React.memo(({ name, avatarUrl, size, isDisabled, onClick }) => {
const style = { const style = {
...STYLES[size], ...STYLES[size],
background: avatarUrl ? `url("${avatarUrl}")` : getColor(name), background: avatarUrl ? `url("${avatarUrl}") center / cover` : getColor(name),
}; };
const contentNode = ( const contentNode = (

View file

@ -12,9 +12,6 @@
} }
.wrapper { .wrapper {
background-position: center center !important;
background-repeat: no-repeat !important;
background-size: cover !important;
border: none; border: none;
border-radius: 50%; border-radius: 50%;
color: #fff; color: #fff;

View file

@ -87,6 +87,9 @@ export default {
PROJECT_UPDATE_SUCCEEDED: 'PROJECT_UPDATE_SUCCEEDED', PROJECT_UPDATE_SUCCEEDED: 'PROJECT_UPDATE_SUCCEEDED',
PROJECT_UPDATE_FAILED: 'PROJECT_UPDATE_FAILED', PROJECT_UPDATE_FAILED: 'PROJECT_UPDATE_FAILED',
PROJECT_UPDATE_RECEIVED: 'PROJECT_UPDATE_RECEIVED', PROJECT_UPDATE_RECEIVED: 'PROJECT_UPDATE_RECEIVED',
PROJECT_BACKGROUND_IMAGE_UPDATE_REQUESTED: 'PROJECT_BACKGROUND_IMAGE_UPDATE_REQUESTED',
PROJECT_BACKGROUND_IMAGE_UPDATE_SUCCEEDED: 'PROJECT_BACKGROUND_IMAGE_UPDATE_SUCCEEDED',
PROJECT_BACKGROUND_IMAGE_UPDATE_FAILED: 'PROJECT_BACKGROUND_IMAGE_UPDATE_FAILED',
PROJECT_DELETE_REQUESTED: 'PROJECT_DELETE_REQUESTED', PROJECT_DELETE_REQUESTED: 'PROJECT_DELETE_REQUESTED',
PROJECT_DELETE_SUCCEEDED: 'PROJECT_DELETE_SUCCEEDED', PROJECT_DELETE_SUCCEEDED: 'PROJECT_DELETE_SUCCEEDED',
PROJECT_DELETE_FAILED: 'PROJECT_DELETE_FAILED', PROJECT_DELETE_FAILED: 'PROJECT_DELETE_FAILED',

View file

@ -39,6 +39,7 @@ export default {
PROJECT_CREATE: `${PREFIX}/PROJECT_CREATE`, PROJECT_CREATE: `${PREFIX}/PROJECT_CREATE`,
CURRENT_PROJECT_UPDATE: `${PREFIX}/CURRENT_PROJECT_UPDATE`, CURRENT_PROJECT_UPDATE: `${PREFIX}/CURRENT_PROJECT_UPDATE`,
CURRENT_PROJECT_BACKGROUND_IMAGE_UPDATE: `${PREFIX}/CURRENT_PROJECT_BACKGROUND_IMAGE_UPDATE`,
CURRENT_PROJECT_DELETE: `${PREFIX}/CURRENT_PROJECT_DELETE`, CURRENT_PROJECT_DELETE: `${PREFIX}/CURRENT_PROJECT_DELETE`,
/* Project membership */ /* Project membership */

View file

@ -12,17 +12,23 @@ import {
deleteCurrentProject, deleteCurrentProject,
deleteProjectMembership, deleteProjectMembership,
updateCurrentProject, updateCurrentProject,
updateCurrentProjectBackgroundImage,
} from '../actions/entry'; } from '../actions/entry';
import Project from '../components/Project'; import Project from '../components/Project';
const mapStateToProps = (state) => { const mapStateToProps = (state) => {
const allUsers = allUsersSelector(state); const allUsers = allUsersSelector(state);
const { isAdmin } = currentUserSelector(state); const { isAdmin } = currentUserSelector(state);
const { name } = currentProjectSelector(state); const { name, background, backgroundImage, isBackgroundImageUpdating } = currentProjectSelector(
state,
);
const memberships = membershipsForCurrentProjectSelector(state); const memberships = membershipsForCurrentProjectSelector(state);
return { return {
name, name,
background,
backgroundImage,
isBackgroundImageUpdating,
memberships, memberships,
allUsers, allUsers,
isEditable: isAdmin, isEditable: isAdmin,
@ -33,6 +39,7 @@ const mapDispatchToProps = (dispatch) =>
bindActionCreators( bindActionCreators(
{ {
onUpdate: updateCurrentProject, onUpdate: updateCurrentProject,
onBackgroundImageUpdate: updateCurrentProjectBackgroundImage,
onDelete: deleteCurrentProject, onDelete: deleteCurrentProject,
onMembershipCreate: createMembershipInCurrentProject, onMembershipCreate: createMembershipInCurrentProject,
onMembershipDelete: deleteProjectMembership, onMembershipDelete: deleteProjectMembership,

View file

@ -33,7 +33,7 @@ body {
} }
.react-datepicker__header { .react-datepicker__header {
background-color: #fff; background: #fff;
border: 0; border: 0;
padding: 0; padding: 0;
} }
@ -57,7 +57,7 @@ body {
.react-datepicker__day--in-selecting-range, .react-datepicker__day--in-selecting-range,
.react-datepicker__day--in-range { .react-datepicker__day--in-range {
border-radius: 0 !important; border-radius: 0 !important;
background-color: #216ba5 !important; background: #216ba5 !important;
color: #fff !important; color: #fff !important;
} }

View file

@ -61,13 +61,14 @@ export default {
dropFileToUpload: 'Drop file to upload', dropFileToUpload: 'Drop file to upload',
editAttachment_title: 'Edit Attachment', editAttachment_title: 'Edit Attachment',
editAvatar_title: 'Edit Avatar', editAvatar_title: 'Edit Avatar',
editBackground_title: 'Edit Background',
editBoard_title: 'Edit Board', editBoard_title: 'Edit Board',
editDueDate_title: 'Edit Due Date', editDueDate_title: 'Edit Due Date',
editEmail_title: 'Edit E-mail', editEmail_title: 'Edit E-mail',
editLabel_title: 'Edit Label', editLabel_title: 'Edit Label',
editPassword_title: 'Edit Password', editPassword_title: 'Edit Password',
editProject_title: 'Edit Project',
editTimer_title: 'Edit Timer', editTimer_title: 'Edit Timer',
editTitle_title: 'Edit Title',
editUsername_title: 'Edit Username', editUsername_title: 'Edit Username',
email: 'E-mail', email: 'E-mail',
emailAlreadyInUse: 'E-mail already in use', emailAlreadyInUse: 'E-mail already in use',
@ -106,6 +107,7 @@ export default {
pressPasteShortcutToAddAttachmentFromClipboard: pressPasteShortcutToAddAttachmentFromClipboard:
'Tip: press Ctrl-V (Cmd-V on Mac) to add an attachment from the clipboard.', 'Tip: press Ctrl-V (Cmd-V on Mac) to add an attachment from the clipboard.',
project: 'Project', project: 'Project',
projectActions_title: 'Project Actions',
projectNotFound_title: 'Project Not Found', projectNotFound_title: 'Project Not Found',
refreshPageToLoadLastDataAndReceiveUpdates: refreshPageToLoadLastDataAndReceiveUpdates:
'<0>Refresh the page</0> to load last data<br />and receive updates', '<0>Refresh the page</0> to load last data<br />and receive updates',
@ -153,6 +155,7 @@ export default {
delete: 'Delete', delete: 'Delete',
deleteAttachment: 'Delete attachment', deleteAttachment: 'Delete attachment',
deleteAvatar: 'Delete avatar', deleteAvatar: 'Delete avatar',
deleteBackground: 'Delete background',
deleteBoard: 'Delete board', deleteBoard: 'Delete board',
deleteCard: 'Delete card', deleteCard: 'Delete card',
deleteCard_title: 'Delete Card', deleteCard_title: 'Delete Card',
@ -161,10 +164,12 @@ export default {
deleteList: 'Delete list', deleteList: 'Delete list',
deleteList_title: 'Delete List', deleteList_title: 'Delete List',
deleteProject: 'Delete project', deleteProject: 'Delete project',
deleteProject_title: 'Delete Project',
deleteTask: 'Delete task', deleteTask: 'Delete task',
deleteTask_title: 'Delete Task', deleteTask_title: 'Delete Task',
deleteUser: 'Delete user', deleteUser: 'Delete user',
edit: 'Edit', edit: 'Edit',
editBackground_title: 'Edit Background',
editDueDate_title: 'Edit Due Date', editDueDate_title: 'Edit Due Date',
editDescription_title: 'Edit Description', editDescription_title: 'Edit Description',
editEmail_title: 'Edit E-mail', editEmail_title: 'Edit E-mail',
@ -189,6 +194,7 @@ export default {
subscribe: 'Subscribe', subscribe: 'Subscribe',
unsubscribe: 'Unsubscribe', unsubscribe: 'Unsubscribe',
uploadNewAvatar: 'Upload new avatar', uploadNewAvatar: 'Upload new avatar',
uploadNewBackground: 'Upload new background',
}, },
}, },
}; };

View file

@ -66,12 +66,13 @@ export default {
editAttachment: 'Изменение вложения', editAttachment: 'Изменение вложения',
editAvatar: 'Изменение аватара', editAvatar: 'Изменение аватара',
editBoard: 'Изменение доски', editBoard: 'Изменение доски',
editBackground: 'Изменение фона',
editDueDate: 'Изменение срока', editDueDate: 'Изменение срока',
editEmail: 'Изменение e-mail', editEmail: 'Изменение e-mail',
editLabel: 'Изменения метки', editLabel: 'Изменения метки',
editPassword: 'Изменение пароля', editPassword: 'Изменение пароля',
editProject: 'Изменение проекта',
editTimer: 'Изменение таймера', editTimer: 'Изменение таймера',
editTitle: 'Изменение названия',
editUsername: 'Изменение имени пользователя', editUsername: 'Изменение имени пользователя',
email: 'E-mail', email: 'E-mail',
emailAlreadyInUse: 'E-mail уже занят', emailAlreadyInUse: 'E-mail уже занят',
@ -110,6 +111,7 @@ export default {
pressPasteShortcutToAddAttachmentFromClipboard: pressPasteShortcutToAddAttachmentFromClipboard:
'Совет: нажмите Ctrl-V (Cmd-V на Mac), чтобы добавить вложение из буфера обмена.', 'Совет: нажмите Ctrl-V (Cmd-V на Mac), чтобы добавить вложение из буфера обмена.',
project: 'Проект', project: 'Проект',
projectActions: 'Действия с проектом',
projectNotFound: 'Проект не найден', projectNotFound: 'Проект не найден',
refreshPageToLoadLastDataAndReceiveUpdates: refreshPageToLoadLastDataAndReceiveUpdates:
'<0>Обновите страницу</0>, чтобы загрузить<br />актуальные данные и получать обновления', '<0>Обновите страницу</0>, чтобы загрузить<br />актуальные данные и получать обновления',
@ -157,6 +159,7 @@ export default {
delete: 'Удалить', delete: 'Удалить',
deleteAttachment: 'Удалить вложение', deleteAttachment: 'Удалить вложение',
deleteAvatar: 'Удалить аватар', deleteAvatar: 'Удалить аватар',
deleteBackground: 'Удалить фон',
deleteBoard: 'Удалить доску', deleteBoard: 'Удалить доску',
deleteCard: 'Удалить карточку', deleteCard: 'Удалить карточку',
deleteComment: 'Удалить комментарий', deleteComment: 'Удалить комментарий',
@ -166,6 +169,7 @@ export default {
deleteTask: 'Удалить задачу', deleteTask: 'Удалить задачу',
deleteUser: 'Удалить пользователя', deleteUser: 'Удалить пользователя',
edit: 'Изменить', edit: 'Изменить',
editBackground: 'Изменить фон',
editDueDate: 'Изменить срок', editDueDate: 'Изменить срок',
editDescription: 'Изменить описание', editDescription: 'Изменить описание',
editEmail: 'Изменить e-mail', editEmail: 'Изменить e-mail',
@ -190,6 +194,7 @@ export default {
subscribe: 'Подписаться', subscribe: 'Подписаться',
unsubscribe: 'Отписаться', unsubscribe: 'Отписаться',
uploadNewAvatar: 'Загрузить новый аватар', uploadNewAvatar: 'Загрузить новый аватар',
uploadNewBackground: 'Загрузить новый фон',
}, },
}, },
}; };

View file

@ -8,6 +8,11 @@ export default class extends Model {
static fields = { static fields = {
id: attr(), id: attr(),
name: attr(), name: attr(),
background: attr(),
backgroundImage: attr(),
isBackgroundImageUpdating: attr({
getDefault: () => false,
}),
users: many({ users: many({
to: 'User', to: 'User',
through: 'ProjectMembership', through: 'ProjectMembership',
@ -39,6 +44,25 @@ export default class extends Model {
case ActionTypes.PROJECT_UPDATE_RECEIVED: case ActionTypes.PROJECT_UPDATE_RECEIVED:
Project.withId(payload.project.id).update(payload.project); Project.withId(payload.project.id).update(payload.project);
break;
case ActionTypes.PROJECT_BACKGROUND_IMAGE_UPDATE_REQUESTED:
Project.withId(payload.id).update({
isBackgroundImageUpdating: true,
});
break;
case ActionTypes.PROJECT_BACKGROUND_IMAGE_UPDATE_SUCCEEDED:
Project.withId(payload.project.id).update({
...payload.project,
isBackgroundImageUpdating: false,
});
break;
case ActionTypes.PROJECT_BACKGROUND_IMAGE_UPDATE_FAILED:
Project.withId(payload.id).update({
isBackgroundImageUpdating: false,
});
break; break;
case ActionTypes.PROJECT_DELETE_RECEIVED: case ActionTypes.PROJECT_DELETE_RECEIVED:
Project.withId(payload.project.id).deleteWithRelated(); Project.withId(payload.project.id).deleteWithRelated();

View file

@ -141,8 +141,8 @@ export default class extends Model {
break; break;
} }
case ActionTypes.USER_EMAIL_UPDATE_SUCCEEDED: { case ActionTypes.USER_EMAIL_UPDATE_SUCCEEDED: {
User.withId(payload.id).update({ User.withId(payload.user.id).update({
email: payload.email, ...payload.user,
emailUpdateForm: DEFAULT_EMAIL_UPDATE_FORM, emailUpdateForm: DEFAULT_EMAIL_UPDATE_FORM,
}); });
@ -175,7 +175,8 @@ export default class extends Model {
break; break;
} }
case ActionTypes.USER_PASSWORD_UPDATE_SUCCEEDED: { case ActionTypes.USER_PASSWORD_UPDATE_SUCCEEDED: {
User.withId(payload.id).update({ User.withId(payload.user.id).update({
...payload.user,
passwordUpdateForm: DEFAULT_PASSWORD_UPDATE_FORM, passwordUpdateForm: DEFAULT_PASSWORD_UPDATE_FORM,
}); });
@ -208,8 +209,8 @@ export default class extends Model {
break; break;
} }
case ActionTypes.USER_USERNAME_UPDATE_SUCCEEDED: { case ActionTypes.USER_USERNAME_UPDATE_SUCCEEDED: {
User.withId(payload.id).update({ User.withId(payload.user.id).update({
username: payload.username, ...payload.user,
usernameUpdateForm: DEFAULT_USERNAME_UPDATE_FORM, usernameUpdateForm: DEFAULT_USERNAME_UPDATE_FORM,
}); });
@ -235,8 +236,8 @@ export default class extends Model {
break; break;
case ActionTypes.USER_AVATAR_UPDATE_SUCCEEDED: case ActionTypes.USER_AVATAR_UPDATE_SUCCEEDED:
User.withId(payload.id).update({ User.withId(payload.user.id).update({
avatarUrl: payload.avatarUrl, ...payload.user,
isAvatarUpdating: false, isAvatarUpdating: false,
}); });

View file

@ -8,6 +8,9 @@ import {
deleteProjectFailed, deleteProjectFailed,
deleteProjectRequested, deleteProjectRequested,
deleteProjectSucceeded, deleteProjectSucceeded,
updateProjectBackgroundImageFailed,
updateProjectBackgroundImageRequested,
updateProjectBackgroundImageSucceeded,
updateProjectFailed, updateProjectFailed,
updateProjectRequested, updateProjectRequested,
updateProjectSucceeded, updateProjectSucceeded,
@ -65,6 +68,30 @@ export function* updateProjectRequest(id, data) {
} }
} }
export function* updateProjectBackgroundImageRequest(id, data) {
yield put(updateProjectBackgroundImageRequested(id));
try {
const { item } = yield call(request, api.updateProjectBackgroundImage, id, data);
const action = updateProjectBackgroundImageSucceeded(item);
yield put(action);
return {
success: true,
payload: action.payload,
};
} catch (error) {
const action = updateProjectBackgroundImageFailed(id, error);
yield put(action);
return {
success: false,
payload: action.payload,
};
}
}
export function* deleteProjectRequest(id) { export function* deleteProjectRequest(id) {
yield put(deleteProjectRequested(id)); yield put(deleteProjectRequested(id));

View file

@ -107,7 +107,7 @@ export function* updateUserEmailRequest(id, data) {
try { try {
const { item } = yield call(request, api.updateUserEmail, id, data); const { item } = yield call(request, api.updateUserEmail, id, data);
const action = updateUserEmailSucceeded(id, item); const action = updateUserEmailSucceeded(item);
yield put(action); yield put(action);
return { return {
@ -129,9 +129,9 @@ export function* updateUserPasswordRequest(id, data) {
yield put(updateUserPasswordRequested(id, data)); yield put(updateUserPasswordRequested(id, data));
try { try {
yield call(request, api.updateUserPassword, id, data); const { item } = yield call(request, api.updateUserPassword, id, data);
const action = updateUserPasswordSucceeded(id); const action = updateUserPasswordSucceeded(item);
yield put(action); yield put(action);
return { return {
@ -155,7 +155,7 @@ export function* updateUserUsernameRequest(id, data) {
try { try {
const { item } = yield call(request, api.updateUserUsername, id, data); const { item } = yield call(request, api.updateUserUsername, id, data);
const action = updateUserUsernameSucceeded(id, item); const action = updateUserUsernameSucceeded(item);
yield put(action); yield put(action);
return { return {
@ -179,7 +179,7 @@ export function* updateUserAvatarRequest(id, data) {
try { try {
const { item } = yield call(request, api.updateUserAvatar, id, data); const { item } = yield call(request, api.updateUserAvatar, id, data);
const action = updateUserAvatarSucceeded(id, item); const action = updateUserAvatarSucceeded(item);
yield put(action); yield put(action);
return { return {

View file

@ -1,7 +1,12 @@
import { call, put, select } from 'redux-saga/effects'; import { call, put, select } from 'redux-saga/effects';
import { goToProjectService, goToRootService } from './router'; import { goToProjectService, goToRootService } from './router';
import { createProjectRequest, deleteProjectRequest, updateProjectRequest } from '../requests'; import {
createProjectRequest,
deleteProjectRequest,
updateProjectBackgroundImageRequest,
updateProjectRequest,
} from '../requests';
import { pathSelector } from '../../../selectors'; import { pathSelector } from '../../../selectors';
import { createProject, deleteProject, updateProject } from '../../../actions'; import { createProject, deleteProject, updateProject } from '../../../actions';
@ -29,6 +34,16 @@ export function* updateCurrentProjectService(data) {
yield call(updateProjectService, projectId, data); yield call(updateProjectService, projectId, data);
} }
export function* updateProjectBackgroundImageService(id, data) {
yield call(updateProjectBackgroundImageRequest, id, data);
}
export function* updateCurrentProjectBackgroundImageService(data) {
const { projectId } = yield select(pathSelector);
yield call(updateProjectBackgroundImageService, projectId, data);
}
export function* deleteProjectService(id) { export function* deleteProjectService(id) {
const { projectId } = yield select(pathSelector); const { projectId } = yield select(pathSelector);

View file

@ -3,6 +3,7 @@ import { all, takeLatest } from 'redux-saga/effects';
import { import {
createProjectService, createProjectService,
deleteCurrentProjectService, deleteCurrentProjectService,
updateCurrentProjectBackgroundImageService,
updateCurrentProjectService, updateCurrentProjectService,
} from '../services'; } from '../services';
import EntryActionTypes from '../../../constants/EntryActionTypes'; import EntryActionTypes from '../../../constants/EntryActionTypes';
@ -15,6 +16,9 @@ export default function* () {
takeLatest(EntryActionTypes.CURRENT_PROJECT_UPDATE, ({ payload: { data } }) => takeLatest(EntryActionTypes.CURRENT_PROJECT_UPDATE, ({ payload: { data } }) =>
updateCurrentProjectService(data), updateCurrentProjectService(data),
), ),
takeLatest(EntryActionTypes.CURRENT_PROJECT_BACKGROUND_IMAGE_UPDATE, ({ payload: { data } }) =>
updateCurrentProjectBackgroundImageService(data),
),
takeLatest(EntryActionTypes.CURRENT_PROJECT_DELETE, () => deleteCurrentProjectService()), takeLatest(EntryActionTypes.CURRENT_PROJECT_DELETE, () => deleteCurrentProjectService()),
]); ]);
} }

3
server/.gitignore vendored
View file

@ -131,6 +131,9 @@ public/*
!public/user-avatars !public/user-avatars
public/user-avatars/* public/user-avatars/*
!public/user-avatars/.gitkeep !public/user-avatars/.gitkeep
!public/project-background-images
public/project-background-images/*
!public/project-background-images/.gitkeep
!public/attachments !public/attachments
public/attachments/* public/attachments/*
!public/attachments/.gitkeep !public/attachments/.gitkeep

View file

@ -0,0 +1,60 @@
const Errors = {
PROJECT_NOT_FOUND: {
projectNotFound: 'Project not found',
},
};
module.exports = {
inputs: {
id: {
type: 'string',
regex: /^[0-9]+$/,
required: true,
},
},
exits: {
projectNotFound: {
responseType: 'notFound',
},
uploadError: {
responseType: 'unprocessableEntity',
},
},
async fn(inputs, exits) {
let project = await Project.findOne(inputs.id);
if (!project) {
throw Errors.PROJECT_NOT_FOUND;
}
this.req
.file('file')
.upload(sails.helpers.createProjectBackgroundImageReceiver(), async (error, files) => {
if (error) {
return exits.uploadError(error.message);
}
if (files.length === 0) {
return exits.uploadError('No file was uploaded');
}
project = await sails.helpers.updateProject(
project,
{
backgroundImageDirname: files[0].extra.dirname,
},
this.req,
);
if (!project) {
throw Errors.PROJECT_NOT_FOUND;
}
return exits.success({
item: project.toJSON(),
});
});
},
};

View file

@ -15,6 +15,14 @@ module.exports = {
type: 'string', type: 'string',
isNotEmptyString: true, isNotEmptyString: true,
}, },
background: {
type: 'json',
custom: (value) => _.isNull(value),
},
backgroundImage: {
type: 'json',
custom: (value) => _.isNull(value),
},
}, },
exits: { exits: {
@ -30,7 +38,7 @@ module.exports = {
throw Errors.PROJECT_NOT_FOUND; throw Errors.PROJECT_NOT_FOUND;
} }
const values = _.pick(inputs, ['name']); const values = _.pick(inputs, ['name', 'background', 'backgroundImage']);
project = await sails.helpers.updateProject(project, values, this.req); project = await sails.helpers.updateProject(project, values, this.req);

View file

@ -38,7 +38,7 @@ module.exports = {
user = currentUser; user = currentUser;
} }
this.req.file('file').upload(sails.helpers.createAvatarReceiver(), async (error, files) => { this.req.file('file').upload(sails.helpers.createUserAvatarReceiver(), async (error, files) => {
if (error) { if (error) {
return exits.uploadError(error.message); return exits.uploadError(error.message);
} }
@ -60,7 +60,7 @@ module.exports = {
} }
return exits.success({ return exits.success({
item: user.toJSON().avatarUrl, item: user.toJSON(),
}); });
}); });
}, },

View file

@ -77,7 +77,7 @@ module.exports = {
} }
return exits.success({ return exits.success({
item: user.email, item: user,
}); });
}, },
}; };

View file

@ -68,7 +68,7 @@ module.exports = {
} }
return exits.success({ return exits.success({
item: null, item: user,
}); });
}, },
}; };

View file

@ -79,7 +79,7 @@ module.exports = {
} }
return exits.success({ return exits.success({
item: user.username, item: user,
}); });
}, },
}; };

View file

@ -0,0 +1,58 @@
const fs = require('fs');
const path = require('path');
const util = require('util');
const stream = require('stream');
const streamToArray = require('stream-to-array');
const { v4: uuid } = require('uuid');
const sharp = require('sharp');
const writeFile = util.promisify(fs.writeFile);
module.exports = {
sync: true,
fn(inputs, exits) {
const receiver = stream.Writable({
objectMode: true,
});
let firstFileHandled = false;
// eslint-disable-next-line no-underscore-dangle
receiver._write = async (file, receiverEncoding, done) => {
if (firstFileHandled) {
file.pipe(new stream.Writable());
return done();
}
firstFileHandled = true;
const buffer = await streamToArray(file).then((parts) =>
Buffer.concat(parts.map((part) => (util.isBuffer(part) ? part : Buffer.from(part)))),
);
try {
const originalBuffer = await sharp(buffer).jpeg().toBuffer();
const cover336Buffer = await sharp(buffer).resize(336, 200).jpeg().toBuffer();
const dirname = uuid();
const rootPath = path.join(sails.config.custom.projectBackgroundImagesPath, dirname);
fs.mkdirSync(rootPath);
await writeFile(path.join(rootPath, 'original.jpg'), originalBuffer);
await writeFile(path.join(rootPath, 'cover-336.jpg'), cover336Buffer);
// eslint-disable-next-line no-param-reassign
file.extra = {
dirname,
};
return done();
} catch (error) {
return done(error);
}
};
return exits.success(receiver);
},
};

View file

@ -31,6 +31,7 @@ module.exports = {
); );
try { try {
const originalBuffer = await sharp(buffer).jpeg().toBuffer();
const square100Buffer = await sharp(buffer).resize(100, 100).jpeg().toBuffer(); const square100Buffer = await sharp(buffer).resize(100, 100).jpeg().toBuffer();
const dirname = uuid(); const dirname = uuid();
@ -38,7 +39,7 @@ module.exports = {
const rootPath = path.join(sails.config.custom.userAvatarsPath, dirname); const rootPath = path.join(sails.config.custom.userAvatarsPath, dirname);
fs.mkdirSync(rootPath); fs.mkdirSync(rootPath);
await writeFile(path.join(rootPath, 'original.jpg'), buffer); await writeFile(path.join(rootPath, 'original.jpg'), originalBuffer);
await writeFile(path.join(rootPath, 'square-100.jpg'), square100Buffer); await writeFile(path.join(rootPath, 'square-100.jpg'), square100Buffer);
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign

View file

@ -1,3 +1,6 @@
const path = require('path');
const rimraf = require('rimraf');
module.exports = { module.exports = {
inputs: { inputs: {
record: { record: {
@ -6,6 +9,10 @@ module.exports = {
}, },
values: { values: {
type: 'json', type: 'json',
custom: (value) =>
_.isPlainObject(value) &&
(_.isUndefined(value.background) || _.isNull(value.background)) &&
(_.isUndefined(value.backgroundImage) || _.isNull(value.backgroundImage)),
required: true, required: true,
}, },
request: { request: {
@ -14,9 +21,39 @@ module.exports = {
}, },
async fn(inputs, exits) { async fn(inputs, exits) {
if (!_.isUndefined(inputs.values.backgroundImage)) {
/* eslint-disable no-param-reassign */
inputs.values.backgroundImageDirname = null;
delete inputs.values.backgroundImage;
/* eslint-enable no-param-reassign */
}
if (inputs.values.backgroundImageDirname) {
// eslint-disable-next-line no-param-reassign
inputs.values.background = {
type: 'image',
};
}
const project = await Project.updateOne(inputs.record.id).set(inputs.values); const project = await Project.updateOne(inputs.record.id).set(inputs.values);
if (project) { if (project) {
if (
inputs.record.backgroundImageDirname &&
project.backgroundImageDirname !== inputs.record.backgroundImageDirname
) {
try {
rimraf.sync(
path.join(
sails.config.custom.projectBackgroundImagesPath,
inputs.record.backgroundImageDirname,
),
);
} catch (error) {
console.warn(error.stack); // eslint-disable-line no-console
}
}
const userIds = await sails.helpers.getMembershipUserIdsForProject(project.id); const userIds = await sails.helpers.getMembershipUserIdsForProject(project.id);
userIds.forEach((userId) => { userIds.forEach((userId) => {

View file

@ -15,6 +15,15 @@ module.exports = {
type: 'string', type: 'string',
required: true, required: true,
}, },
background: {
type: 'json',
},
backgroundImageDirname: {
type: 'string',
isNotEmptyString: true,
allowNull: true,
columnName: 'background_image_dirname',
},
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
// ║╣ ║║║╠╩╗║╣ ║║╚═╗ // ║╣ ║║║╠╩╗║╣ ║║╚═╗
@ -34,4 +43,14 @@ module.exports = {
via: 'projectId', via: 'projectId',
}, },
}, },
customToJSON() {
return {
..._.omit(this, ['backgroundImageDirname']),
backgroundImage: this.backgroundImageDirname && {
url: `${sails.config.custom.projectBackgroundImagesUrl}/${this.backgroundImageDirname}/original.jpg`,
coverUrl: `${sails.config.custom.projectBackgroundImagesUrl}/${this.backgroundImageDirname}/cover-336.jpg`,
},
};
},
}; };

View file

@ -23,6 +23,9 @@ module.exports.custom = {
userAvatarsPath: path.join(sails.config.paths.public, 'user-avatars'), userAvatarsPath: path.join(sails.config.paths.public, 'user-avatars'),
userAvatarsUrl: `${process.env.BASE_URL}/user-avatars`, userAvatarsUrl: `${process.env.BASE_URL}/user-avatars`,
projectBackgroundImagesPath: path.join(sails.config.paths.public, 'project-background-images'),
projectBackgroundImagesUrl: `${process.env.BASE_URL}/project-background-images`,
attachmentsPath: path.join(sails.config.paths.public, 'attachments'), attachmentsPath: path.join(sails.config.paths.public, 'attachments'),
attachmentsUrl: `${process.env.BASE_URL}/attachments`, attachmentsUrl: `${process.env.BASE_URL}/attachments`,
}; };

View file

@ -332,6 +332,9 @@ module.exports = {
userAvatarsPath: path.join(sails.config.paths.public, 'user-avatars'), userAvatarsPath: path.join(sails.config.paths.public, 'user-avatars'),
userAvatarsUrl: `${process.env.BASE_URL}/user-avatars`, userAvatarsUrl: `${process.env.BASE_URL}/user-avatars`,
projectBackgroundImagesPath: path.join(sails.config.paths.public, 'project-background-images'),
projectBackgroundImagesUrl: `${process.env.BASE_URL}/project-background-images`,
attachmentsPath: path.join(sails.config.paths.public, 'attachments'), attachmentsPath: path.join(sails.config.paths.public, 'attachments'),
attachmentsUrl: `${process.env.BASE_URL}/attachments`, attachmentsUrl: `${process.env.BASE_URL}/attachments`,
}, },

View file

@ -24,6 +24,7 @@ module.exports.policies = {
'projects/create': ['is-authenticated', 'is-admin'], 'projects/create': ['is-authenticated', 'is-admin'],
'projects/update': ['is-authenticated', 'is-admin'], 'projects/update': ['is-authenticated', 'is-admin'],
'projects/update-background-image': ['is-authenticated', 'is-admin'],
'projects/delete': ['is-authenticated', 'is-admin'], 'projects/delete': ['is-authenticated', 'is-admin'],
'project-memberships/create': ['is-authenticated', 'is-admin'], 'project-memberships/create': ['is-authenticated', 'is-admin'],

View file

@ -18,12 +18,13 @@ module.exports.routes = {
'PATCH /api/users/:id/email': 'users/update-email', 'PATCH /api/users/:id/email': 'users/update-email',
'PATCH /api/users/:id/password': 'users/update-password', 'PATCH /api/users/:id/password': 'users/update-password',
'PATCH /api/users/:id/username': 'users/update-username', 'PATCH /api/users/:id/username': 'users/update-username',
'POST /api/users/:id/update-avatar': 'users/update-avatar', 'POST /api/users/:id/avatar': 'users/update-avatar',
'DELETE /api/users/:id': 'users/delete', 'DELETE /api/users/:id': 'users/delete',
'GET /api/projects': 'projects/index', 'GET /api/projects': 'projects/index',
'POST /api/projects': 'projects/create', 'POST /api/projects': 'projects/create',
'PATCH /api/projects/:id': 'projects/update', 'PATCH /api/projects/:id': 'projects/update',
'POST /api/projects/:id/background-image': 'projects/update-background-image',
'DELETE /api/projects/:id': 'projects/delete', 'DELETE /api/projects/:id': 'projects/delete',
'POST /api/projects/:projectId/memberships': 'project-memberships/create', 'POST /api/projects/:projectId/memberships': 'project-memberships/create',

View file

@ -5,6 +5,8 @@ module.exports.up = (knex) =>
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()')); table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.text('name').notNullable(); table.text('name').notNullable();
table.jsonb('background');
table.text('background_image_dirname');
table.timestamp('created_at', true); table.timestamp('created_at', true);
table.timestamp('updated_at', true); table.timestamp('updated_at', true);