1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-18 12:49:43 +02:00

Add project backgrounds

This commit is contained in:
Maksim Eltyshev 2020-05-26 00:46:04 +05:00
parent af4297ac62
commit 3bb68b0d4f
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 = () => ({
type: EntryActionTypes.CURRENT_PROJECT_DELETE,
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) => ({
type: ActionTypes.PROJECT_DELETE_REQUESTED,
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,
payload: {
id,
email,
user,
},
});
@ -194,10 +193,10 @@ export const updateUserPasswordRequested = (id, data) => ({
},
});
export const updateUserPasswordSucceeded = (id) => ({
export const updateUserPasswordSucceeded = (user) => ({
type: ActionTypes.USER_PASSWORD_UPDATE_SUCCEEDED,
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,
payload: {
id,
username,
user,
},
});
@ -240,11 +238,10 @@ export const updateUserAvatarRequested = (id) => ({
},
});
export const updateUserAvatarSucceeded = (id, avatarUrl) => ({
export const updateUserAvatarSucceeded = (user) => ({
type: ActionTypes.USER_AVATAR_UPDATE_SUCCEEDED,
payload: {
id,
avatarUrl,
user,
},
});

View file

@ -1,3 +1,4 @@
import http from './http';
import socket from './socket';
/* 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 updateProjectBackgroundImage = (id, data, headers) =>
http.post(`/projects/${id}/background-image`, data, headers);
const deleteProject = (id, headers) => socket.delete(`/projects/${id}`, undefined, headers);
export default {
getProjects,
createProject,
updateProject,
updateProjectBackgroundImage,
deleteProject,
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,5 @@
.cover {
background: url("../../assets/images/cover.jpg") no-repeat center center;
background-size: cover;
background: url("../../assets/images/cover.jpg") center / cover !important;
}
.descriptionSubtitle {
@ -20,7 +19,7 @@
}
.descriptionWrapperOverlay {
background-color: rgba(33, 33, 33, 0.5);
background: rgba(33, 33, 33, 0.5);
bottom: 0;
height: 100%;
left: 0;
@ -37,7 +36,7 @@
}
.fullHeight {
background-color: #fff;
background: #fff;
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 {
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 { Button, Grid } from 'semantic-ui-react';
import BoardsContainer from '../../containers/BoardsContainer';
import EditPopup from './EditPopup';
import ActionsPopup from './ActionsPopup';
import AddMembershipPopup from './AddMembershipPopup';
import EditMembershipPopup from './EditMembershipPopup';
import User from '../User';
@ -13,10 +13,14 @@ import styles from './Project.module.css';
const Project = React.memo(
({
name,
background,
backgroundImage,
isBackgroundImageUpdating,
memberships,
allUsers,
isEditable,
onUpdate,
onBackgroundImageUpdate,
onDelete,
onMembershipCreate,
onMembershipDelete,
@ -28,21 +32,41 @@ const Project = React.memo(
[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 (
<div className={styles.wrapper}>
<Grid className={styles.header}>
<Grid.Row>
<Grid.Column>
{isEditable ? (
<EditPopup
defaultData={{
<ActionsPopup
project={{
name,
background,
backgroundImage,
isBackgroundImageUpdating,
}}
onUpdate={onUpdate}
onBackgroundImageUpdate={onBackgroundImageUpdate}
onDelete={onDelete}
>
<Button content={name} disabled={!isEditable} className={styles.name} />
</EditPopup>
</ActionsPopup>
) : (
<span className={styles.name}>{name}</span>
)}
@ -85,14 +109,25 @@ const Project = React.memo(
Project.propTypes = {
name: PropTypes.string.isRequired,
/* 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,
allUsers: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
isEditable: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired,
onBackgroundImageUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onMembershipCreate: PropTypes.func.isRequired,
onMembershipDelete: PropTypes.func.isRequired,
};
Project.defaultProps = {
background: undefined,
backgroundImage: undefined,
};
export default Project;

View file

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

View file

@ -25,7 +25,15 @@ const Projects = React.memo(({ items, isEditable, onAdd }) => {
: 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 && (
<span className={styles.notification}>{item.notificationsTotal}</span>
)}

View file

@ -75,7 +75,6 @@
.open {
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);
color: #fff;
position: relative;

View file

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

View file

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

View file

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

View file

@ -87,6 +87,9 @@ export default {
PROJECT_UPDATE_SUCCEEDED: 'PROJECT_UPDATE_SUCCEEDED',
PROJECT_UPDATE_FAILED: 'PROJECT_UPDATE_FAILED',
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_SUCCEEDED: 'PROJECT_DELETE_SUCCEEDED',
PROJECT_DELETE_FAILED: 'PROJECT_DELETE_FAILED',

View file

@ -39,6 +39,7 @@ export default {
PROJECT_CREATE: `${PREFIX}/PROJECT_CREATE`,
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`,
/* Project membership */

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,6 +8,9 @@ import {
deleteProjectFailed,
deleteProjectRequested,
deleteProjectSucceeded,
updateProjectBackgroundImageFailed,
updateProjectBackgroundImageRequested,
updateProjectBackgroundImageSucceeded,
updateProjectFailed,
updateProjectRequested,
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) {
yield put(deleteProjectRequested(id));

View file

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

View file

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

View file

@ -3,6 +3,7 @@ import { all, takeLatest } from 'redux-saga/effects';
import {
createProjectService,
deleteCurrentProjectService,
updateCurrentProjectBackgroundImageService,
updateCurrentProjectService,
} from '../services';
import EntryActionTypes from '../../../constants/EntryActionTypes';
@ -15,6 +16,9 @@ export default function* () {
takeLatest(EntryActionTypes.CURRENT_PROJECT_UPDATE, ({ payload: { data } }) =>
updateCurrentProjectService(data),
),
takeLatest(EntryActionTypes.CURRENT_PROJECT_BACKGROUND_IMAGE_UPDATE, ({ payload: { data } }) =>
updateCurrentProjectBackgroundImageService(data),
),
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/.gitkeep
!public/project-background-images
public/project-background-images/*
!public/project-background-images/.gitkeep
!public/attachments
public/attachments/*
!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',
isNotEmptyString: true,
},
background: {
type: 'json',
custom: (value) => _.isNull(value),
},
backgroundImage: {
type: 'json',
custom: (value) => _.isNull(value),
},
},
exits: {
@ -30,7 +38,7 @@ module.exports = {
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);

View file

@ -38,7 +38,7 @@ module.exports = {
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) {
return exits.uploadError(error.message);
}
@ -60,7 +60,7 @@ module.exports = {
}
return exits.success({
item: user.toJSON().avatarUrl,
item: user.toJSON(),
});
});
},

View file

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

View file

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

View file

@ -79,7 +79,7 @@ module.exports = {
}
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 {
const originalBuffer = await sharp(buffer).jpeg().toBuffer();
const square100Buffer = await sharp(buffer).resize(100, 100).jpeg().toBuffer();
const dirname = uuid();
@ -38,7 +39,7 @@ module.exports = {
const rootPath = path.join(sails.config.custom.userAvatarsPath, dirname);
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);
// eslint-disable-next-line no-param-reassign

View file

@ -1,3 +1,6 @@
const path = require('path');
const rimraf = require('rimraf');
module.exports = {
inputs: {
record: {
@ -6,6 +9,10 @@ module.exports = {
},
values: {
type: 'json',
custom: (value) =>
_.isPlainObject(value) &&
(_.isUndefined(value.background) || _.isNull(value.background)) &&
(_.isUndefined(value.backgroundImage) || _.isNull(value.backgroundImage)),
required: true,
},
request: {
@ -14,9 +21,39 @@ module.exports = {
},
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);
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);
userIds.forEach((userId) => {

View file

@ -15,6 +15,15 @@ module.exports = {
type: 'string',
required: true,
},
background: {
type: 'json',
},
backgroundImageDirname: {
type: 'string',
isNotEmptyString: true,
allowNull: true,
columnName: 'background_image_dirname',
},
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
// ║╣ ║║║╠╩╗║╣ ║║╚═╗
@ -34,4 +43,14 @@ module.exports = {
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'),
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'),
attachmentsUrl: `${process.env.BASE_URL}/attachments`,
};

View file

@ -332,6 +332,9 @@ module.exports = {
userAvatarsPath: path.join(sails.config.paths.public, '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'),
attachmentsUrl: `${process.env.BASE_URL}/attachments`,
},

View file

@ -24,6 +24,7 @@ module.exports.policies = {
'projects/create': ['is-authenticated', 'is-admin'],
'projects/update': ['is-authenticated', 'is-admin'],
'projects/update-background-image': ['is-authenticated', 'is-admin'],
'projects/delete': ['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/password': 'users/update-password',
'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',
'GET /api/projects': 'projects/index',
'POST /api/projects': 'projects/create',
'PATCH /api/projects/:id': 'projects/update',
'POST /api/projects/:id/background-image': 'projects/update-background-image',
'DELETE /api/projects/:id': 'projects/delete',
'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.text('name').notNullable();
table.jsonb('background');
table.text('background_image_dirname');
table.timestamp('created_at', true);
table.timestamp('updated_at', true);