color editing

This commit is contained in:
Jori Lallo
2017-10-29 23:22:46 -07:00
parent a1bfde7aec
commit e70a8c2495
18 changed files with 261 additions and 16 deletions

View File

@ -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;
`; `;

View File

@ -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 };

View File

@ -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}

View File

@ -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');

View File

@ -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';

View File

@ -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}

View File

@ -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>

View 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;

View File

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

View File

@ -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 = {

View File

@ -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();
}; };
} }

View 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');
},
};

View File

@ -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 },

View File

@ -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})$/;

View File

@ -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,

View File

@ -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
View File

@ -0,0 +1,4 @@
// @flow
export const validateColorHex = (color: string) =>
/(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(color);