diff options
Diffstat (limited to 'animism-align/frontend/common')
21 files changed, 2287 insertions, 0 deletions
diff --git a/animism-align/frontend/common/app.css b/animism-align/frontend/common/app.css new file mode 100644 index 0000000..d9f9946 --- /dev/null +++ b/animism-align/frontend/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/animism-align/frontend/common/copyToClipboardButton.component.js b/animism-align/frontend/common/copyToClipboardButton.component.js new file mode 100644 index 0000000..cfe7103 --- /dev/null +++ b/animism-align/frontend/common/copyToClipboardButton.component.js @@ -0,0 +1,24 @@ +import React, { Component } from 'react'; +import { writeToClipboard } from '../util' + +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/animism-align/frontend/common/fonts.css b/animism-align/frontend/common/fonts.css new file mode 100644 index 0000000..c782885 --- /dev/null +++ b/animism-align/frontend/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/animism-align/frontend/common/form.component.js b/animism-align/frontend/common/form.component.js new file mode 100644 index 0000000..f3775a2 --- /dev/null +++ b/animism-align/frontend/common/form.component.js @@ -0,0 +1,220 @@ +import React, { Component } from 'react'; +import { courtesyS } from '../util' + +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/animism-align/frontend/common/form.css b/animism-align/frontend/common/form.css new file mode 100644 index 0000000..dbfa01f --- /dev/null +++ b/animism-align/frontend/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/animism-align/frontend/common/header.component.js b/animism-align/frontend/common/header.component.js new file mode 100644 index 0000000..9e96e80 --- /dev/null +++ b/animism-align/frontend/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 '../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/animism-align/frontend/common/imageCrop.component.js b/animism-align/frontend/common/imageCrop.component.js new file mode 100644 index 0000000..9cae850 --- /dev/null +++ b/animism-align/frontend/common/imageCrop.component.js @@ -0,0 +1,41 @@ +import React, { Component } from 'react'; +import { cropImage } from '../util' + +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/animism-align/frontend/common/index.js b/animism-align/frontend/common/index.js new file mode 100644 index 0000000..3647203 --- /dev/null +++ b/animism-align/frontend/common/index.js @@ -0,0 +1,31 @@ +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 { + Loader, Swatch, Dot, Columns, Statistic, Detections, Progress +} from './miscellaneous.component' +export { default as TableIndex } from './tableIndex.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/animism-align/frontend/common/loader.css b/animism-align/frontend/common/loader.css new file mode 100644 index 0000000..f047e8e --- /dev/null +++ b/animism-align/frontend/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/animism-align/frontend/common/menubutton.component.js b/animism-align/frontend/common/menubutton.component.js new file mode 100644 index 0000000..d4c2e31 --- /dev/null +++ b/animism-align/frontend/common/menubutton.component.js @@ -0,0 +1,128 @@ +import React, { Component } from 'react' +import { Route, Link } from 'react-router-dom' +import { history } from '../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/animism-align/frontend/common/miscellaneous.component.js b/animism-align/frontend/common/miscellaneous.component.js new file mode 100644 index 0000000..4eb23f1 --- /dev/null +++ b/animism-align/frontend/common/miscellaneous.component.js @@ -0,0 +1,82 @@ +import React, { Component } from 'react'; +import { Link } from 'react-router-dom' +import { clamp, percent } from '../util' + +export 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 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/animism-align/frontend/common/miscellaneous.css b/animism-align/frontend/common/miscellaneous.css new file mode 100644 index 0000000..32c3e7b --- /dev/null +++ b/animism-align/frontend/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/animism-align/frontend/common/modal.component.js b/animism-align/frontend/common/modal.component.js new file mode 100644 index 0000000..75c479c --- /dev/null +++ b/animism-align/frontend/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/animism-align/frontend/common/modal.css b/animism-align/frontend/common/modal.css new file mode 100644 index 0000000..5e95a09 --- /dev/null +++ b/animism-align/frontend/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/animism-align/frontend/common/slider.component.js b/animism-align/frontend/common/slider.component.js new file mode 100644 index 0000000..7e42b4d --- /dev/null +++ b/animism-align/frontend/common/slider.component.js @@ -0,0 +1,115 @@ +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.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(new_value) + } + } + 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} + onChange={this.handleInput} + onBlur={this.handleInput} + /> + <input + type='range' + min={min} + max={max} + step={step} + value={value} + onChange={this.handleRange} + /> + </label> + ) + } +} diff --git a/animism-align/frontend/common/table.component.js b/animism-align/frontend/common/table.component.js new file mode 100644 index 0000000..8a74a79 --- /dev/null +++ b/animism-align/frontend/common/table.component.js @@ -0,0 +1,128 @@ +import React from 'react' + +import { formatName } from '../util' + +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/animism-align/frontend/common/table.css b/animism-align/frontend/common/table.css new file mode 100644 index 0000000..4752e21 --- /dev/null +++ b/animism-align/frontend/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/animism-align/frontend/common/tableIndex.component.js b/animism-align/frontend/common/tableIndex.component.js new file mode 100644 index 0000000..a34a9e9 --- /dev/null +++ b/animism-align/frontend/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 '../util' +import { Loader, Swatch, Dot } from '../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/animism-align/frontend/common/upload.css b/animism-align/frontend/common/upload.css new file mode 100644 index 0000000..719f98c --- /dev/null +++ b/animism-align/frontend/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/animism-align/frontend/common/upload.helpers.js b/animism-align/frontend/common/upload.helpers.js new file mode 100644 index 0000000..f26e2cc --- /dev/null +++ b/animism-align/frontend/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/animism-align/frontend/common/uploadImage.component.js b/animism-align/frontend/common/uploadImage.component.js new file mode 100644 index 0000000..3ae41c8 --- /dev/null +++ b/animism-align/frontend/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> + ) + } +} |
