From e70a8c249596014c55e96ed58c17e400e4aa94f0 Mon Sep 17 00:00:00 2001 From: Jori Lallo Date: Sun, 29 Oct 2017 23:22:46 -0700 Subject: [PATCH] color editing --- app/components/Input/Input.js | 4 +- app/components/Input/index.js | 3 +- .../Layout/components/SidebarCollections.js | 2 +- app/models/Collection.js | 15 +- app/models/Document.js | 2 +- app/scenes/CollectionEdit/CollectionEdit.js | 12 +- app/scenes/CollectionNew/CollectionNew.js | 9 +- .../components/ColorPicker/ColorPicker.js | 184 ++++++++++++++++++ frontend/components/ColorPicker/index.js | 3 + server/api/collections.js | 10 +- server/api/middlewares/validation.js | 7 + .../20171023064220-collection-color.js | 11 ++ server/models/Collection.js | 1 + server/models/Document.js | 2 +- server/presenters/collection.js | 1 + shared/styles/constants.js | 7 + shared/utils/color.js | 4 + shared/{ => utils}/parseTitle.js | 0 18 files changed, 261 insertions(+), 16 deletions(-) create mode 100644 frontend/components/ColorPicker/ColorPicker.js create mode 100644 frontend/components/ColorPicker/index.js create mode 100644 server/migrations/20171023064220-collection-color.js create mode 100644 shared/utils/color.js rename shared/{ => utils}/parseTitle.js (100%) diff --git a/app/components/Input/Input.js b/app/components/Input/Input.js index 5fbe3410..7897b3b3 100644 --- a/app/components/Input/Input.js +++ b/app/components/Input/Input.js @@ -32,7 +32,7 @@ const Wrapper = styled.div` `; -const Outline = styled(Flex)` +export const Outline = styled(Flex)` display: flex; flex: 1; 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; padding-bottom: 4px; `; diff --git a/app/components/Input/index.js b/app/components/Input/index.js index e005a8af..e9b21f08 100644 --- a/app/components/Input/index.js +++ b/app/components/Input/index.js @@ -1,3 +1,4 @@ // @flow -import Input from './Input'; +import Input, { LabelText, Outline } from './Input'; export default Input; +export { LabelText, Outline }; diff --git a/app/components/Layout/components/SidebarCollections.js b/app/components/Layout/components/SidebarCollections.js index 45e9e304..9f257e2e 100644 --- a/app/components/Layout/components/SidebarCollections.js +++ b/app/components/Layout/components/SidebarCollections.js @@ -100,7 +100,7 @@ type Props = { } + icon={} > {collection.name} diff --git a/app/models/Collection.js b/app/models/Collection.js index 93923e9c..77158d86 100644 --- a/app/models/Collection.js +++ b/app/models/Collection.js @@ -19,6 +19,7 @@ class Collection extends BaseModel { description: ?string; id: string; name: string; + color: string; type: 'atlas' | 'journal'; documents: Array; updatedAt: string; @@ -57,19 +58,21 @@ class Collection extends BaseModel { if (this.isSaving) return this; this.isSaving = true; + const params = { + name: this.name, + color: this.color, + description: this.description, + }; + try { let res; if (this.id) { res = await client.post('/collections.update', { id: this.id, - name: this.name, - description: this.description, + ...params, }); } else { - res = await client.post('/collections.create', { - name: this.name, - description: this.description, - }); + res = await client.post('/collections.create', params); } runInAction('Collection#save', () => { invariant(res && res.data, 'Data should be available'); diff --git a/app/models/Document.js b/app/models/Document.js index 4f4c760e..d882c039 100644 --- a/app/models/Document.js +++ b/app/models/Document.js @@ -5,7 +5,7 @@ import invariant from 'invariant'; import { client } from 'utils/ApiClient'; import stores from 'stores'; import ErrorsStore from 'stores/ErrorsStore'; -import parseTitle from '../../shared/parseTitle'; +import parseTitle from '../../shared/utils/parseTitle.js'; import type { User } from 'types'; import BaseModel from './BaseModel'; diff --git a/app/scenes/CollectionEdit/CollectionEdit.js b/app/scenes/CollectionEdit/CollectionEdit.js index 17fc570b..db5b0711 100644 --- a/app/scenes/CollectionEdit/CollectionEdit.js +++ b/app/scenes/CollectionEdit/CollectionEdit.js @@ -7,6 +7,7 @@ import Button from 'components/Button'; import Input from 'components/Input'; import Flex from 'shared/components/Flex'; import HelpText from 'components/HelpText'; +import ColorPicker from 'components/ColorPicker'; import Collection from 'models/Collection'; type Props = { @@ -18,6 +19,7 @@ type Props = { @observer class CollectionEdit extends Component { props: Props; @observable name: string; + @observable color: string = ''; @observable isSaving: boolean; componentWillMount() { @@ -28,7 +30,7 @@ type Props = { ev.preventDefault(); 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(); if (success) { @@ -42,6 +44,10 @@ type Props = { this.name = ev.target.value; }; + handleColor = (color: string) => { + this.color = color; + }; + render() { return ( @@ -58,6 +64,10 @@ type Props = { required autoFocus /> + diff --git a/frontend/components/ColorPicker/ColorPicker.js b/frontend/components/ColorPicker/ColorPicker.js new file mode 100644 index 00000000..58df07be --- /dev/null +++ b/frontend/components/ColorPicker/ColorPicker.js @@ -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 ( + + Color + + + {colors.map(color => ( + this.setColor(color)} + /> + ))} + + + Custom color: + # + + + + + + ); + } +} + +type SwatchProps = { + onClick?: Function, + color?: string, + active?: boolean, +}; + +const Swatch = ({ onClick, ...props }: SwatchProps) => ( + + + +); + +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; diff --git a/frontend/components/ColorPicker/index.js b/frontend/components/ColorPicker/index.js new file mode 100644 index 00000000..84f7ebb8 --- /dev/null +++ b/frontend/components/ColorPicker/index.js @@ -0,0 +1,3 @@ +// @flow +import ColorPicker from './ColorPicker'; +export default ColorPicker; diff --git a/server/api/collections.js b/server/api/collections.js index 2e76179b..e7964c10 100644 --- a/server/api/collections.js +++ b/server/api/collections.js @@ -11,14 +11,17 @@ import { Collection } from '../models'; const router = new Router(); 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'); + if (color) + ctx.assertHexColor(color, 'Invalid hex value (please use format #FFFFFF)'); const user = ctx.state.user; const collection = await Collection.create({ name, description, + color, type: type || 'atlas', teamId: user.teamId, creatorId: user.id, @@ -30,11 +33,14 @@ router.post('collections.create', 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'); + if (color) + ctx.assertHexColor(color, 'Invalid hex value (please use format #FFFFFF)'); const collection = await Collection.findById(id); collection.name = name; + collection.color = color; await collection.save(); ctx.body = { diff --git a/server/api/middlewares/validation.js b/server/api/middlewares/validation.js index 874cbecf..d7ef52bc 100644 --- a/server/api/middlewares/validation.js +++ b/server/api/middlewares/validation.js @@ -1,6 +1,7 @@ // @flow import apiError from '../../errors'; import validator from 'validator'; +import { validateColorHex } from '../../../shared/utils/color'; export default function validation() { 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(); }; } diff --git a/server/migrations/20171023064220-collection-color.js b/server/migrations/20171023064220-collection-color.js new file mode 100644 index 00000000..b67a8b51 --- /dev/null +++ b/server/migrations/20171023064220-collection-color.js @@ -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'); + }, +}; diff --git a/server/models/Collection.js b/server/models/Collection.js index a05f0289..8cfd45ee 100644 --- a/server/models/Collection.js +++ b/server/models/Collection.js @@ -22,6 +22,7 @@ const Collection = sequelize.define( urlId: { type: DataTypes.STRING, unique: true }, name: DataTypes.STRING, description: DataTypes.STRING, + color: DataTypes.STRING, type: { type: DataTypes.STRING, validate: { isIn: allowedCollectionTypes }, diff --git a/server/models/Document.js b/server/models/Document.js index 95e3b146..61028013 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -5,7 +5,7 @@ import randomstring from 'randomstring'; import isUUID from 'validator/lib/isUUID'; import { DataTypes, sequelize } from '../sequelize'; -import parseTitle from '../../shared/parseTitle'; +import parseTitle from '../../shared/utils/parseTitle.js'; import Revision from './Revision'; const URL_REGEX = /^[a-zA-Z0-9-]*-([a-zA-Z0-9]{10,15})$/; diff --git a/server/presenters/collection.js b/server/presenters/collection.js index aeae3dce..d9f3b05e 100644 --- a/server/presenters/collection.js +++ b/server/presenters/collection.js @@ -11,6 +11,7 @@ async function present(ctx: Object, collection: Collection) { url: collection.getUrl(), name: collection.name, description: collection.description, + color: collection.color || '#4E5C6E', type: collection.type, createdAt: collection.createdAt, updatedAt: collection.updatedAt, diff --git a/shared/styles/constants.js b/shared/styles/constants.js index 9bd4f6f4..d3c72bd3 100644 --- a/shared/styles/constants.js +++ b/shared/styles/constants.js @@ -35,6 +35,13 @@ export const fontWeight = { 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 = { text: '#171B35', diff --git a/shared/utils/color.js b/shared/utils/color.js new file mode 100644 index 00000000..fd34e65d --- /dev/null +++ b/shared/utils/color.js @@ -0,0 +1,4 @@ +// @flow + +export const validateColorHex = (color: string) => + /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(color); diff --git a/shared/parseTitle.js b/shared/utils/parseTitle.js similarity index 100% rename from shared/parseTitle.js rename to shared/utils/parseTitle.js