color editing
This commit is contained in:
@ -32,7 +32,7 @@ const Wrapper = styled.div`
|
|||||||
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Outline = styled(Flex)`
|
export const Outline = styled(Flex)`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin: 0 0 ${size.large};
|
margin: 0 0 ${size.large};
|
||||||
@ -48,7 +48,7 @@ const Outline = styled(Flex)`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const LabelText = styled.div`
|
export const LabelText = styled.div`
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
`;
|
`;
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import Input from './Input';
|
import Input, { LabelText, Outline } from './Input';
|
||||||
export default Input;
|
export default Input;
|
||||||
|
export { LabelText, Outline };
|
||||||
|
@ -100,7 +100,7 @@ type Props = {
|
|||||||
<SidebarLink
|
<SidebarLink
|
||||||
key={collection.id}
|
key={collection.id}
|
||||||
to={collection.url}
|
to={collection.url}
|
||||||
icon={<CollectionIcon expanded={expanded} />}
|
icon={<CollectionIcon expanded={expanded} color={collection.color} />}
|
||||||
>
|
>
|
||||||
<Flex justify="space-between">
|
<Flex justify="space-between">
|
||||||
{collection.name}
|
{collection.name}
|
||||||
|
@ -19,6 +19,7 @@ class Collection extends BaseModel {
|
|||||||
description: ?string;
|
description: ?string;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
color: string;
|
||||||
type: 'atlas' | 'journal';
|
type: 'atlas' | 'journal';
|
||||||
documents: Array<NavigationNode>;
|
documents: Array<NavigationNode>;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@ -57,19 +58,21 @@ class Collection extends BaseModel {
|
|||||||
if (this.isSaving) return this;
|
if (this.isSaving) return this;
|
||||||
this.isSaving = true;
|
this.isSaving = true;
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
name: this.name,
|
||||||
|
color: this.color,
|
||||||
|
description: this.description,
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let res;
|
let res;
|
||||||
if (this.id) {
|
if (this.id) {
|
||||||
res = await client.post('/collections.update', {
|
res = await client.post('/collections.update', {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
name: this.name,
|
...params,
|
||||||
description: this.description,
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
res = await client.post('/collections.create', {
|
res = await client.post('/collections.create', params);
|
||||||
name: this.name,
|
|
||||||
description: this.description,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
runInAction('Collection#save', () => {
|
runInAction('Collection#save', () => {
|
||||||
invariant(res && res.data, 'Data should be available');
|
invariant(res && res.data, 'Data should be available');
|
||||||
|
@ -5,7 +5,7 @@ import invariant from 'invariant';
|
|||||||
import { client } from 'utils/ApiClient';
|
import { client } from 'utils/ApiClient';
|
||||||
import stores from 'stores';
|
import stores from 'stores';
|
||||||
import ErrorsStore from 'stores/ErrorsStore';
|
import ErrorsStore from 'stores/ErrorsStore';
|
||||||
import parseTitle from '../../shared/parseTitle';
|
import parseTitle from '../../shared/utils/parseTitle.js';
|
||||||
|
|
||||||
import type { User } from 'types';
|
import type { User } from 'types';
|
||||||
import BaseModel from './BaseModel';
|
import BaseModel from './BaseModel';
|
||||||
|
@ -7,6 +7,7 @@ import Button from 'components/Button';
|
|||||||
import Input from 'components/Input';
|
import Input from 'components/Input';
|
||||||
import Flex from 'shared/components/Flex';
|
import Flex from 'shared/components/Flex';
|
||||||
import HelpText from 'components/HelpText';
|
import HelpText from 'components/HelpText';
|
||||||
|
import ColorPicker from 'components/ColorPicker';
|
||||||
import Collection from 'models/Collection';
|
import Collection from 'models/Collection';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -18,6 +19,7 @@ type Props = {
|
|||||||
@observer class CollectionEdit extends Component {
|
@observer class CollectionEdit extends Component {
|
||||||
props: Props;
|
props: Props;
|
||||||
@observable name: string;
|
@observable name: string;
|
||||||
|
@observable color: string = '';
|
||||||
@observable isSaving: boolean;
|
@observable isSaving: boolean;
|
||||||
|
|
||||||
componentWillMount() {
|
componentWillMount() {
|
||||||
@ -28,7 +30,7 @@ type Props = {
|
|||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
this.isSaving = true;
|
this.isSaving = true;
|
||||||
|
|
||||||
this.props.collection.updateData({ name: this.name });
|
this.props.collection.updateData({ name: this.name, color: this.color });
|
||||||
const success = await this.props.collection.save();
|
const success = await this.props.collection.save();
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
@ -42,6 +44,10 @@ type Props = {
|
|||||||
this.name = ev.target.value;
|
this.name = ev.target.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleColor = (color: string) => {
|
||||||
|
this.color = color;
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<Flex column>
|
<Flex column>
|
||||||
@ -58,6 +64,10 @@ type Props = {
|
|||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
<ColorPicker
|
||||||
|
onSelect={this.handleColor}
|
||||||
|
value={this.props.collection.color}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={this.isSaving || !this.props.collection.name}
|
disabled={this.isSaving || !this.props.collection.name}
|
||||||
|
@ -5,6 +5,7 @@ import { observable } from 'mobx';
|
|||||||
import { inject, observer } from 'mobx-react';
|
import { inject, observer } from 'mobx-react';
|
||||||
import Button from 'components/Button';
|
import Button from 'components/Button';
|
||||||
import Input from 'components/Input';
|
import Input from 'components/Input';
|
||||||
|
import ColorPicker from 'components/ColorPicker';
|
||||||
import HelpText from 'components/HelpText';
|
import HelpText from 'components/HelpText';
|
||||||
|
|
||||||
import Collection from 'models/Collection';
|
import Collection from 'models/Collection';
|
||||||
@ -20,6 +21,7 @@ type Props = {
|
|||||||
props: Props;
|
props: Props;
|
||||||
@observable collection: Collection;
|
@observable collection: Collection;
|
||||||
@observable name: string = '';
|
@observable name: string = '';
|
||||||
|
@observable color: string = '';
|
||||||
@observable isSaving: boolean;
|
@observable isSaving: boolean;
|
||||||
|
|
||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
@ -30,7 +32,7 @@ type Props = {
|
|||||||
handleSubmit = async (ev: SyntheticEvent) => {
|
handleSubmit = async (ev: SyntheticEvent) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
this.isSaving = true;
|
this.isSaving = true;
|
||||||
this.collection.updateData({ name: this.name });
|
this.collection.updateData({ name: this.name, color: this.color });
|
||||||
const success = await this.collection.save();
|
const success = await this.collection.save();
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
@ -46,6 +48,10 @@ type Props = {
|
|||||||
this.name = ev.target.value;
|
this.name = ev.target.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleColor = (color: string) => {
|
||||||
|
this.color = color;
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<form onSubmit={this.handleSubmit}>
|
<form onSubmit={this.handleSubmit}>
|
||||||
@ -61,6 +67,7 @@ type Props = {
|
|||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
<ColorPicker onSelect={this.handleColor} />
|
||||||
<Button type="submit" disabled={this.isSaving || !this.name}>
|
<Button type="submit" disabled={this.isSaving || !this.name}>
|
||||||
{this.isSaving ? 'Creating…' : 'Create'}
|
{this.isSaving ? 'Creating…' : 'Create'}
|
||||||
</Button>
|
</Button>
|
||||||
|
184
frontend/components/ColorPicker/ColorPicker.js
Normal file
184
frontend/components/ColorPicker/ColorPicker.js
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
// @flow
|
||||||
|
import React from 'react';
|
||||||
|
import { observable, computed, action } from 'mobx';
|
||||||
|
import { observer } from 'mobx-react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import Flex from 'components/Flex';
|
||||||
|
import { LabelText, Outline } from 'components/Input';
|
||||||
|
import { color, fonts, fontWeight } from 'styles/constants';
|
||||||
|
import { validateColorHex } from '../../../shared/utils/color';
|
||||||
|
|
||||||
|
const colors = [
|
||||||
|
'#4E5C6E',
|
||||||
|
'#19B7FF',
|
||||||
|
'#7F6BFF',
|
||||||
|
'#FC7419',
|
||||||
|
'#FC2D2D',
|
||||||
|
'#FFE100',
|
||||||
|
'#14CF9F',
|
||||||
|
'#EE84F0',
|
||||||
|
'#2F362F',
|
||||||
|
];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onSelect: string => void,
|
||||||
|
value?: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
@observer class ColorPicker extends React.Component {
|
||||||
|
props: Props;
|
||||||
|
|
||||||
|
@observable selectedColor: string = colors[0];
|
||||||
|
@observable customColorValue: string = '';
|
||||||
|
@observable customColorSelected: boolean;
|
||||||
|
|
||||||
|
componentWillMount() {
|
||||||
|
const { value } = this.props;
|
||||||
|
if (value && colors.includes(value)) {
|
||||||
|
this.selectedColor = value;
|
||||||
|
} else if (value) {
|
||||||
|
this.customColorSelected = true;
|
||||||
|
this.customColorValue = value.replace('#', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.fireCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
fireCallback = () => {
|
||||||
|
this.props.onSelect(
|
||||||
|
this.customColorSelected ? this.customColor : this.selectedColor
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
@computed get customColor(): string {
|
||||||
|
return this.customColorValue &&
|
||||||
|
validateColorHex(`#${this.customColorValue}`)
|
||||||
|
? `#${this.customColorValue}`
|
||||||
|
: colors[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
@action setColor = (color: string) => {
|
||||||
|
this.selectedColor = color;
|
||||||
|
this.customColorSelected = false;
|
||||||
|
this.fireCallback();
|
||||||
|
};
|
||||||
|
|
||||||
|
@action focusOnCustomColor = (event: SyntheticEvent) => {
|
||||||
|
this.selectedColor = '';
|
||||||
|
this.customColorSelected = true;
|
||||||
|
this.fireCallback();
|
||||||
|
};
|
||||||
|
|
||||||
|
@action setCustomColor = (event: SyntheticEvent) => {
|
||||||
|
let target = event.target;
|
||||||
|
if (target instanceof HTMLInputElement) {
|
||||||
|
const color = target.value;
|
||||||
|
this.customColorValue = color.replace('#', '');
|
||||||
|
this.fireCallback();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<Flex column>
|
||||||
|
<LabelText>Color</LabelText>
|
||||||
|
<StyledOutline justify="space-between">
|
||||||
|
<Flex>
|
||||||
|
{colors.map(color => (
|
||||||
|
<Swatch
|
||||||
|
key={color}
|
||||||
|
color={color}
|
||||||
|
active={
|
||||||
|
color === this.selectedColor && !this.customColorSelected
|
||||||
|
}
|
||||||
|
onClick={() => this.setColor(color)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
<Flex justify="flex-end">
|
||||||
|
<strong>Custom color:</strong>
|
||||||
|
<HexHash>#</HexHash>
|
||||||
|
<CustomColorInput
|
||||||
|
placeholder="FFFFFF"
|
||||||
|
onFocus={this.focusOnCustomColor}
|
||||||
|
onChange={this.setCustomColor}
|
||||||
|
value={this.customColorValue}
|
||||||
|
maxLength={6}
|
||||||
|
/>
|
||||||
|
<Swatch
|
||||||
|
color={this.customColor}
|
||||||
|
active={this.customColorSelected}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</StyledOutline>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SwatchProps = {
|
||||||
|
onClick?: Function,
|
||||||
|
color?: string,
|
||||||
|
active?: boolean,
|
||||||
|
};
|
||||||
|
|
||||||
|
const Swatch = ({ onClick, ...props }: SwatchProps) => (
|
||||||
|
<SwatchOutset onClick={onClick} {...props}>
|
||||||
|
<SwatchInset {...props} />
|
||||||
|
</SwatchOutset>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SwatchOutset = styled(Flex)`
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin-right: 5px;
|
||||||
|
border: 2px solid ${({ active, color }) => (active ? color : 'transparent')};
|
||||||
|
border-radius: 2px;
|
||||||
|
background: ${({ color }) => color};
|
||||||
|
${({ onClick }) => onClick && `cursor: pointer;`}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SwatchInset = styled(Flex)`
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 1px solid ${({ active, color }) => (active ? 'white' : 'transparent')};
|
||||||
|
border-radius: 2px;
|
||||||
|
background: ${({ color }) => color};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledOutline = styled(Outline)`
|
||||||
|
padding: 5px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HexHash = styled.div`
|
||||||
|
margin-left: 12px;
|
||||||
|
padding-bottom: 0;
|
||||||
|
font-weight: ${fontWeight.medium};
|
||||||
|
user-select: none;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CustomColorInput = styled.input`
|
||||||
|
border: 0;
|
||||||
|
flex: 1;
|
||||||
|
width: 65px;
|
||||||
|
margin-right: 12px;
|
||||||
|
padding-bottom: 0;
|
||||||
|
outline: none;
|
||||||
|
background: none;
|
||||||
|
font-family: ${fonts.monospace};
|
||||||
|
font-weight: ${fontWeight.medium};
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: ${color.slate};
|
||||||
|
font-family: ${fonts.monospace};
|
||||||
|
font-weight: ${fontWeight.medium};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default ColorPicker;
|
3
frontend/components/ColorPicker/index.js
Normal file
3
frontend/components/ColorPicker/index.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
// @flow
|
||||||
|
import ColorPicker from './ColorPicker';
|
||||||
|
export default ColorPicker;
|
@ -11,14 +11,17 @@ import { Collection } from '../models';
|
|||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
router.post('collections.create', auth(), async ctx => {
|
router.post('collections.create', auth(), async ctx => {
|
||||||
const { name, description, type } = ctx.body;
|
const { name, color, description, type } = ctx.body;
|
||||||
ctx.assertPresent(name, 'name is required');
|
ctx.assertPresent(name, 'name is required');
|
||||||
|
if (color)
|
||||||
|
ctx.assertHexColor(color, 'Invalid hex value (please use format #FFFFFF)');
|
||||||
|
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
|
|
||||||
const collection = await Collection.create({
|
const collection = await Collection.create({
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
|
color,
|
||||||
type: type || 'atlas',
|
type: type || 'atlas',
|
||||||
teamId: user.teamId,
|
teamId: user.teamId,
|
||||||
creatorId: user.id,
|
creatorId: user.id,
|
||||||
@ -30,11 +33,14 @@ router.post('collections.create', auth(), async ctx => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post('collections.update', auth(), async ctx => {
|
router.post('collections.update', auth(), async ctx => {
|
||||||
const { id, name } = ctx.body;
|
const { id, name, color } = ctx.body;
|
||||||
ctx.assertPresent(name, 'name is required');
|
ctx.assertPresent(name, 'name is required');
|
||||||
|
if (color)
|
||||||
|
ctx.assertHexColor(color, 'Invalid hex value (please use format #FFFFFF)');
|
||||||
|
|
||||||
const collection = await Collection.findById(id);
|
const collection = await Collection.findById(id);
|
||||||
collection.name = name;
|
collection.name = name;
|
||||||
|
collection.color = color;
|
||||||
await collection.save();
|
await collection.save();
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import apiError from '../../errors';
|
import apiError from '../../errors';
|
||||||
import validator from 'validator';
|
import validator from 'validator';
|
||||||
|
import { validateColorHex } from '../../../shared/utils/color';
|
||||||
|
|
||||||
export default function validation() {
|
export default function validation() {
|
||||||
return function validationMiddleware(ctx: Object, next: Function) {
|
return function validationMiddleware(ctx: Object, next: Function) {
|
||||||
@ -28,6 +29,12 @@ export default function validation() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ctx.assertHexColor = (value, message) => {
|
||||||
|
if (!validateColorHex(value)) {
|
||||||
|
throw apiError(400, 'validation_error', message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
11
server/migrations/20171023064220-collection-color.js
Normal file
11
server/migrations/20171023064220-collection-color.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
module.exports = {
|
||||||
|
up: function(queryInterface, Sequelize) {
|
||||||
|
queryInterface.addColumn('collections', 'color', {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
down: function(queryInterface, Sequelize) {
|
||||||
|
queryInterface.removeColumn('collections', 'color');
|
||||||
|
},
|
||||||
|
};
|
@ -22,6 +22,7 @@ const Collection = sequelize.define(
|
|||||||
urlId: { type: DataTypes.STRING, unique: true },
|
urlId: { type: DataTypes.STRING, unique: true },
|
||||||
name: DataTypes.STRING,
|
name: DataTypes.STRING,
|
||||||
description: DataTypes.STRING,
|
description: DataTypes.STRING,
|
||||||
|
color: DataTypes.STRING,
|
||||||
type: {
|
type: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
validate: { isIn: allowedCollectionTypes },
|
validate: { isIn: allowedCollectionTypes },
|
||||||
|
@ -5,7 +5,7 @@ import randomstring from 'randomstring';
|
|||||||
|
|
||||||
import isUUID from 'validator/lib/isUUID';
|
import isUUID from 'validator/lib/isUUID';
|
||||||
import { DataTypes, sequelize } from '../sequelize';
|
import { DataTypes, sequelize } from '../sequelize';
|
||||||
import parseTitle from '../../shared/parseTitle';
|
import parseTitle from '../../shared/utils/parseTitle.js';
|
||||||
import Revision from './Revision';
|
import Revision from './Revision';
|
||||||
|
|
||||||
const URL_REGEX = /^[a-zA-Z0-9-]*-([a-zA-Z0-9]{10,15})$/;
|
const URL_REGEX = /^[a-zA-Z0-9-]*-([a-zA-Z0-9]{10,15})$/;
|
||||||
|
@ -11,6 +11,7 @@ async function present(ctx: Object, collection: Collection) {
|
|||||||
url: collection.getUrl(),
|
url: collection.getUrl(),
|
||||||
name: collection.name,
|
name: collection.name,
|
||||||
description: collection.description,
|
description: collection.description,
|
||||||
|
color: collection.color || '#4E5C6E',
|
||||||
type: collection.type,
|
type: collection.type,
|
||||||
createdAt: collection.createdAt,
|
createdAt: collection.createdAt,
|
||||||
updatedAt: collection.updatedAt,
|
updatedAt: collection.updatedAt,
|
||||||
|
@ -35,6 +35,13 @@ export const fontWeight = {
|
|||||||
heavy: 800,
|
heavy: 800,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fonts = {
|
||||||
|
regular: `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||||
|
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;`,
|
||||||
|
monospace: `'Atlas Typewriter', 'Source Code Pro', Menlo, Consolas,
|
||||||
|
'Liberation Mono', monospace;`,
|
||||||
|
};
|
||||||
|
|
||||||
export const color = {
|
export const color = {
|
||||||
text: '#171B35',
|
text: '#171B35',
|
||||||
|
|
||||||
|
4
shared/utils/color.js
Normal file
4
shared/utils/color.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
// @flow
|
||||||
|
|
||||||
|
export const validateColorHex = (color: string) =>
|
||||||
|
/(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(color);
|
Reference in New Issue
Block a user