summaryrefslogtreecommitdiff
path: root/frontend/app/common
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/app/common')
-rw-r--r--frontend/app/common/app.css428
-rw-r--r--frontend/app/common/copyToClipboardButton.component.js24
-rw-r--r--frontend/app/common/fonts.css55
-rw-r--r--frontend/app/common/form.component.js220
-rw-r--r--frontend/app/common/form.css323
-rw-r--r--frontend/app/common/header.component.js41
-rw-r--r--frontend/app/common/imageCrop.component.js41
-rw-r--r--frontend/app/common/index.js32
-rw-r--r--frontend/app/common/loader.component.js16
-rw-r--r--frontend/app/common/loader.css125
-rw-r--r--frontend/app/common/menubutton.component.js128
-rw-r--r--frontend/app/common/miscellaneous.component.js71
-rw-r--r--frontend/app/common/miscellaneous.css18
-rw-r--r--frontend/app/common/modal.component.js9
-rw-r--r--frontend/app/common/modal.css20
-rw-r--r--frontend/app/common/slider.component.js120
-rw-r--r--frontend/app/common/table.component.js128
-rw-r--r--frontend/app/common/table.css96
-rw-r--r--frontend/app/common/tableIndex.component.js129
-rw-r--r--frontend/app/common/upload.css26
-rw-r--r--frontend/app/common/upload.helpers.js174
-rw-r--r--frontend/app/common/uploadImage.component.js74
22 files changed, 2298 insertions, 0 deletions
diff --git a/frontend/app/common/app.css b/frontend/app/common/app.css
new file mode 100644
index 0000000..d9f9946
--- /dev/null
+++ b/frontend/app/common/app.css
@@ -0,0 +1,428 @@
+* { box-sizing: border-box; }
+html, body {
+ margin: 0;
+ padding: 0;
+ width: 100%;
+ height: 100%;
+}
+body {
+ background: #000;
+ color: #ddd;
+ overflow: hidden;
+ font-family: 'Roboto', sans-serif;
+ font-size: 0.875rem;
+ height: 100%;
+ width: 100%;
+}
+.gray {
+ color: #888;
+}
+
+/* layout */
+
+.container {
+ height: 100%;
+ width: 100%;
+}
+.app {
+ /*display: flex;*/
+ height: 100%;
+ width: 100%;
+}
+.app > div {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ width: 100%;
+}
+.app .body {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ position: relative;
+ height: 100%;
+ width: 100%;
+}
+
+.row {
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-start;
+ align-items: flex-start;
+}
+.row > div {
+ margin-right: 1.5rem;
+}
+.row > div:last-child {
+ margin-right: 0;
+}
+
+
+.row.menubar {
+ justify-content: flex-end;
+}
+.menubar > :first-child {
+ flex: 1;
+}
+
+/* lists */
+
+ul {
+ margin: 0.75rem 0;
+}
+li {
+ line-height: 1.5;
+}
+
+/* header */
+
+header {
+ height: 3.125rem;
+ font-size: 0.875rem;
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ background: rgba(16,32,64,0.5);
+ color: white;
+ z-index: 50;
+}
+header b {
+ font-weight: 900;
+}
+header a {
+ color: rgba(255,255,255,0.95);
+ text-decoration: none;
+ font-size: 0.875rem;
+ font-weight: 500;
+}
+header > div:first-child {
+ display: flex;
+ justify-content: flex-start;
+ align-items: center;
+ padding-left: 1.5rem;
+}
+header > div:last-child {
+ padding-right: 1.5rem;
+}
+header > div > button {
+ padding: 0.25rem;
+ margin: 0 0 0 0.5rem;
+ background: #000;
+ border-color: #888;
+ color: #888;
+}
+header > div > button:hover {
+ border-color: #fff;
+ color: #fff;
+}
+header .vcat-btn {
+ font-size: 0.875rem;
+ padding-left: 0.5rem;
+ letter-spacing: 0.0625rem;
+}
+header > div:last-child a {
+ padding: 0.5rem;
+}
+header .btn-link:focus,
+header .btn-link:hover,
+header .btn-link:active,
+header a:focus,
+header a:hover,
+header a:active {
+ text-decoration: none;
+ color: white;
+}
+header a:focus,
+header a:hover,
+header a:active {
+ color: white;
+}
+.menuToggle {
+ width: 1.625rem;
+ height: 1.625rem;
+ cursor: pointer;
+ line-height: 1;
+}
+header a.navbar-brand {
+ font-size: .8rem;
+}
+
+header .username {
+ cursor: pointer;
+}
+
+/* headings */
+
+h1 {
+ color: #eee;
+ margin-bottom: 1.25rem;
+ font-size: 1.5rem;
+ font-weight: normal;
+}
+div:first-child > h1:first-child,
+.menuButtons + div > h1:first-child {
+ margin-top: 0;
+}
+h2 {
+ color: #eee;
+ font-size: 1.25rem;
+ font-weight: normal;
+}
+h3 {
+ color: #eee;
+ margin-top: 0;
+ margin-bottom: 1.25rem;
+ font-size: 1.0rem;
+ font-weight: normal;
+}
+p {
+ margin: 1.25rem 0;
+ line-height: 1.5;
+}
+.byline {
+ color: #888;
+ font-size: 0.75rem;
+ margin-top: 0.25rem;
+ margin-bottom: 1.25rem;
+}
+
+/* links */
+
+b {
+ color: #fff;
+}
+a {
+ text-decoration: underline;
+ color: #8df;
+}
+
+/* menu button */
+
+.menuButtons {
+ width: 2.5rem;
+ min-height: 18rem;
+}
+.menuButton {
+ position: relative;
+ text-align: center;
+ text-transform: uppercase;
+ font-size: 0.625rem;
+ color: #333;
+ text-decoration: none;
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ width: 2.5rem;
+ margin-bottom: 0.75rem;
+}
+.menuButton .icon {
+ background-color: #fff;
+ width: 2.5rem;
+ height: 2.5rem;
+ border: 1px solid;
+ border-color: #888;
+ margin-bottom: 0.3rem;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ transition: border-color 0.1s;
+ border-radius: 0.125rem;
+}
+.menuButton svg {
+ width: 80%;
+ fill: #888;
+}
+.menuButton:hover .icon {
+ background-color: #eef;
+ border-color: #000;
+}
+.menuButton:hover svg {
+ fill: #000;
+}
+
+.menuButton.small {
+ width: 2.0rem;
+ margin-bottom: 0;
+ margin-right: 0.1875rem;
+}
+.menuButton.small .icon {
+ border: 0;
+ border-color: #888;
+ margin-bottom: 0;
+ width: 2.0rem;
+ height: 2.0rem;
+ border-radius: 0.0625rem;
+}
+.menuButton.small svg {
+ fill: #888;
+ width: 1.75rem;
+ height: 1.75rem;
+}
+.menuButton.small .icon:hover {
+ border-color: #11f;
+ background-color: #11f;
+}
+.menuButton.small:hover svg {
+ fill: #fff;
+}
+.results.th .menuButton.small {
+ width: 1.5rem;
+}
+.results.th .menuButton.small .icon {
+ width: 1.5rem;
+ height: 1.5rem;
+}
+.results.th .menuButton.small svg {
+ width: 1.25rem;
+ height: 1.25rem;
+}
+
+.menuButton.small.active .icon {
+ border-color: #11f;
+ background-color: #11f;
+}
+.menuButton.small.active svg {
+ fill: #fff;
+}
+.menuButton.small.active:hover .icon {
+ border-color: #fff;
+ background-color: #11f;
+}
+.menuButton.small.active:hover svg {
+ fill: #fff;
+}
+
+/* rows - like a table */
+
+.rows {
+ width: 100%;
+}
+.rows .row {
+ width: 100%;
+}
+.rows .row:nth-of-type(2n+1) {
+ background: #f8f8f8;
+}
+.rows .row:nth-of-type(2n+2) {
+ background: #eeeeee;
+}
+.rows .row:hover {
+ background: #d8d8d8;
+}
+.rows .row > div,
+.rows .row a > div {
+ padding: 0.75rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin: 0;
+}
+.rows .row div.title {
+ width: 10rem;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+.rows .row div.string,
+.rows .row div.str {
+ min-width: 6rem;
+}
+.rows .row div.bool {
+ width: 4rem;
+ overflow: visible;
+ text-align: center;
+}
+.rows .row div.color {
+ width: 4rem;
+ overflow: visible;
+ text-align: center;
+}
+.rows .row div.date {
+ min-width: 10rem;
+}
+.rows .row div.int,
+.rows .row div.float {
+ text-align: right;
+ min-width: 6rem;
+}
+.rows .row.heading div.int,
+.rows .row.heading div.float {
+ text-align: center;
+}
+.rows .row div.text {
+ flex: 1;
+ max-width: 20rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.rows .row.heading div {
+ text-transform: capitalize;
+ font-weight: bold;
+ background: #f8f8f8;
+}
+.rows .row.heading:hover {
+ background: #f8f8f8;
+}
+
+/* misc ui */
+
+pre, code, .license {
+ font-family: Menlo, monospace;
+ font-size: 0.75rem;
+ line-height: 2;
+}
+.swatch {
+ display: inline-block;
+ width: 0.75rem;
+ height: 0.75rem;
+ border: 1px solid #333;
+}
+.dot {
+ display: inline-block;
+ width: 0.5rem;
+ height: 0.5rem;
+ border-radius: 50%;
+}
+.light {
+ color: #888;
+}
+.pill {
+ display: inline-block;
+ padding: 0.125rem;
+ width: 2.5rem;
+ text-align: center;
+ border-radius: 0.5rem;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ font-weight: bold;
+}
+.pill.yes {
+ background: #11f;
+ color: #fff;
+}
+.pill.no {
+ color: #ccc;
+ border: 1px solid;
+}
+
+/* columns (of tags) */
+
+.form .columnCells {
+ padding-top: 0.25rem
+}
+.columnCells .column {
+ margin-top: 0.25rem;
+}
+.columnCells .column > div {
+ max-width: 100%;
+ padding: 0 0.375rem 0.375rem 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.columnCells .selected {
+ color: #11f;
+} \ No newline at end of file
diff --git a/frontend/app/common/copyToClipboardButton.component.js b/frontend/app/common/copyToClipboardButton.component.js
new file mode 100644
index 0000000..0defba5
--- /dev/null
+++ b/frontend/app/common/copyToClipboardButton.component.js
@@ -0,0 +1,24 @@
+import React, { Component } from 'react'
+import { writeToClipboard } from 'app/utils'
+
+export default class CopyToClipboardButton extends Component {
+ state = {
+ copied: false,
+ }
+
+ handleClick() {
+ writeToClipboard(this.props.data)
+ this.setState({ copied: true })
+ }
+
+ render() {
+ return (
+ <button
+ className={this.state.copied ? 'copyButton copied' : 'copyButton'}
+ onClick={this.handleClick.bind(this)}
+ >
+ {this.state.copied ? 'Copied!' : 'Copy'}
+ </button>
+ )
+ }
+}
diff --git a/frontend/app/common/fonts.css b/frontend/app/common/fonts.css
new file mode 100644
index 0000000..c782885
--- /dev/null
+++ b/frontend/app/common/fonts.css
@@ -0,0 +1,55 @@
+@font-face {
+ font-family: 'Roboto';
+ src: url('/static/fonts/Roboto-Bold.ttf') format('truetype');
+ font-weight: bold;
+}
+@font-face {
+ font-family: 'Roboto';
+ src: url('/static/fonts/Roboto-BoldItalic.ttf') format('truetype');
+ font-weight: bold;
+ font-style: italic;
+}
+/*
+@font-face {
+ font-family: 'Roboto';
+ src: url('/static/fonts/Roboto-Light.ttf') format('truetype');
+ font-weight: 100;
+}
+@font-face {
+ font-family: 'Roboto';
+ src: url('/static/fonts/Roboto-LightItalic.ttf') format('truetype');
+ font-weight: 100;
+}
+*/
+@font-face {
+ font-family: 'Roboto';
+ src: url('/static/fonts/Roboto-Medium.ttf') format('truetype');
+ font-weight: 300;
+}
+@font-face {
+ font-family: 'Roboto';
+ src: url('/static/fonts/Roboto-MediumItalic.ttf') format('truetype');
+ font-style: italic;
+ font-weight: 300;
+}
+@font-face {
+ font-family: 'Roboto';
+ src: url('/static/fonts/Roboto-Regular.ttf') format('truetype');
+}
+@font-face {
+ font-family: 'Roboto';
+ src: url('/static/fonts/Roboto-Italic.ttf') format('truetype');
+ font-style: italic;
+}
+/*
+@font-face {
+ font-family: 'Roboto';
+ src: url('/static/fonts/Roboto-Thin.ttf') format('truetype');
+ font-weight: 100;
+}
+@font-face {
+ font-family: 'Roboto';
+ src: url('/static/fonts/Roboto-ThinItalic.ttf') format('truetype');
+ font-weight: 100;
+}
+*/ \ No newline at end of file
diff --git a/frontend/app/common/form.component.js b/frontend/app/common/form.component.js
new file mode 100644
index 0000000..cf3e466
--- /dev/null
+++ b/frontend/app/common/form.component.js
@@ -0,0 +1,220 @@
+import React, { Component } from 'react';
+import { courtesyS } from 'app/utils'
+
+export const TextInput = props => (
+ <label className={props.error ? 'error' : 'text'}>
+ {props.title && <span>{props.title}</span>}
+ <input
+ type="text"
+ required={props.required}
+ onChange={props.onChange}
+ name={props.name}
+ value={props.data[props.name]}
+ placeholder={props.placeholder}
+ autoComplete={props.autoComplete}
+ />
+ </label>
+)
+
+export const LabelDescription = props => (
+ <label className={'text description'}>
+ <span>{props.title}</span>
+ <span>{props.children}</span>
+ </label>
+)
+
+export const NumberInput = props => (
+ <label className={props.error ? 'error' : 'text'}>
+ <span>{props.title}</span>
+ <input
+ type="number"
+ required={props.required}
+ onChange={props.onChange}
+ name={props.name}
+ value={props.data[props.name]}
+ min={props.min}
+ max={props.max}
+ step={props.step || 1}
+ />
+ </label>
+)
+
+export const ColorInput = props => (
+ <label className={props.error ? 'error color' : 'text color'}>
+ <span>{props.title}</span>
+ <input
+ type="color"
+ required={props.required}
+ onChange={props.onChange}
+ name={props.name}
+ value={props.data[props.name]}
+ />
+ <input
+ type="text"
+ required={props.required}
+ onChange={props.onChange}
+ name={props.name}
+ value={props.data[props.name]}
+ />
+ </label>
+)
+
+export const TextArea = props => (
+ <label className={props.error ? 'textarea error' : 'textarea'}>
+ {props.title && <span>{props.title}</span>}
+ <textarea
+ onChange={props.onChange}
+ name={props.name}
+ value={props.data[props.name]}
+ />
+ </label>
+)
+
+export const Checkbox = props => (
+ <label className="checkbox">
+ <input
+ type="checkbox"
+ name={props.name}
+ value={1}
+ checked={props.checked}
+ onChange={(e) => props.onChange(props.name, e.target.checked)}
+ />
+ <span>{props.label}</span>
+ </label>
+)
+
+export const Radio = props => {
+ return (
+ <label className="radio">
+ <input
+ type="radio"
+ name={props.name}
+ value={props.value}
+ checked={props.value === props.currentValue}
+ onChange={() => props.onChange(props.name, props.value)}
+ />
+ <span>{props.label}</span>
+ </label>
+ )
+}
+
+export class Select extends Component {
+ state = {
+ focused: false,
+ }
+
+ render() {
+ const { name, selected, options, defaultOption, title, loading, onChange, className } = this.props
+ if (loading) {
+ return <label className='select'><div>Loading...</div></label>
+ }
+ const { focused } = this.state
+ return (
+ <label>
+ {title && <span>{title}</span>}
+ <div className={(focused ? 'select focus' : 'select') + " " + (className || "")}>
+ <div>{(options.find(opt => opt.name === selected) || {label: defaultOption}).label}</div>
+ <select
+ onFocus={() => this.setState({ focused: true })}
+ onBlur={() => this.setState({ focused: false })}
+ onChange={e => {
+ onChange(name, e.target.value)
+ // this.setState({ focused: false })
+ }}
+ value={selected || "__default__"}
+ >
+ {!selected && defaultOption && <option value="__default__">{defaultOption}</option>}
+ {options.map((option, i) => (
+ <option
+ key={option.name}
+ value={option.name}
+ disabled={option.disabled}
+ >{option.label}</option>
+ ))}
+ </select>
+ </div>
+ </label>
+ )
+ }
+}
+
+export class FileInputField extends Component {
+ state = {
+ count: 0,
+ }
+
+ handleChange(files) {
+ const { multiple, onChange } = this.props
+ if (!files) {
+ this.setState({ count: 0 })
+ } else {
+ this.setState({ count: multiple ? files.length : 0 })
+ }
+ onChange(files)
+ }
+
+ render() {
+ const { error, title, label, required, multiple, mime, name } = this.props
+ return (
+ <label className={error ? 'error' : 'text fileInput'}>
+ <span>{title}</span>
+ <div className="row">
+ <button>
+ {label || "Choose files"}
+ <FileInput
+ mime={mime}
+ multiple={multiple}
+ onChange={this.handleChange.bind(this)}
+ />
+ </button>
+ {!!this.state.count && <span>{courtesyS(this.state.count, "file")}{" selected"}</span>}
+ </div>
+ </label>
+ )
+ }
+}
+
+export class FileInput extends Component {
+ handleChange(e) {
+ let { multiple, mime } = this.props
+ if (!mime) {
+ mime = "image/"
+ }
+ const files = e.dataTransfer ? e.dataTransfer.files : e.target.files
+ let i
+ let file, selectedFiles = []
+ for (i = 0; i < files.length; i++) {
+ file = files[i]
+ if (file && file.type.indexOf(mime) === 0) {
+ if (multiple) {
+ selectedFiles.push(file)
+ } else {
+ break
+ }
+ }
+ }
+ if (multiple && selectedFiles.length) {
+ this.props.onChange(selectedFiles)
+ } else if (!multiple && file) {
+ this.props.onChange(file)
+ } else {
+ this.props.onChange()
+ }
+ }
+
+ render() {
+ return (
+ <input type="file" multiple={!!this.props.multiple} onChange={this.handleChange.bind(this)} />
+ )
+ }
+}
+
+export const SubmitButton = (props) => (
+ <label>
+ <span></span>
+ <button
+ className={props.className ? "submit " + props.className : "submit"}
+ onClick={props.onClick}
+ >{props.title}</button>
+ </label>
+)
diff --git a/frontend/app/common/form.css b/frontend/app/common/form.css
new file mode 100644
index 0000000..dbfa01f
--- /dev/null
+++ b/frontend/app/common/form.css
@@ -0,0 +1,323 @@
+/* label */
+
+label {
+ display: flex;
+ min-width: 10rem;
+ flex-direction: row;
+ justify-content: flex-start;
+ align-items: center;
+ cursor: pointer;
+}
+
+.label {
+ display: flex;
+ min-width: 10rem;
+ flex-direction: row;
+ justify-content: flex-start;
+ align-items: flex-start;
+ cursor: pointer;
+}
+
+/* form (stock forms) */
+
+form label,
+form .label {
+ width: 100%;
+ margin-bottom: 0.5rem;
+}
+form label span,
+form .label > span {
+ display: inline-block;
+ min-width: 8rem;
+ padding: 0.5rem 0;
+}
+form label.textarea {
+ align-items: flex-start;
+}
+form input[type="checkbox"] {
+ margin: -0.0625rem 0.625rem 0 8rem;
+}
+
+/* form errors */
+
+form .error span {
+ color: #f11;
+}
+form .error input[type=text],
+form .error input[type=number],
+form .error input[type=password] {
+ border-color: #f11;
+}
+
+/* form field descriptions */
+
+form label.description {
+ cursor: normal;
+ font-size: small;
+ color: #ddd;
+}
+form label.description span {
+ padding-top: 0;
+}
+
+/* text input */
+
+input[type=text],
+input[type=number],
+input[type=password] {
+ padding: 0.5rem;
+ border: 1px solid #ddd;
+ color: #fff;
+ background: #111;
+ font-family: 'Roboto', sans-serif;
+ font-size: 0.875rem;
+ width: 15rem;
+ border-radius: 0.125rem;
+}
+
+input[type=text]:focus,
+input[type=number]:focus,
+input[type=password]:focus {
+ border: 1px solid #84f;
+ background: #000;
+}
+
+textarea {
+ width: 20rem;
+ height: 10rem;
+ padding: 0.5rem;
+ border: 1px solid #ddd;
+ font-family: 'Roboto', sans-serif;
+ background: #111;
+ color: #fff;
+ font-size: 0.875rem;
+ border-radius: 0.125rem;
+}
+textarea:focus {
+ border: 1px solid #84f;
+ background: #000;
+}
+
+/* checkbox */
+
+input[type=checkbox] {
+ position: relative;
+ display: block;
+ width: 0.75rem;
+ height: 0.75rem;
+ margin-right: 0.625rem;
+ cursor: pointer;
+ outline: 0;
+}
+input[type=checkbox] + span {
+ font-size: 0.825rem;
+ text-transform: uppercase;
+ color: #444;
+}
+input[type=checkbox]:hover + span {
+ color: #000;
+}
+input[type=checkbox]:focus + span {
+ color: #84f;
+}
+input[type="checkbox"]:checked + span {
+ color: #000;
+}
+input[type="checkbox"]:focus:checked + span {
+ color: #84f;
+}
+
+input[type="checkbox"]:after {
+ position: relative;
+ display: block;
+ left: 0;
+ width: 0.75rem;
+ height: 0.75rem;
+ border: 0.0625rem solid #ddd;
+ content: "";
+ background-color: #fff;
+ background-repeat: no-repeat;
+ background-position: center;
+ cursor: pointer;
+ transition: background-color 0.1s;
+ border-radius: 0.125rem;
+}
+input[type=checkbox]:focus:after {
+ border-color: #84f;
+}
+input[type="checkbox"]:checked:after {
+ border-color: #84f;
+ background-color: #84f;
+ background-image: url(/static/img/check.svg);
+ background-size: cover;
+}
+
+/* select */
+
+.select {
+ position: relative;
+ width: 9rem;
+ min-width: auto;
+ background: #111;
+ border-radius: 0.125rem;
+ border: 1px solid #ddd;
+ padding: 0.5rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-right: 1.25rem;
+ cursor: pointer;
+}
+.select select {
+ position: absolute;
+ top: 0; left: 0;
+ width: 100%; height: 100%;
+ opacity: 0;
+ cursor: pointer;
+}
+.select:after {
+ content: '';
+ position: absolute;
+ top: 50%;
+ right: 0.375rem;
+ transform: translateY(-0.125rem);
+ width: 0;
+ height: 0;
+ border-left: 0.375rem solid transparent;
+ border-right: 0.375rem solid transparent;
+ border-top: 0.375rem solid #ddd;
+}
+.select.focus {
+ border-color: #84f;
+ background: #000;
+}
+.select.focus:after {
+ border-top-color: #84f;
+}
+.select:hover {
+ background-color: #000;
+}
+.select div {
+ width: calc(100% - 1.025rem);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.select.wide {
+ width: 20rem;
+}
+
+/* button */
+
+button {
+ position: relative;
+ background: #333;
+ border-radius: 0.125rem;
+ color: #ddd;
+ border: 1px solid;
+ padding: 0.5rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-family: 'Roboto', sans-serif;
+ font-size: 0.875rem;
+ cursor: pointer;
+ /*text-transform: uppercase;*/
+ transition: all 0.1s;
+}
+button:hover {
+ background-color: #000;
+ border-color: #fff;
+}
+button.process {
+ padding-left: 1.5rem;
+}
+button.process:after {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 0.625rem;
+ transform: translateY(-0.375rem);
+ width: 0;
+ height: 0;
+ border-top: 0.375rem solid transparent;
+ border-bottom: 0.375rem solid transparent;
+ border-left: 0.375rem solid #888;
+}
+button.process:focus:after {
+ border-left-color: #84f;
+}
+button:focus {
+ background: #000;
+ border-color: #fff;
+ color: #fff;
+ outline: 0;
+}
+button:disabled {
+ background: #eee;
+ color: #888;
+ border-color: #bbb;
+ pointer-events: none;
+}
+button:disabled:after {
+ border-left-color: #aaa;
+}
+.buttons button {
+ margin-right: 0.75rem;
+}
+button.submit {
+ border-color: #d8f;
+ color: #fff;
+ background: #111;
+}
+button.submit:focus,
+button.submit:hover {
+ border-color: #fff;
+ color: #fff;
+ background: #222;
+}
+button.submit.destroy {
+ background-color: rgba(16,16,16,0.5);
+ border-color: #b11;
+ color: #d11;
+}
+button.submit.destroy:focus,
+button.submit.destroy:hover {
+ background: #000;
+ border-color: #f33;
+ color: #f33;
+}
+
+/* file upload, should always be inside a container */
+
+input[type=file] {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ opacity: 0;
+ cursor: pointer;
+}
+::-webkit-file-upload-button { cursor: pointer; }
+.fileInput > .row {
+ align-items: center;
+}
+.fileInput > .row > span {
+ padding-left: 1rem;
+}
+
+/* copy button */
+
+.copyButton {
+ border-color: transparent;
+ color: #84f;
+ font-size: 0.675rem;
+ padding: 0.25rem;
+ margin-left: 0.25rem;
+}
+.desktop .copyButton:hover {
+ border-color: #84f;
+}
+.copyButton.copied {
+ color: #84f;
+} \ No newline at end of file
diff --git a/frontend/app/common/header.component.js b/frontend/app/common/header.component.js
new file mode 100644
index 0000000..115c9b4
--- /dev/null
+++ b/frontend/app/common/header.component.js
@@ -0,0 +1,41 @@
+import React from 'react'
+// import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+import { Link } from 'react-router-dom'
+import { session } from 'app/session'
+
+function Header(props) {
+ return (
+ <header>
+ <div>
+ <Link to="/" className="logo"><b>{props.site.siteTitle}</b></Link>
+ </div>
+ <div>
+ <span className='username' onClick={() => changeUsername()}>
+ {' → '}{props.username}
+ </span>
+ </div>
+ </header>
+ )
+}
+
+const changeUsername = () => {
+ const username = prompt("Please enter your username:", session('username'))
+ if (username && username.length) {
+ session.set('username', username)
+ document.querySelector('Header div span').innerText = ' → ' + username // very naughty
+ }
+}
+
+
+const mapStateToProps = (state) => ({
+ // auth: state.auth,
+ site: state.site,
+ username: session.get('username'),
+ // isAuthenticated: state.auth.isAuthenticated,
+})
+
+const mapDispatchToProps = (dispatch) => ({
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(Header)
diff --git a/frontend/app/common/imageCrop.component.js b/frontend/app/common/imageCrop.component.js
new file mode 100644
index 0000000..f687d90
--- /dev/null
+++ b/frontend/app/common/imageCrop.component.js
@@ -0,0 +1,41 @@
+import React, { Component } from 'react';
+import { cropImage } from 'app/utils'
+
+export default class ImageCrop extends Component {
+ state = {
+ cropURL: null
+ }
+
+ componentDidMount() {
+ const { url, crop } = this.props
+ this.crop(url, crop)
+ }
+
+ componentDidUpdate(prevProps) {
+ const { url, crop } = this.props
+ if (this.props.crop !== prevProps.crop) {
+ cropImage(url, crop).then(canvas =>{
+ const cropURL = canvas.toDataURL('image/jpeg', 0.8)
+ this.setState({ cropURL })
+ })
+ }
+ }
+
+ crop(url, crop) {
+ cropImage(url, crop).then(canvas =>{
+ const cropURL = canvas.toDataURL('image/jpeg', 0.8)
+ this.setState({ cropURL })
+ })
+ }
+
+
+ render() {
+ const { cropURL } = this.state
+ if (!cropURL) {
+ return null
+ }
+ return (
+ <img src={cropURL} className='preview' />
+ )
+ }
+}
diff --git a/frontend/app/common/index.js b/frontend/app/common/index.js
new file mode 100644
index 0000000..5c0dc50
--- /dev/null
+++ b/frontend/app/common/index.js
@@ -0,0 +1,32 @@
+export { default as Header } from './header.component'
+export {
+ MenuButton, SmallMenuButton, MenuRoute,
+} from './menubutton.component'
+export {
+ Select, Checkbox, Radio, FileInput, FileInputField,
+ TextInput, NumberInput, TextArea, SubmitButton,
+ LabelDescription, ColorInput,
+} from './form.component'
+export {
+ Swatch, Dot, Columns, Statistic, Detections, Progress
+} from './miscellaneous.component'
+export { default as TableIndex } from './tableIndex.component'
+export { Loader } from './loader.component'
+export {
+ TableObject, TableArray, TableTuples,
+ TableRow, TableCell
+} from './table.component'
+export { default as CopyToClipboardButton } from './copyToClipboardButton.component'
+export { default as ImageCrop } from './imageCrop.component'
+export { Modal } from './modal.component'
+export { default as UploadImage } from './uploadImage.component'
+export { default as Slider } from './slider.component'
+
+import './fonts.css'
+import './app.css'
+import './form.css'
+import './loader.css'
+import './table.css'
+import './modal.css'
+import './miscellaneous.css'
+import './upload.css'
diff --git a/frontend/app/common/loader.component.js b/frontend/app/common/loader.component.js
new file mode 100644
index 0000000..f0a0c69
--- /dev/null
+++ b/frontend/app/common/loader.component.js
@@ -0,0 +1,16 @@
+import React, { Component } from 'react';
+
+import './loader.css'
+
+const Loader = () => (
+ <div>
+ <div className='circular-loader color'>
+ <div className="stroke">
+ <div className="stroke-left"></div>
+ <div className="stroke-right"></div>
+ </div>
+ </div>
+ </div>
+)
+
+export { Loader }
diff --git a/frontend/app/common/loader.css b/frontend/app/common/loader.css
new file mode 100644
index 0000000..f047e8e
--- /dev/null
+++ b/frontend/app/common/loader.css
@@ -0,0 +1,125 @@
+
+@keyframes L_circle_rotate {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+@keyframes L_stroke_rotate {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(1080deg);
+ }
+}
+@keyframes L_stroke_fix {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 50% {
+ transform: rotate(135deg);
+ }
+ 100% {
+ transform: rotate(270deg);
+ }
+}
+@keyframes L_stroke_left_grow {
+ 0% {
+ transform: rotate(-5deg);
+ }
+ 50% {
+ transform: rotate(-140deg);
+ }
+ 100% {
+ transform: rotate(-5deg);
+ }
+}
+@keyframes L_stroke_right_grow {
+ 0% {
+ transform: rotate(5deg);
+ }
+ 50% {
+ transform: rotate(140deg);
+ }
+ 100% {
+ transform: rotate(5deg);
+ }
+}
+.circular-loader .stroke::before, .circular-loader .stroke-right::before, .circular-loader .stroke-left::before {
+ content: "";
+ display: block;
+}
+
+.circular-loader, .circular-loader .stroke, .circular-loader .stroke::before, .circular-loader .stroke-right, .circular-loader .stroke-right::before, .circular-loader .stroke-left, .circular-loader .stroke-left::before {
+ width: 2em;
+ height: 2em;
+ box-sizing: border-box;
+ border-radius: 50%;
+}
+
+.circular-loader .stroke::before, .circular-loader .stroke-right::before, .circular-loader .stroke-left::before {
+ border-style: solid;
+ border-width: 0.21429em;
+ border-color: #778;
+}
+
+.circular-loader .stroke-right, .circular-loader .stroke-left::before {
+ position: absolute;
+ clip: rect(0 2em 2em 1em);
+}
+.circular-loader .stroke-right::before, .circular-loader .stroke-left {
+ position: absolute;
+ clip: rect(0 1em 2em 0);
+}
+.circular-loader .stroke::before {
+ position: absolute;
+ clip: rect(0 1.05em 1em 0.95em);
+}
+
+/**/
+.circular-loader {
+ animation: L_circle_rotate 1568.23529ms linear infinite both;
+}
+.circular-loader .stroke::before,
+.circular-loader .stroke-right, .circular-loader .stroke-left {
+ animation: L_stroke_fix 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;
+}
+.circular-loader .stroke {
+ animation: L_stroke_rotate 5332ms steps(4) infinite both;
+}
+.circular-loader .stroke-right::before {
+ animation: L_stroke_right_grow 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;
+}
+.circular-loader .stroke-left::before {
+ animation: L_stroke_left_grow 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;
+}
+@keyframes color_K {
+ 0%, 15% {
+ border-color: #44444f;
+ }
+ 25%, 40% {
+ border-color: #bbbbc7;
+ }
+ 50%, 65% {
+ border-color: #66666f;
+ }
+ 75%, 90% {
+ border-color: #ccccd4;
+ }
+ 100% {
+ border-color: #44444f;
+ }
+}
+.circular-loader.color .stroke::before {
+ animation: L_stroke_fix 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both, color_K 5332ms linear infinite both;
+}
+.circular-loader.color .stroke-right::before {
+ animation: L_stroke_right_grow 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both, color_K 5332ms linear infinite both;
+}
+.circular-loader.color .stroke-left::before {
+ animation: L_stroke_left_grow 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both, color_K 5332ms linear infinite both;
+}
+
diff --git a/frontend/app/common/menubutton.component.js b/frontend/app/common/menubutton.component.js
new file mode 100644
index 0000000..5fd8e2f
--- /dev/null
+++ b/frontend/app/common/menubutton.component.js
@@ -0,0 +1,128 @@
+import React, { Component } from 'react'
+import { Route, Link } from 'react-router-dom'
+import { history } from 'app/store'
+
+const icons = {
+ upload: {
+ title: 'Upload',
+ image: '/static/img/add.svg',
+ // svg: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0V0z"/><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>,
+ // svg: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" d="M9,16V10H5L12,3L19,10H15V16H9M5,20V18H19V20H5Z" /></svg>
+ // svg: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9,10V16H15V10H19L12,3L5,10H9M12,5.8L14.2,8H13V14H11V8H9.8L12,5.8M19,18H5V20H19V18Z" /></svg>
+ svg: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" d="M0,0h24v24H0V0z"/><path d="M19,12v7H5v-7H3v7c0,1.1,0.9,2,2,2h14c1.1,0,2-0.9,2-2v-7H19z M11,6.83L8.41,9.41L7,8l5-5l5,5l-1.41,1.41L13,6.83v9.67h-2 V6.83z"/></svg>,
+ },
+ new: {
+ title: 'New',
+ image: '/static/img/add.svg',
+ svg: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0V0z"/><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>,
+ // svg: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" d="M9,16V10H5L12,3L19,10H15V16H9M5,20V18H19V20H5Z" /></svg>,
+ // svg: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9,10V16H15V10H19L12,3L5,10H9M12,5.8L14.2,8H13V14H11V8H9.8L12,5.8M19,18H5V20H19V18Z" /></svg>
+ },
+ save: {
+ title: 'Export',
+ image: '/static/img/save.svg',
+ svg: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0V0z"/><path d="M19 12v7H5v-7H3v7c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-7h-2zm-6 .67l2.59-2.58L17 11.5l-5 5-5-5 1.41-1.41L11 12.67V3h2v9.67z"/></svg>,
+ },
+ saved: {
+ title: 'Saved',
+ image: '/static/img/folder.svg',
+ svg: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0V0z"/><path d="M9.17 6l2 2H20v10H4V6h5.17M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>,
+ },
+ recent: {
+ title: 'Recent',
+ image: '/static/img/history.svg',
+ svg: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0V0z"/><path d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.25 2.52.77-1.28-3.52-2.09V8z"/></svg>,
+ },
+ random: {
+ title: 'Random',
+ image: '/static/img/random.svg',
+ // svg: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0V0z"/><path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/></svg>,
+ svg: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0z"/><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14z"/><circle cx="7.5" cy="16.5" r="1.5"/><circle cx="7.5" cy="7.5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="16.5" cy="16.5" r="1.5"/><circle cx="16.5" cy="7.5" r="1.5"/></svg>
+ },
+ menu: {
+ title: 'Menu',
+ image: '/static/img/menu.svg',
+ svg: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0V0z"/><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>,
+ },
+ list: {
+ title: 'List',
+ image: '/static/img/view_list.svg',
+ svg: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path opacity=".87" fill="none" d="M0 0h24v24H0V0z"/><path d="M3 5v14h17V5H3zm4 2v2H5V7h2zm-2 6v-2h2v2H5zm0 2h2v2H5v-2zm13 2H9v-2h9v2zm0-4H9v-2h9v2zm0-4H9V7h9v2z"/></svg>,
+ },
+ edit: {
+ title: 'Edit',
+ image: '/static/img/edit.svg',
+ svg: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0V0z"/><path d="M14.06 9.02l.92.92L5.92 19H5v-.92l9.06-9.06M17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75z"/></svg>
+ },
+ delete: {
+ title: 'Delete',
+ image: '/static/img/delete.svg',
+ svg: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0V0z"/><path d="M16 9v10H8V9h8m-1.5-6h-5l-1 1H5v2h14V4h-3.5l-1-1zM18 7H6v12c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7z"/></svg>
+ },
+ back: {
+ title: 'Back',
+ image: '/static/img/back.svg',
+ svg: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path opacity=".87" fill="none" d="M0 0h24v24H0V0z"/><path d="M17.51 3.87L15.73 2.1 5.84 12l9.9 9.9 1.77-1.77L9.38 12l8.13-8.13z"/></svg>,
+ },
+ image_search: {
+ title: 'Search',
+ image: '/static/img/image_search.svg',
+ svg: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0V0z"/><path d="M18 13v7H4V6h5.02c.05-.71.22-1.38.48-2H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-5l-2-2zm-1.5 5h-11l2.75-3.53 1.96 2.36 2.75-3.54zm2.8-9.11c.44-.7.7-1.51.7-2.39C20 4.01 17.99 2 15.5 2S11 4.01 11 6.5s2.01 4.5 4.49 4.5c.88 0 1.7-.26 2.39-.7L21 13.42 22.42 12 19.3 8.89zM15.5 9C14.12 9 13 7.88 13 6.5S14.12 4 15.5 4 18 5.12 18 6.5 16.88 9 15.5 9z"/><path fill="none" d="M0 0h24v24H0z"/></svg>,
+ },
+ search: {
+ title: 'Search',
+ image: '/static/img/search.svg',
+ svg: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/><path d="M0 0h24v24H0z" fill="none"/></svg>,
+ },
+ open_in_new: {
+ title: 'Open',
+ image: '/static/img/open_in_new.svg',
+ svg: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0V0z"/><path d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></svg>,
+ },
+ test: {
+ title: 'Test',
+ image: '/static/img/fastfood.svg',
+ svg: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M18.06 22.99h1.66c.84 0 1.53-.64 1.63-1.46L23 5.05h-5V1h-1.97v4.05h-4.97l.3 2.34c1.71.47 3.31 1.32 4.27 2.26 1.44 1.42 2.43 2.89 2.43 5.29v8.05zM1 21.99V21h15.03v.99c0 .55-.45 1-1.01 1H2.01c-.56 0-1.01-.45-1.01-1zm15.03-7c0-8-15.03-8-15.03 0h15.03zM1.02 17h15v2h-15z"/><path fill="none" d="M0 0h24v24H0z"/></svg>
+ },
+ // export: {
+ // title: 'Export',
+ // image: '/static/img/export.svg',
+ // svg: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" d="M0,0h24v24H0V0z"/><path d="M19,12v7H5v-7H3v7c0,1.1,0.9,2,2,2h14c1.1,0,2-0.9,2-2v-7H19z M11,6.83L8.41,9.41L7,8l5-5l5,5l-1.41,1.41L13,6.83v9.67h-2 V6.83z"/></svg>,
+ // },
+}
+
+const goBack = () => history.goBack()
+
+export const MenuButton = ({ name, href, onClick, label, children, className }) => {
+ const { svg, title } = icons[name]
+ if (name === 'back') {
+ onClick = goBack
+ }
+ if (href) {
+ return (
+ <Link to={href} className={className || 'menuButton'}>
+ <div className='icon'>{svg}</div>
+ {label === false ? "" : title}
+ {children}
+ </Link>
+ )
+ } else {
+ return (
+ <div className={className || 'menuButton'} onClick={onClick}>
+ <div className='icon'>{svg}</div>
+ {label === false ? "" : title}
+ {children}
+ </div>
+ )
+ }
+}
+
+export const SmallMenuButton = (props) => (
+ <MenuButton {...props} label={false} className={props.active ? 'menuButton small active' : 'menuButton small'} />
+)
+
+export const MenuRoute = ({ component: Component, props, ...rest }) => (
+ <Route {...rest} render={routeProps => (
+ <Component {...routeProps} {...props} />
+ )}/>
+)
diff --git a/frontend/app/common/miscellaneous.component.js b/frontend/app/common/miscellaneous.component.js
new file mode 100644
index 0000000..bf2c266
--- /dev/null
+++ b/frontend/app/common/miscellaneous.component.js
@@ -0,0 +1,71 @@
+import React, { Component } from 'react'
+import { Link } from 'react-router-dom'
+import { clamp, percent } from 'app/utils'
+
+export const Swatch = ({ color }) => (
+ <div
+ className='swatch'
+ style={{ backgroundColor: color ? 'rgb(' + color.join(',') + ')' : 'transparent' }}
+ />
+)
+
+export const Dot = ({ color }) => (
+ <div
+ className='dot'
+ style={{ backgroundColor: color }}
+ />
+)
+
+export const Columns = ({ count, margin, width, object, children, className }) => {
+ if (!object || !object.length) object = children
+ if (!object || !object.length) return null
+ margin = margin || 380
+ width = width || 250
+ count = count || Math.floor((window.innerWidth - margin) / width)
+ let columns = []
+ let len = object.length
+ let j = 0
+ for (let i = 0; i < count; i++) {
+ let column_len = len * (i + 1) / count
+ let column = []
+ for (; j < column_len; j++) {
+ column.push(<div key={j}>{object[j]}</div>)
+ }
+ columns.push(<div key={"col_" + i + "_" + j} className='column' style={{ width }}>{column}</div>)
+ if (j >= len) break
+ }
+ return (
+ <div className={'row columnCells ' + className}>
+ {columns}
+ </div>
+ )
+}
+
+export const Statistic = ({ name, value, link }) => (
+ <div className='statistic row'>
+ <div className='title'>{link ? <Link to={link}>{name}</Link> : name}</div>
+ <div className='int'>{value}</div>
+ </div>
+)
+
+export const Detections = ({ detections, labels }) => (
+ (detections || []).map(({ label, rect }, i) => (
+ <div
+ className='rect'
+ key={i}
+ style={{
+ left: percent(clamp(rect.x1)),
+ width: percent(clamp(rect.x2 - rect.x1, 0, Math.min(1.0, 1.0 - rect.x1))),
+ top: percent(clamp(rect.y1)),
+ height: percent(clamp(rect.y2 - rect.y1, 0, Math.min(1.0, 1.0 - rect.y1))),
+ }}>
+ {labels && <span>{label.replace(/_/g, ' ')}</span>}
+ </div>
+ )
+))
+
+export const Progress = ({ current, total }) => (
+ <div className='progress'>
+ <div className='bar' style={{ width: Math.round(100 * current / total) + '%' }} />
+ </div>
+)
diff --git a/frontend/app/common/miscellaneous.css b/frontend/app/common/miscellaneous.css
new file mode 100644
index 0000000..32c3e7b
--- /dev/null
+++ b/frontend/app/common/miscellaneous.css
@@ -0,0 +1,18 @@
+
+/* Progress bar */
+
+.progress {
+ background: #f8f8f8;
+ margin: 0.25rem 0;
+ height: 0.25rem;
+ width: 20rem;
+ position: relative;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2);
+ margin-bottom: 0.2rem;
+}
+.progress .bar {
+ position: absolute;
+ top: 0; left: 0;
+ height: 100%;
+ background: #11f;
+}
diff --git a/frontend/app/common/modal.component.js b/frontend/app/common/modal.component.js
new file mode 100644
index 0000000..75c479c
--- /dev/null
+++ b/frontend/app/common/modal.component.js
@@ -0,0 +1,9 @@
+import React, { Component } from 'react'
+
+export const Modal = ({ visible, children }) => (
+ <div className={visible ? "modal visible" : "modal"}>
+ <div>
+ {children}
+ </div>
+ </div>
+)
diff --git a/frontend/app/common/modal.css b/frontend/app/common/modal.css
new file mode 100644
index 0000000..5e95a09
--- /dev/null
+++ b/frontend/app/common/modal.css
@@ -0,0 +1,20 @@
+.modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 100;
+ background: rgba(0,0,0,0.2);
+ display: none;
+}
+.modal.visible {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+.modal > div {
+ background: #fff;
+ padding: 1rem;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.5);
+}
diff --git a/frontend/app/common/slider.component.js b/frontend/app/common/slider.component.js
new file mode 100644
index 0000000..9d96b1e
--- /dev/null
+++ b/frontend/app/common/slider.component.js
@@ -0,0 +1,120 @@
+import React, { Component } from 'react'
+import { default as throttle } from 'lodash.throttle'
+
+const SLIDER_THROTTLE_TIME = 1000 / 30
+
+export default class Slider extends Component {
+ state = {
+ value: 0
+ }
+
+ constructor(props){
+ super(props)
+ this.timeout = 0
+ this.handleInput = this.handleInput.bind(this)
+ this.handleRange = this.handleRange.bind(this)
+ this.handleKeyDown = this.handleKeyDown.bind(this)
+ this.onChange = throttle(props.onChange, SLIDER_THROTTLE_TIME)
+ }
+ componentDidMount() {
+ let { value } = this.props
+ if (this.props.type === 'int') {
+ value = parseInt(value)
+ }
+ this.setState({ value })
+ }
+ componentDidUpdate(prevProps) {
+ let { value } = this.props
+ if (prevProps.value !== value) {
+ if (this.props.type === 'int') {
+ value = parseInt(value)
+ }
+ this.setState({ value })
+ }
+ }
+ handleInput(e){
+ let { name } = this.props
+ let new_value = e.target.value
+ if (new_value === '') {
+ new_value = this.props.defaultValue || (this.props.max - this.props.min) / 2
+ }
+ else if (this.props.type === 'int') {
+ new_value = parseInt(new_value)
+ }
+ else if (this.props.type === 'odd') {
+ new_value = parseInt(Math.floor(new_value / 2) * 2 + 1)
+ }
+ else {
+ new_value = parseFloat(new_value)
+ }
+ if (this.state.value !== new_value) {
+ this.setState({ value: new_value })
+ this.props.onChange(this.props.name, new_value)
+ }
+ }
+ handleKeyDown(e) {
+ console.log(e.keyCode)
+ }
+ handleRange(e){
+ let { value: new_value } = e.target
+ if (this.props.type === 'int') {
+ new_value = parseInt(new_value)
+ }
+ else if (this.props.type === 'odd') {
+ new_value = parseInt(Math.floor(new_value / 2) * 2 + 1)
+ }
+ else if (this.props.type === 'list') {
+ new_value = this.props.options[new_value] || this.props.options[0]
+ }
+ else {
+ new_value = parseFloat(new_value)
+ }
+ this.setState({ value: new_value })
+ this.onChange(this.props.name, new_value)
+ }
+ render(){
+ let { name, title } = this.props
+ let value = this.state.value
+ if (typeof value === 'undefined') {
+ value = this.props.min
+ }
+ let text_value = value
+ let step;
+ let min = this.props.min || 0
+ let max = this.props.max || 0
+ if (this.props.type === 'int') {
+ step = 1
+ } else if (this.props.type === 'list') {
+ min = 0
+ max = this.props.options.length - 1
+ step = 1
+ value = this.props.options.indexOf(value)
+ } else {
+ step = (this.props.max - this.props.min) / 100
+ text_value = parseFloat(value).toFixed(2)
+ }
+ return (
+ <label className={this.props.error ? 'slider error' : 'slider'}>
+ <span>{title}</span>
+ <input
+ type='number'
+ min={min}
+ max={max}
+ step={step}
+ value={text_value}
+ onKeyDown={this.handleKeyDown}
+ onChange={this.handleInput}
+ onBlur={this.handleInput}
+ />
+ <input
+ type='range'
+ min={min}
+ max={max}
+ step={step}
+ value={value}
+ onChange={this.handleRange}
+ />
+ </label>
+ )
+ }
+}
diff --git a/frontend/app/common/table.component.js b/frontend/app/common/table.component.js
new file mode 100644
index 0000000..b26dcba
--- /dev/null
+++ b/frontend/app/common/table.component.js
@@ -0,0 +1,128 @@
+import React from 'react'
+
+import { formatName } from 'app/utils'
+
+const __HR__ = '__HR__'
+
+export function TableObject({ tag, object, order, summary }) {
+ if (!object) return null
+ if (object === 'loading') {
+ return <div className='tableObject loading'>{tag}{': Loading'}</div>
+ }
+ if (object.err) {
+ return <div className='tableObject error'>{tag}{' Error: '}{object.err}</div>
+ }
+ let objects = Object.keys(object)
+ if (order) {
+ const grouped = objects.reduce((a, b) => {
+ const index = order.indexOf(b)
+ if (index !== -1) {
+ a.order.push([index, b])
+ } else {
+ a.alpha.push(b)
+ }
+ return a
+ }, { order: [], alpha: [] })
+ objects = grouped.order
+ .sort((a, b) => a[0] - b[0])
+ .map(([i, s]) => s)
+ if (!summary) {
+ objects = objects
+ // .concat([__HR__])
+ .concat(grouped.alpha.sort())
+ }
+ } else {
+ objects = objects.sort()
+ }
+ return (
+ <div>
+ {tag && <h3 className='tt'>{tag}</h3>}
+ <table className={'tableObject ' + tag}>
+ <tbody>
+ {objects.map((key, i) => (
+ <TableRow key={key + '_' + i} name={key} value={object[key]} />
+ ))}
+ </tbody>
+ </table>
+ </div>
+ )
+}
+
+export function TableArray({ tag, list }) {
+ if (!list) return null
+ return (
+ <div>
+ {tag && <h3>{tag}</h3>}
+ <table className={'tableArray ' + tag}>
+ <tbody>
+ {list.map((value, i) => (
+ <tr key={tag + '_' + i}>
+ <TableCell value={value} />
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ )
+}
+
+export function TableTuples({ tag, list }) {
+ if (!list) return null
+ return (
+ <div>
+ {tag && <h3>{tag}</h3>}
+ <table className={'tableTuples ' + tag}>
+ <tbody>
+ {list.map(([key, ...values], i) => (
+ <tr key={tag + '_' + i}>
+ <th>{formatName(key)}</th>
+ {values.map((value, j) => (
+ <TableCell key={i + '_' + j} value={value} />
+ ))}
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ )
+}
+
+export function TableRow({ name, value }) {
+ if (name === __HR__) {
+ return (
+ <tr>
+ <th className='tr'>
+ <hr />
+ </th>
+ </tr>
+ )
+ }
+ return (
+ <tr>
+ <th>{formatName(name)}</th>
+ <TableCell name={name} value={value} />
+ </tr>
+ )
+}
+
+export function TableCell({ value }) {
+ if (value && typeof value === 'object') {
+ if (value._raw) {
+ value = value.value
+ } else if (value.length) {
+ value = <TableArray nested tag={''} list={value} />
+ } else {
+ value = <TableObject nested tag={''} object={value} />
+ }
+ }
+ if (typeof value === 'boolean') {
+ return <td>{value ? <Pill type='yes' /> : <Pill type='no' />}</td>
+ }
+ return (
+ <td>{value}</td>
+ )
+}
+
+export const Pill = ({ color, type }) => (
+ <div className={'pill ' + type} style={{ backgroundColor: color }}>{type}</div>
+) \ No newline at end of file
diff --git a/frontend/app/common/table.css b/frontend/app/common/table.css
new file mode 100644
index 0000000..4752e21
--- /dev/null
+++ b/frontend/app/common/table.css
@@ -0,0 +1,96 @@
+/* tables on metadata pages */
+
+h3.tt {
+ margin: 1.5rem 0 0.25rem 0;
+ font-size: 1.25rem;
+}
+table {
+ border: 0;
+ margin: 0;
+ padding: 0;
+ border-spacing: 0;
+ line-height: 1.5;
+ color: #666;
+}
+.tableObject td,
+.tableObject th {
+ padding: 0.1875rem;
+ vertical-align: top;
+}
+.tableObject hr {
+ width: 100%;
+ color: transparent;
+ border: 0;
+ border-bottom: 1px solid #bbb;
+ align: left;
+ margin: 3px 0;
+ padding: 0;
+}
+.tableObject th,
+.tableTuples th {
+ min-width: 8rem;
+ text-align: left;
+ text-transform: capitalize;
+ padding-left: 0;
+ padding-right: 0.625rem;
+ font-weight: 300;
+ color: #333;
+}
+.tableTuples td {
+ text-align: right;
+}
+.tableObject td {
+ font-weight: normal;
+ color: #000;
+}
+.tableObject .tableObject {
+ border: 0.0625rem solid #ddd;
+}
+.tableArray {
+ border: 0.0625rem solid #ddd;
+ border-spacing: 0;
+}
+.tableArray td {
+ border-bottom: 0.0625rem solid #ddd;
+}
+
+.rows .tableRow {
+ flex-wrap: wrap;
+}
+.rows .row > div.galleryRow {
+ padding: 0 0.75rem 0.75rem 0.75rem;
+}
+.rows .row > div.galleryRow > div {
+ display: flex;
+ flex-direction: row;
+ max-height: 100px;
+ flex-wrap: wrap;
+ max-width: 100%;
+ overflow: hidden;
+}
+.galleryRow .thumbnail {
+ height: 100px;
+ margin-right: 0.75rem;
+}
+/*
+.gray {
+ font-size: 12px;
+ color: #888;
+ display: block;
+}
+.sha256.heading {
+ margin: 20px 0 0px;
+}
+.gray span {
+ padding-right: 5px;
+}
+.gray {
+ margin-bottom: 10px;
+}
+.gray a {
+ color: #666;
+}
+*/
+.tableIndex {
+ width: 100%;
+} \ No newline at end of file
diff --git a/frontend/app/common/tableIndex.component.js b/frontend/app/common/tableIndex.component.js
new file mode 100644
index 0000000..c58fc35
--- /dev/null
+++ b/frontend/app/common/tableIndex.component.js
@@ -0,0 +1,129 @@
+import React, { Component } from 'react'
+import { Link } from 'react-router-dom'
+import { connect } from 'react-redux'
+
+import { formatDateTime } from 'app/utils'
+import { Loader, Swatch, Dot } from 'app/common'
+
+/*
+ <TableIndex
+ title="Collections"
+ actions={actions.collection}
+ data={data.collection.index}
+ fields={[
+ { name: 'title', type: 'title', link: row => '/collection/' + row.id + '/show/' },
+ { name: 'username', type: 'string' },
+ { name: 'date', type: 'date' },
+ { name: 'notes', type: 'text' },
+ ]}
+ />
+*/
+
+export default class TableIndex extends Component {
+ componentDidMount() {
+ this.props.actions && this.props.actions.index()
+ }
+
+ render() {
+ const { data, els, title, fields, noHeadings, notFoundMessage } = this.props
+ if (data.loading) {
+ return <Loader />
+ }
+ if (!els && (!data.lookup || !data.order.length)) {
+ return (
+ <div>
+ <h1>{title}</h1>
+ <p className='gray'>
+ {notFoundMessage || ("No " + title)}
+ </p>
+ </div>
+ )
+ }
+ return (
+ <div className='tableIndex'>
+ <h1>{title}</h1>
+ <div className='rows'>
+ {!noHeadings && <RowHeadings fields={fields} />}
+ {els
+ ? els.map(el => <Row key={el.id} row={el} fields={fields} />)
+ : data.order.map(id => <Row key={id} row={data.lookup[id]} fields={fields} />)
+ }
+ </div>
+ </div>
+ )
+ }
+}
+
+const RowHeadings = ({fields}) => {
+ return (
+ <div className='row heading'>
+ {fields.map(field => {
+ if (field.type === 'gallery') return
+ let css = {}
+ if (field.width) {
+ css = { width: field.width, maxWidth: 'none', flex: 'none', }
+ }
+ if (field.flex) {
+ css.flex = field.flex
+ }
+ return <div key={field.name} className={field.type} style={css}>{(field.title || field.name).replace(/_/g, ' ')}</div>
+ })}
+ </div>
+ )
+}
+
+const Row = ({ row, fields }) => {
+ return (
+ <div className='row tableRow'>
+ {fields.map(field => {
+ let value = field.valueFn ? field.valueFn(row) : row[field.name]
+ let css = {}
+ if (field.type === 'date' && (row.updated_at || row.created_at || value)) {
+ // value = (value || "").split('.')[0]
+ value = formatDateTime(row.updated_at || row.created_at || value)
+ } else if (field.type === 'text') {
+ value = String(value || "").trim().split('\n')[0].replace(/^#+/, '').substr(0, 100)
+ } else if (field.type === 'color') {
+ value = <Swatch color={value} />
+ } else if (field.type === 'bool') {
+ value = <Dot color={value ? '#11f' : '#fff'} />
+ } else if (field.type === 'str') {
+ value = String(value || "").replace(/_/g, ' ')
+ } else if (field.type === 'gallery') {
+ return <GalleryRow key={field.name} media={value} />
+ }
+ if (field.width) {
+ css = { width: field.width, maxWidth: 'none', flex: 'none', }
+ }
+ if (field.flex) {
+ css.flex = field.flex
+ }
+ let className
+ if (field.style) {
+ className = field.type + ' ' + field.style
+ } else {
+ className = field.type
+ }
+ value = <div title={value} key={field.name} className={className} style={css}>{value}</div>
+ if (field.link) {
+ return <Link key={field.name} to={field.link(row)}>{value}</Link>
+ }
+ return value
+ })}
+ </div>
+ )
+}
+
+const GalleryRow = ({ media }) => {
+ return (
+ <div className='galleryRow'>
+ <div>
+ {media.map(img => (
+ <Link to={"/media/id/" + img.id + "/"} key={img.url}>
+ <img src={img.url} className='thumbnail' />
+ </Link>
+ ))}
+ </div>
+ </div>
+ )
+}
diff --git a/frontend/app/common/upload.css b/frontend/app/common/upload.css
new file mode 100644
index 0000000..719f98c
--- /dev/null
+++ b/frontend/app/common/upload.css
@@ -0,0 +1,26 @@
+/* drag-and-drop */
+
+.dragCurtain {
+ display: none;
+ pointer-events: none;
+ justify-content: center;
+ align-items: center;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0,0,0,0.5);
+ z-index: 1;
+}
+.dragCurtain div {
+ color: #11f;
+ background: white;
+ border: 2px solid #11f;
+ padding: 2rem;
+ font-size: 1.5rem;
+ font-weight: bold;
+}
+.dragging .dragCurtain {
+ display: flex;
+}
diff --git a/frontend/app/common/upload.helpers.js b/frontend/app/common/upload.helpers.js
new file mode 100644
index 0000000..f26e2cc
--- /dev/null
+++ b/frontend/app/common/upload.helpers.js
@@ -0,0 +1,174 @@
+import ExifReader from 'exifreader'
+
+function base64ToUint8Array(string, start, finish) {
+ start = start || 0
+ finish = finish || string.length
+ // atob that shit
+ const binary = atob(string)
+ const buffer = new Uint8Array(binary.length)
+ for (let i = start; i < finish; i++) {
+ buffer[i] = binary.charCodeAt(i)
+ }
+ return buffer
+}
+
+function getOrientation(uri) {
+ // Split off the base64 data
+ const base64String = uri.split(',')[1]
+ // Read off first 128KB, which is all we need to
+ // get the EXIF data
+ const arr = base64ToUint8Array(base64String, 0, 2 ** 17)
+ try {
+ const tags = ExifReader.load(arr.buffer)
+ // console.log(tags)
+ if (typeof tags.Orientation == 'number') {
+ return tags.Orientation
+ }
+ return tags.Orientation.value
+ } catch (err) {
+ return 1
+ }
+}
+
+function applyRotation(canvas, ctx, deg) {
+ const radians = deg * (Math.PI / 180)
+ if (deg === 90) {
+ ctx.translate(canvas.width, 0)
+ } else if (deg === 180) {
+ ctx.translate(canvas.width, canvas.height)
+ } else if (deg === 270) {
+ ctx.translate(0, canvas.height)
+ }
+ ctx.rotate(radians)
+}
+
+/**
+ * Mapping from EXIF orientation values to data
+ * regarding the rotation and mirroring necessary to
+ * render the canvas correctly
+ * Derived from:
+ * http://www.daveperrett.com/articles/2012/07/28/exif-orientation-handling-is-a-ghetto/
+ */
+const orientationToTransform = {
+ 1: { rotation: 0, mirror: false },
+ 2: { rotation: 0, mirror: true },
+ 3: { rotation: 180, mirror: false },
+ 4: { rotation: 180, mirror: true },
+ 5: { rotation: 90, mirror: true },
+ 6: { rotation: 90, mirror: false },
+ 7: { rotation: 270, mirror: true },
+ 8: { rotation: 270, mirror: false }
+}
+
+function applyOrientationCorrection(canvas, ctx, uri) {
+ const orientation = getOrientation(uri)
+ // Only apply transform if there is some non-normal orientation
+ if (orientation && orientation !== 1) {
+ console.log(orientation)
+ const transform = orientationToTransform[orientation]
+ const { rotation } = transform
+ const flipAspect = rotation === 90 || rotation === 270
+ if (flipAspect) {
+ // Fancy schmancy swap algo
+ canvas.width = canvas.height + canvas.width
+ canvas.height = canvas.width - canvas.height
+ canvas.width -= canvas.height
+ }
+ if (rotation > 0) {
+ applyRotation(canvas, ctx, rotation)
+ }
+ }
+}
+
+function getScale(width, height, viewportWidth, viewportHeight, fillViewport) {
+ function fitHorizontal() {
+ return viewportWidth / width
+ }
+ function fitVertical() {
+ return viewportHeight / height
+ }
+ fillViewport = !!fillViewport
+ const landscape = (width / height) > (viewportWidth / viewportHeight)
+ if (landscape) {
+ if (fillViewport) {
+ return fitVertical()
+ }
+ if (width > viewportWidth) {
+ return fitHorizontal()
+ }
+ } else {
+ if (fillViewport) {
+ return fitHorizontal()
+ }
+ if (height > viewportHeight) {
+ return fitVertical()
+ }
+ }
+ return 1
+}
+
+export function renderToCanvas(img, options) {
+ if (!img) return null
+ options = options || {}
+
+ // Canvas max size for any side
+ const maxSide = options.maxSide || 0
+ const canvas = document.createElement('canvas')
+ const ctx = canvas.getContext('2d')
+ const initialScale = options.scale || 1
+ /*
+ // constrain
+ // Scale to needed to constrain canvas to max size
+ let scale = getScale(img.naturalWidth * initialScale, img.naturalHeight * initialScale, maxSide, maxSide, true)
+ // console.log(scale)
+ // Still need to apply the user defined scale
+ scale *= initialScale
+ canvas.width = Math.round(img.naturalWidth * scale)
+ canvas.height = Math.round(img.naturalHeight * scale)
+ */
+ const { naturalWidth, naturalHeight } = img
+ if (maxSide > 0) {
+ if (naturalWidth > naturalHeight) {
+ canvas.width = Math.min(maxSide, naturalWidth)
+ canvas.height = naturalHeight * canvas.width / naturalWidth
+ } else {
+ canvas.height = Math.min(maxSide, naturalHeight)
+ canvas.width = naturalWidth * canvas.height / naturalHeight
+ }
+ } else {
+ canvas.width = naturalWidth
+ canvas.height = naturalHeight
+ }
+ const { correctOrientation } = options
+ const jpeg = !!img.src.match(/data:image\/jpeg|\.jpeg$|\.jpg$/i)
+ const hasDataURI = !!img.src.match(/^data:/)
+
+ ctx.save()
+
+ // Can only correct orientation on JPEGs represented as dataURIs
+ // for the time being
+ if (correctOrientation && jpeg && hasDataURI) {
+ applyOrientationCorrection(canvas, ctx, img.src)
+ }
+ // Resize image if too large
+ // if (scale !== 1) {
+ // ctx.scale(scale, scale)
+ // }
+
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
+ ctx.restore()
+
+ return canvas
+}
+
+export function renderThumbnail(img) {
+ const resized = renderToCanvas(img, { correctOrientation: true })
+ // const canvas = document.createElement('canvas') // document.querySelector('#user_photo_canvas')
+ // const ctx = canvas.getContext('2d')
+ // ctx.fillStyle = 'black'
+ // ctx.fillRect(0, 0, MAX_SIDE, MAX_SIDE)
+ // const xOffset = (MAX_SIDE - resized.width) / 2
+ // const yOffset = (MAX_SIDE - resized.height) / 2
+ // ctx.drawImage(resized, xOffset, yOffset, resized.width, resized.height)
+ return resized
+}
diff --git a/frontend/app/common/uploadImage.component.js b/frontend/app/common/uploadImage.component.js
new file mode 100644
index 0000000..3ae41c8
--- /dev/null
+++ b/frontend/app/common/uploadImage.component.js
@@ -0,0 +1,74 @@
+import React, { Component } from 'react'
+
+import { renderThumbnail } from './upload.helpers'
+
+export default class UploadImageComponent extends Component {
+ constructor(props) {
+ super(props)
+ document.body.addEventListener("dragover", this.dragOver.bind(this))
+ document.body.addEventListener("dragleave", this.dragLeave.bind(this))
+ document.body.addEventListener("drop", this.upload.bind(this))
+ }
+
+ dragOver(e) {
+ e.stopPropagation()
+ e.preventDefault()
+ document.body.className = 'dragging'
+ }
+
+ dragLeave(e) {
+ e.stopPropagation()
+ e.preventDefault()
+ document.body.className = ''
+ }
+
+ upload(e) {
+ e.preventDefault()
+ document.body.className = ''
+ const files = e.dataTransfer ? e.dataTransfer.files : e.target.files
+ let i
+ let file
+ for (i = 0; i < files.length; i++) {
+ file = files[i]
+ if (file && file.type.match('image.*')) break
+ }
+ if (!file) {
+ console.log('No file specified')
+ return
+ }
+ const fr = new FileReader()
+ fr.onload = fileReaderEvent => {
+ fr.onload = null
+ const img = new Image()
+ img.onload = () => {
+ img.onload = null
+ this.resizeAndUpload(file, img)
+ }
+ img.src = fileReaderEvent.target.result
+ }
+ fr.readAsDataURL(file)
+ }
+
+ resizeAndUpload(file, img) {
+ const canvas = renderThumbnail(img, this.props)
+ canvas.toBlob(blob => {
+ this.props.onUpload({ file, img, canvas, blob, freshen: true })
+ }, 'image/jpeg', this.props.quality || 80)
+ }
+
+ render() {
+ return (
+ <div className='uploadButton'>
+ <input
+ type="file"
+ accept="image/*"
+ onChange={this.upload.bind(this)}
+ required
+ />
+ <div className='dragCurtain'>
+ <div className='dragLabel'>Drop image here</div>
+ </div>
+ </div>
+ )
+ }
+}