diff options
Diffstat (limited to 'animism-align/frontend/app')
97 files changed, 7537 insertions, 0 deletions
diff --git a/animism-align/frontend/app/actions.js b/animism-align/frontend/app/actions.js new file mode 100644 index 0000000..8fa780e --- /dev/null +++ b/animism-align/frontend/app/actions.js @@ -0,0 +1,30 @@ +import { bindActionCreators } from 'redux' +// import { actions as crudActions } from 'app/api' +import { crud_actions } from 'app/api/crud.actions' + +import * as audioActions from 'app/views/audio/audio.actions' +import * as alignActions from 'app/views/align/align.actions' +import * as siteActions from 'app/views/site/site.actions' + +import { store } from 'app/store' + +const crudActions = [ + 'paragraph', + 'annotation', + 'upload', + 'media', +].reduce((a,b) => (a[b] = crud_actions(b)) && a, {}) + +export default + Object.keys(crudActions) + .map(a => [a, crudActions[a]]) + .concat([ + ['site', siteActions], + ['align', alignActions], + ['audio', audioActions], + ]) + .map(p => [p[0], bindActionCreators(p[1], store.dispatch)]) + .concat([ + // ['socket', socketActions], + ]) + .reduce((a,b) => (a[b[0]] = b[1])&&a,{}) diff --git a/animism-align/frontend/app/api/crud.actions.js b/animism-align/frontend/app/api/crud.actions.js new file mode 100644 index 0000000..86c2948 --- /dev/null +++ b/animism-align/frontend/app/api/crud.actions.js @@ -0,0 +1,51 @@ +import { crud_fetch } from 'app/api/crud.fetch' +import { as_type } from 'app/api/crud.types' +import { upload_action } from 'app/api/crud.upload' +import { store } from 'app/store' + +export function crud_actions(type) { + const fetch_type = crud_fetch(type) + return [ + 'index', + 'show', + 'create', + 'update', + 'destroy', + ].reduce((lookup, param) => { + lookup[param] = crud_action(type, param, (q) => fetch_type[param](q)) + return lookup + }, { + action: (method, fn) => crud_action(type, method, fn), + upload: (fd) => upload_action(type, fd), + updateOption: (key, value) => dispatch => { + dispatch({ type: as_type(type, 'update_option'), key, value }) + }, + updateOptions: opt => dispatch => { + dispatch({ type: as_type(type, 'update_options'), opt }) + }, + }) +} + +export const crud_action = (type, method, fn) => (q, load_more) => dispatch => { + return new Promise ((resolve, reject) => { + if (method === 'index') { + if (store.getState()[type].index.loading) { + return resolve({}) + } + } + dispatch({ type: as_type(type, method + '_loading'), load_more }) + fn(q).then(data => { + if (data.status === 'ok') { + dispatch({ type: as_type(type, method), data, load_more }) + resolve(data) + } else { + dispatch({ type: as_type(type, method + '_error'), error: data.error }) + reject(data) + } + }).catch(e => { + console.log(e) + dispatch({ type: as_type(type, method + '_error') }) + reject(e) + }) + }) +} diff --git a/animism-align/frontend/app/api/crud.fetch.js b/animism-align/frontend/app/api/crud.fetch.js new file mode 100644 index 0000000..c88225e --- /dev/null +++ b/animism-align/frontend/app/api/crud.fetch.js @@ -0,0 +1,105 @@ +import fetch from 'node-fetch' + +export function crud_fetch(type, tag) { + const uri = '/api/v1/' + type + '/' + (tag || '') + return { + index: q => { + return fetch(_get_url(uri, q), _get_headers()) + .then(req => req.json()) + .catch(error) + }, + + show: id => { + let url; + if (typeof id === 'object') { + url = _get_url(uri + id[0] + '/', id[1]) + } else { + url = _get_url(uri + id + '/') + } + return fetch(url, _get_headers()) + .then(req => req.json()) + .catch(error) + }, + + create: data => { + return fetch(uri, post(data)) + .then(req => req.json()) + .catch(error) + }, + + update: data => { + return fetch(uri + data.id + '/', put(data)) + .then(req => req.json()) + .catch(error) + }, + + destroy: data => { + return fetch(uri + data.id + '/', destroy(data)) + .then(req => req.json()) + .catch(error) + }, + } +} + +function _get_url(_url, data) { + const url = new URL(window.location.origin + _url) + if (data) { + Object.keys(data).forEach(key => url.searchParams.append(key, data[key])) + } + return url +} +export function _get_headers() { + return { + method: 'GET', + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + }, + } +} +export function post(data) { + return { + method: 'POST', + body: JSON.stringify(data), + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + } +} +export function postBody(data) { + return { + method: 'POST', + body: data, + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + }, + } +} +export function put(data) { + return { + method: 'PUT', + body: JSON.stringify(data), + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + } +} +export function destroy(data) { + return { + method: 'DELETE', + body: JSON.stringify(data), + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + } +} +function error(err) { + console.warn(err) +} diff --git a/animism-align/frontend/app/api/crud.reducer.js b/animism-align/frontend/app/api/crud.reducer.js new file mode 100644 index 0000000..3ee4974 --- /dev/null +++ b/animism-align/frontend/app/api/crud.reducer.js @@ -0,0 +1,194 @@ +import * as types from 'app/types' +import { getOrderedIds, getOrderedIdsFromLookup } from 'app/utils' +import { session } from 'app/session' + +export const crudState = (type, options) => ({ + index: {}, + show: {}, + create: {}, + update: {}, + destroy: {}, + lookup: [], + ...options, +}) + +export const crudReducer = (type) => { + const crud_type = types[type] + return (state, action) => { + switch (action.type) { + // index + case crud_type.index_loading: + return { + ...state, + index: action.load_more + ? { ...state.index, loading: true } + : { loading: true }, + } + case crud_type.index: + if (action.data.res.length) { + return { + ...state, + index: { + lookup: action.data.res.reduce((a, b) => { a[b.id] = b; return a }, action.load_more ? state.index.lookup : {}), + order: getOrderedIds(action.data.res, state.options.sort, action.load_more ? state.index.order : []), + }, + } + } else { + Object.keys(action.data.res).forEach(key => { + const el = action.data.res[key] + el.key = key + el.id = el.id || key + }) + return { + ...state, + index: { + lookup: action.data.res, + order: getOrderedIdsFromLookup(action.data.res, state.options.sort), + }, + } + } + case crud_type.index_error: + return { + ...state, + index: { loading: false, error: true }, + } + case crud_type.index_sort: + return { + ...state, + index: { + ...state.index, + order: action.data.res.map(b => b.id), + }, + } + + // show + case crud_type.show_loading: + return { + ...state, + show: { loading: true }, + } + case crud_type.show: + if (!action.data) { + return { + ...state, + show: { not_found: true }, + } + } + return { + ...state, + show: action.data, + } + case crud_type.show_error: + return { + ...state, + show: { loading: false, error: true }, + } + + // + // create + case crud_type.create_loading: + return { + ...state, + create: { loading: true }, + } + case crud_type.create: + return { + ...state, + create: action.data, + index: addToIndex(state.index, action.data.res, state.options.sort), + } + case crud_type.create_error: + return { + ...state, + create: action.data, + } + + // + // update + case crud_type.update_loading: + return { + ...state, + update: { loading: true }, + } + case crud_type.update: + return { + ...state, + update: action.data, + index: addToIndex(state.index, action.data.res, state.options.sort), + show: (state.show.res && state.show.res.id === action.data.res.id) ? { + ...state.show.res, + ...action.data.res, + } : state.show + } + case crud_type.index_error: + return { + ...state, + update: { loading: false, error: true }, + } + + // + // destroy + case crud_type.destroy_loading: + return { + ...state, + destroy: { loading: true }, + } + case crud_type.destroy: + return { + ...state, + index: { + ...(() => { + if (!state.index.lookup) { + return {} + } + delete state.index.lookup[action.data.id] + const _id = parseInt(action.data.id) + state.index.order = state.index.order.filter(id => id !== _id) + return { ...state.index } + })() + }, + destroy: { loading: false }, + } + case crud_type.destroy_error: + return { + ...state, + destroy: { error: true }, + } + + // + // options + case crud_type.update_option: + session.set(type + "." + action.key, action.value) + return { + ...state, + options: { + ...state.options, + [action.key]: action.value, + } + } + + case crud_type.update_options: + session.setAll( + Object.keys(action.opt).reduce((a,b) => { a[type + '.' + b] = action.opt[b]; return a }, {}) + ) + return { + ...state, + options: { + ...action.opt, + } + } + + default: + return state + } + } +} + +const addToIndex = (index, data, sort) => { + const lookup = (index && index.lookup) ? { + ...index.lookup, + } : {} + lookup[data.id] = data + const order = getOrderedIdsFromLookup(lookup, sort) + return { lookup, order } +} diff --git a/animism-align/frontend/app/api/crud.types.js b/animism-align/frontend/app/api/crud.types.js new file mode 100644 index 0000000..ac9b3f3 --- /dev/null +++ b/animism-align/frontend/app/api/crud.types.js @@ -0,0 +1,35 @@ +export const as_type = (a, b) => [a, b].join('_').toUpperCase() + +export const with_type = (type, actions) => + actions.reduce((a, b) => (a[b] = as_type(type, b)) && a, {}) + +export const crud_type = (type, actions=[]) => + with_type(type, actions.concat([ + 'index_loading', + 'index', + 'index_error', + 'index_sort', + 'show_loading', + 'show', + 'show_error', + 'create_loading', + 'create', + 'create_error', + 'update_loading', + 'update', + 'update_error', + 'destroy_loading', + 'destroy', + 'destroy_error', + 'upload_loading', + 'upload_progress', + 'upload_waiting', + 'upload_complete', + 'upload_error', + 'sort', + 'update_option', + 'update_options', + 'loading', + 'loaded', + 'error', + ])) diff --git a/animism-align/frontend/app/api/crud.upload.js b/animism-align/frontend/app/api/crud.upload.js new file mode 100644 index 0000000..b4d9d4e --- /dev/null +++ b/animism-align/frontend/app/api/crud.upload.js @@ -0,0 +1,115 @@ +import { as_type } from 'app/api/crud.types' + +export function crud_upload(type, data, dispatch) { + return new Promise( (resolve, reject) => { + // console.log(type, data) + const { id } = data + + const fd = new FormData() + if (!data.tag) { + data.tag = 'misc' + } + + Object.keys(data).forEach(key => { + if (key.indexOf('__') !== -1) return + if (key === 'id') return + const fn_key = '__' + key + '_filename' + if (fn_key in data) { + fd.append(key, data[key], data[fn_key]) + } else { + fd.append(key, data[key]) + } + }) + + let url = id ? '/api/v1/' + type + '/' + id + '/' + : '/api/v1/' + type + '/' + // console.log(url) + + const xhr = new XMLHttpRequest() + xhr.upload.addEventListener("progress", uploadProgress, false) + xhr.addEventListener("load", uploadComplete, false) + xhr.addEventListener("error", uploadFailed, false) + xhr.addEventListener("abort", uploadCancelled, false) + xhr.open("POST", url) + xhr.send(fd) + + dispatch && dispatch({ type: as_type(type, 'upload_loading')}) + + let complete = false + + function uploadProgress (e) { + if (e.lengthComputable) { + const percent = Math.round(e.loaded * 100 / e.total) || 0 + if (percent > 99) { + dispatch && dispatch({ + type: as_type(type, 'upload_waiting'), + percent, + [type]: id, + }) + } else { + dispatch && dispatch({ + type: as_type(type, 'upload_progress'), + percent, + [type]: id, + }) + } + } + else { + dispatch && dispatch({ + type: as_type(type, 'upload_error'), + error: 'unable to compute upload progress', + [type]: id, + }) + } + } + + function uploadComplete (e) { + let parsed; + try { + parsed = JSON.parse(e.target.responseText) + } catch (e) { + dispatch && dispatch({ + type: as_type(type, 'upload_error'), + error: 'upload failed', + [type]: id, + }) + reject(e) + return + } + dispatch && dispatch({ + type: as_type(type, 'upload_complete'), + data: parsed, + [type]: id, + }) + if (parsed.res) { + (parsed.res.length ? parsed.res : [parsed.res]).forEach(file => { + dispatch && dispatch({ + type: as_type('upload', 'create'), + data: { res: file }, + }) + }) + } + resolve(parsed) + } + + function uploadFailed (evt) { + dispatch && dispatch({ + type: as_type(type, 'upload_error'), + error: 'upload failed', + [type]: id, + }) + reject(evt) + } + + function uploadCancelled (evt) { + dispatch && dispatch({ + type: as_type(type, 'upload_error'), + error: 'upload cancelled', + [type]: id, + }) + reject(evt) + } + }) +} + +export const upload_action = (type, data) => dispatch => crud_upload(type, data, dispatch) diff --git a/animism-align/frontend/app/api/index.js b/animism-align/frontend/app/api/index.js new file mode 100644 index 0000000..f4be118 --- /dev/null +++ b/animism-align/frontend/app/api/index.js @@ -0,0 +1,17 @@ +// import { crud_actions } from 'api/crud.actions' +// import * as utils from 'app/utils' + +/* +for our crud events, create corresponding actions +the actions fire a 'loading' event, call the underlying api method, and then resolve. +so you can do ... + import { folderActions } from '../../api' + folderActions.index({ module: 'samplernn' }) + folderActions.show(12) + folderActions.create({ module: 'samplernn', name: 'foo' }) + folderActions.update(12, { module: 'pix2pix' }) + folderActions.destroy(12, { confirm: true }) + folderActions.upload(12, form_data) +*/ + +// export { utils } diff --git a/animism-align/frontend/app/common/app.css b/animism-align/frontend/app/common/app.css new file mode 100644 index 0000000..362b933 --- /dev/null +++ b/animism-align/frontend/app/common/app.css @@ -0,0 +1,354 @@ +* { 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: row; + 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 { + background: #324; + padding: 0.5rem; + justify-content: flex-end; + margin-bottom: 1rem; +} +.menubar > :first-child { + flex: 1; +} + +/* lists */ + +ul { + margin: 0.75rem 0; +} +li { + line-height: 1.5; +} + +/* 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: 3.5rem; + min-height: 18rem; + padding: 0 1.0rem 0 1.0rem; +} +.menuButton { + position: relative; + text-align: center; + text-transform: uppercase; + font-size: 0.625rem; + color: #ddd; + 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: #101; + 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: #000; + border-color: #fff; +} +.menuButton:hover svg { + fill: #fff; +} + +.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/app/common/copyToClipboardButton.component.js b/animism-align/frontend/app/common/copyToClipboardButton.component.js new file mode 100644 index 0000000..0defba5 --- /dev/null +++ b/animism-align/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/animism-align/frontend/app/common/fonts.css b/animism-align/frontend/app/common/fonts.css new file mode 100644 index 0000000..c782885 --- /dev/null +++ b/animism-align/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/animism-align/frontend/app/common/form.component.js b/animism-align/frontend/app/common/form.component.js new file mode 100644 index 0000000..c727544 --- /dev/null +++ b/animism-align/frontend/app/common/form.component.js @@ -0,0 +1,222 @@ +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} + onBlur={props.onBlur} + name={props.name} + value={props.data[props.name] || ""} + placeholder={props.placeholder} + autoComplete={props.autoComplete} + /> + </label> +) + +export const LabelDescription = props => ( + <label className={props.className ? 'text description ' + props.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} + placeholder={props.placeholder} + 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 => String(opt.name) === String(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 || (multiple ? "Choose files" : "Choose file")} + <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/app/common/form.css b/animism-align/frontend/app/common/form.css new file mode 100644 index 0000000..bbee27e --- /dev/null +++ b/animism-align/frontend/app/common/form.css @@ -0,0 +1,327 @@ +/* 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; + line-height: 1.3; + 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: #ddd; +} +input[type=checkbox]:hover + span { + color: #fff; +} +input[type=checkbox]:focus + span { + color: #fff; +} +input[type="checkbox"]:checked + span { + color: #fff; +} +input[type="checkbox"]:focus:checked + span { + color: #fff; +} + +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; +} +.buttons button:last-child { + margin-right: 0; +} +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/app/common/imageCrop.component.js b/animism-align/frontend/app/common/imageCrop.component.js new file mode 100644 index 0000000..f139dce --- /dev/null +++ b/animism-align/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/animism-align/frontend/app/common/index.js b/animism-align/frontend/app/common/index.js new file mode 100644 index 0000000..1f0a61d --- /dev/null +++ b/animism-align/frontend/app/common/index.js @@ -0,0 +1,31 @@ +// export { default as Header } from 'app/common/header.component' +export { + MenuButton, SmallMenuButton, MenuRoute, +} from 'app/common/menubutton.component' +export { + Select, Checkbox, Radio, FileInput, FileInputField, + TextInput, NumberInput, TextArea, SubmitButton, + LabelDescription, ColorInput, +} from 'app/common/form.component' +export { + Loader, Swatch, Dot, Columns, Statistic, Detections, Progress +} from 'app/common/miscellaneous.component' +export { default as TableIndex } from 'app/common/tableIndex.component' +export { + TableObject, TableArray, TableTuples, + TableRow, TableCell +} from 'app/common/table.component' +export { default as CopyToClipboardButton } from 'app/common/copyToClipboardButton.component' +export { default as ImageCrop } from 'app/common/imageCrop.component' +export { Modal } from 'app/common/modal.component' +export { default as UploadImage } from 'app/common/uploadImage.component' +export { default as Slider } from 'app/common/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/app/common/loader.css b/animism-align/frontend/app/common/loader.css new file mode 100644 index 0000000..f047e8e --- /dev/null +++ b/animism-align/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/animism-align/frontend/app/common/menubutton.component.js b/animism-align/frontend/app/common/menubutton.component.js new file mode 100644 index 0000000..ce26d74 --- /dev/null +++ b/animism-align/frontend/app/common/menubutton.component.js @@ -0,0 +1,133 @@ +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> + }, + copy: { + title: 'Copy', + image: '/static/img/copy.svg', + svg: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></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/app/common/miscellaneous.component.js b/animism-align/frontend/app/common/miscellaneous.component.js new file mode 100644 index 0000000..8021c5d --- /dev/null +++ b/animism-align/frontend/app/common/miscellaneous.component.js @@ -0,0 +1,82 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' +import { clamp, percent } from 'app/utils' + +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/app/common/miscellaneous.css b/animism-align/frontend/app/common/miscellaneous.css new file mode 100644 index 0000000..32c3e7b --- /dev/null +++ b/animism-align/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/animism-align/frontend/app/common/modal.component.js b/animism-align/frontend/app/common/modal.component.js new file mode 100644 index 0000000..75c479c --- /dev/null +++ b/animism-align/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/animism-align/frontend/app/common/modal.css b/animism-align/frontend/app/common/modal.css new file mode 100644 index 0000000..5e95a09 --- /dev/null +++ b/animism-align/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/animism-align/frontend/app/common/slider.component.js b/animism-align/frontend/app/common/slider.component.js new file mode 100644 index 0000000..7e42b4d --- /dev/null +++ b/animism-align/frontend/app/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/app/common/table.component.js b/animism-align/frontend/app/common/table.component.js new file mode 100644 index 0000000..b26dcba --- /dev/null +++ b/animism-align/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/animism-align/frontend/app/common/table.css b/animism-align/frontend/app/common/table.css new file mode 100644 index 0000000..4752e21 --- /dev/null +++ b/animism-align/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/animism-align/frontend/app/common/tableIndex.component.js b/animism-align/frontend/app/common/tableIndex.component.js new file mode 100644 index 0000000..c58fc35 --- /dev/null +++ b/animism-align/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/animism-align/frontend/app/common/upload.css b/animism-align/frontend/app/common/upload.css new file mode 100644 index 0000000..719f98c --- /dev/null +++ b/animism-align/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/animism-align/frontend/app/common/upload.helpers.js b/animism-align/frontend/app/common/upload.helpers.js new file mode 100644 index 0000000..60d5b82 --- /dev/null +++ b/animism-align/frontend/app/common/upload.helpers.js @@ -0,0 +1,192 @@ +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 +} + +function getImageProperties(img) { + // img is an image + if ('naturalWidth' in img) { + const { naturalWidth, naturalHeight } = img + const jpeg = !!img.src.match(/data:image\/jpeg|\.jpeg$|\.jpg$/i) + const hasDataURI = !!img.src.match(/^data:/) + return { naturalWidth, naturalHeight, jpeg, hasDataURI } + } + // img is a canvas + return { + naturalWidth: img.width, + naturalHeight: img.height, + jpeg: false, + hasDataURI: false, + } +} + +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, jpeg, hasDataURI } = getImageProperties(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 + + 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, options) { + const resized = renderToCanvas(img, { + correctOrientation: true, + ...options, + }) + // 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/app/common/uploadImage.component.js b/animism-align/frontend/app/common/uploadImage.component.js new file mode 100644 index 0000000..8048c8b --- /dev/null +++ b/animism-align/frontend/app/common/uploadImage.component.js @@ -0,0 +1,74 @@ +import React, { Component } from 'react' + +import { renderThumbnail } from 'app/common/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={this.props.required} + /> + <div className='dragCurtain'> + <div className='dragLabel'>Drop image here</div> + </div> + </div> + ) + } +} diff --git a/animism-align/frontend/app/constants.js b/animism-align/frontend/app/constants.js new file mode 100644 index 0000000..cf504d3 --- /dev/null +++ b/animism-align/frontend/app/constants.js @@ -0,0 +1,34 @@ +export const WAVEFORM_SIZE = 300 + +export const ZOOM_STEPS = [ + 1, + 2, + 3, + 10, + 20, + 30, + 60, +] + +export const ZOOM_LABEL_STEPS = [ + 20, + 60, + 60, + 300, + 600, + 600, + 1200, +] + +export const ZOOM_TICK_STEPS = [ + 5, + 10, + 30, + 60, + 60, + 60, + 600, +] + +export const HEADER_MARGIN = 50 +export const INNER_HEIGHT = window.innerHeight - HEADER_MARGIN diff --git a/animism-align/frontend/app/router.js b/animism-align/frontend/app/router.js new file mode 100644 index 0000000..bd87cbe --- /dev/null +++ b/animism-align/frontend/app/router.js @@ -0,0 +1,42 @@ +import React, { Component } from 'react' +import { ConnectedRouter } from 'connected-react-router' +import { Route } from 'react-router' + +import actions from 'app/actions' + +import Header from 'app/views/nav/header.component' +import * as views from 'app/views' + +const viewList = Object.keys(views).map(name => { + const view = views[name] + let path, exact = false + path = '/' + name + return ( + <Route key={name} path={path} component={view} exact={exact} /> + ) +}) + +export default class Router extends Component { + componentDidMount() { + actions.site.loadText() + actions.site.loadPeaks() + actions.annotation.index() + actions.paragraph.index() + actions.media.index() + } + render() { + return ( + <ConnectedRouter history={this.props.history}> + <div className='app'> + <Header /> + {viewList} + <Route exact key='root' path='/' render={() => { + // redirect to index!! + setTimeout(() => this.props.history.push('/align'), 10) + return null + }} /> + </div> + </ConnectedRouter> + ) + } +} diff --git a/animism-align/frontend/app/session.js b/animism-align/frontend/app/session.js new file mode 100644 index 0000000..ff40fb3 --- /dev/null +++ b/animism-align/frontend/app/session.js @@ -0,0 +1,19 @@ +import Storage from 'store2' + +export const session = Storage.namespace('animism.search') + +export const getDefault = (key, def) => { + const val = session.get(key); + return (val === null) ? def : val +} +export const getDefaultInt = (key, def) => { + return parseInt(getDefault(key, def), 10) +} +export const getDefaultFloat = (key, def) => { + return parseFloat(getDefault(key, def), 10) +} + +const username = session.get('username') +if (!username) { + session.set('username', 'anonymous') +} diff --git a/animism-align/frontend/app/store.js b/animism-align/frontend/app/store.js new file mode 100644 index 0000000..e779ce4 --- /dev/null +++ b/animism-align/frontend/app/store.js @@ -0,0 +1,52 @@ +import { applyMiddleware, compose, combineReducers, createStore } from 'redux' +import { connectRouter, routerMiddleware } from 'connected-react-router' +import { createBrowserHistory } from 'history' +// import createDebounce from 'redux-debounced' +import thunk from 'redux-thunk' +// import { login } from 'app/utils' + +import uploadReducer from 'app/views/upload/upload.reducer' +import alignReducer from 'app/views/align/align.reducer' +import audioReducer from 'app/views/audio/audio.reducer' +import paragraphReducer from 'app/views/paragraph/paragraph.reducer' +import annotationReducer from 'app/views/annotation/annotation.reducer' +import siteReducer from 'app/views/site/site.reducer' +import mediaReducer from 'app/views/media/media.reducer' + +const createRootReducer = history => ( + combineReducers({ + auth: (state = {}) => state, + router: connectRouter(history), + site: siteReducer, + upload: uploadReducer, + align: alignReducer, + audio: audioReducer, + paragraph: paragraphReducer, + annotation: annotationReducer, + media: mediaReducer, + }) +) + +const configureStore = (initialState = {}, history) => { + const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose + + const store = createStore( + createRootReducer(history), + initialState, + composeEnhancers( + applyMiddleware( + thunk, + // createDebounce(), + routerMiddleware(history) + ), + ), + ) + + return store +} + +const history = createBrowserHistory() +const store = configureStore({}, history) +const { dispatch } = store + +export { store, history, dispatch } diff --git a/animism-align/frontend/app/types.js b/animism-align/frontend/app/types.js new file mode 100644 index 0000000..7fb368c --- /dev/null +++ b/animism-align/frontend/app/types.js @@ -0,0 +1,31 @@ +import { with_type, crud_type } from 'app/api/crud.types' + +export const api = crud_type('api', []) + +export const upload = crud_type('upload', []) +export const media = crud_type('media', []) +export const peaks = crud_type('peaks', []) +export const text = crud_type('text', []) +export const annotation = crud_type('annotation', []) +export const paragraph = crud_type('paragraph', []) +export const vimeo = crud_type('vimeo', []) +export const align = crud_type('align', [ + 'set_display_setting', + 'set_temporary_annotation', + 'update_temporary_annotation', + 'update_temporary_annotation_settings', + 'set_selected_annotation', + 'clear_selected_annotation', +]) + +export const audio = with_type('audio', [ + 'play', 'pause', 'update_time', +]) + +export const site = with_type('site', [ +]) + +export const system = with_type('system', [ +]) + +export const init = '@@INIT' diff --git a/animism-align/frontend/app/utils/index.js b/animism-align/frontend/app/utils/index.js new file mode 100644 index 0000000..c2dd464 --- /dev/null +++ b/animism-align/frontend/app/utils/index.js @@ -0,0 +1,371 @@ +import { api as api_type } from 'app/types' + +// import { format, formatDistance } from 'date-fns' +import format from 'date-fns/format' +import formatDistance from 'date-fns/formatDistance' + +export const formatDateTime = dateStr => format(new Date(dateStr), 'd MMM yyyy H:mm') +export const formatDate = dateStr => format(new Date(dateStr), 'd MMM yyyy') +export const formatTime = dateStr => format(new Date(dateStr), 'H:mm') +export const formatAge = dateStr => formatDistance(new Date(), new Date(dateStr)) + ' ago.' + +/* Mobile check */ + +export const isiPhone = !!((navigator.userAgent.match(/iPhone/i)) || (navigator.userAgent.match(/iPod/i))) +export const isiPad = !!(navigator.userAgent.match(/iPad/i)) +export const isAndroid = !!(navigator.userAgent.match(/Android/i)) +export const isMobile = isiPhone || isiPad || isAndroid +export const isDesktop = !isMobile + +const htmlClassList = document.body.parentNode.classList +htmlClassList.add(isDesktop ? 'desktop' : 'mobile') + +/* Default image dimensions */ + +export const widths = { + th: 160, + sm: 320, + md: 640, + lg: 1280, +} + +/* Formatting functions */ + +const acronyms = 'id url cc sa fp md5 sha256'.split(' ').map(s => '_' + s) +const acronymsUpperCase = acronyms.map(s => s.toUpperCase()) + +export const formatName = s => { + acronyms.forEach((acronym, i) => s = s.replace(acronym, acronymsUpperCase[i])) + return s.replace(/_/g, ' ') +} + +// Use to pad frame numbers with zeroes +export const pad = (n, m) => { + let s = String(n || 0) + while (s.length < m) { + s = '0' + s + } + return s +} + +export const courtesyS = (n, s) => n + ' ' + (n === 1 ? s : s + 's') +export const capitalize = s => s.split(' ').map(capitalizeWord).join(' ') +export const capitalizeWord = s => s.substr(0, 1).toUpperCase() + s.substr(1) +export const padSeconds = n => n < 10 ? '0' + n : n + +export const timestamp = (n = 0, fps = 1, ms = false) => { + if (n < 0) return '' + let s = '' + n /= fps + if (ms) { + const mantissa = Math.round((n % 1) * 10) + s = '.' + mantissa + } + n = Math.round(n) + s = padSeconds(n % 60) + s + n = Math.floor(n / 60) + if (n > 60) { + return Math.floor(n / 60) + ':' + padSeconds(n % 60) + ':' + s + } + return (n % 60) + ':' + s +} + +export const percent = n => (n * 100).toFixed(1) + '%' + +export const px = (n, w) => Math.round(n * w) + 'px' + +export const clamp = (n, a=0, b=1) => n < a ? a : n < b ? n : b +export const dist = (x1, y1, x2, y2) => Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2)) +export const mod = (n, m) => n - (m * Math.floor(n / m)) +export const angle = (x1, y1, x2, y2) => Math.atan2(y2 - y1, x2 - x1) +export const floatLT = (a,b) => ((a*10|0) < (b*10|0)) +export const floatLTE = (a,b) => ((a*10|0) === (b*10|0) || floatLT(a,b)) + +/* URLs */ + +export const sha256_tree = (sha256, branch_size=2, tree_depth=2) => { + const tree_size = tree_depth * branch_size + let tree = "" + for (var i = 0; i < tree_size; i += branch_size) { + tree += '/' + sha256.substr(i, branch_size) + } + return tree +} + +export const preloadImage = (url, anonymous=false) => ( + new Promise((resolve, reject) => { + if (typeof url === 'object' && url instanceof Image) { + return resolve(url) + } + const image = new Image() + let loaded = false + image.onload = () => { + if (loaded) return + loaded = true + image.onload = null + image.onerror = null + resolve(image) + } + image.onerror = () => { + if (loaded) return + image.onload = null + image.onerror = null + resolve(image) + } + // console.log(img.src) + if (anonymous) { + image.crossOrigin = 'anonymous' + } + image.src = url + if (image.complete) { + image.onload() + } + }) +) + +export const cropImage = (url, crop, maxSide) => { + return new Promise((resolve, reject) => { + preloadImage(url, true) + .then(image => { + let { x, y, w, h } = crop + x = parseFloat(x) + y = parseFloat(y) + w = parseFloat(w) + h = parseFloat(h) + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + const { naturalWidth, naturalHeight } = image + + let width, height + let cropWidth = naturalWidth * w + let cropHeight = naturalHeight * h + + if (maxSide > 0) { + if (cropWidth > cropHeight) { + width = Math.min(maxSide, cropWidth) + height = cropHeight * width / cropWidth + } else { + height = Math.min(maxSide, cropHeight) + width = cropWidth * height / cropHeight + } + } else { + width = cropWidth + height = cropHeight + } + + canvas.width = w * width + canvas.height = h * height + + ctx.drawImage( + image, + Math.round(x * naturalWidth), + Math.round(y * naturalHeight), + Math.round(w * naturalWidth), + Math.round(h * naturalHeight), + 0, 0, canvas.width, canvas.height + ) + // console.log(x, y, w, h) + // console.log(naturalWidth, naturalHeight) + // console.log(width, height) + resolve(canvas) + }) + }) +} +export const urlSearchParamsToDict = search => { + const params = new URLSearchParams(search) + const dict = {} + params.forEach((value, key) => { // ??? + dict[key] = value + }) + return dict +} + +/* AJAX */ + +let cachedAuth = null +let token = '' +let username = '' + +export const post = (dispatch, type=api_type, tag, url, data) => { + let headers + if (data instanceof FormData) { + headers = { + Accept: 'application/json', + } + } else if (data) { + headers = { + Accept: 'application/json', + 'Content-Type': 'application/json; charset=utf-8', + } + data = JSON.stringify(data) + } + + dispatch({ + type: type.loading, + tag, + }) + return fetch(url, { + method: 'POST', + body: data, + headers, + }) + .then(res => res.json()) + .then(res => dispatch({ + type: type.loaded, + tag, + data: res, + })) + .catch(err => dispatch({ + type: type.error, + tag, + err, + })) +} + +export const api = (dispatch, type=api_type, tag, url, data) => { + dispatch({ + type: type.loading, + tag, + }) + if (url.indexOf('http') !== 0) { + url = window.location.origin + url + } + url = new URL(url) + if (data) { + url.search = new URLSearchParams(data).toString() + } + let req = fetch(url, { + method: 'GET', + // mode: 'cors', + }) + // console.log(tag) + if (tag === 'text') { + req = req.then(res => res.text()) + } else { + req = req.then(res => res.json()) + } + req = req.then(res => { + dispatch({ + type: type.loaded, + tag, + data: res, + }) + return res + }) + .catch(err => dispatch({ + type: type.error, + tag, + err, + })) + return req +} + +/* sorting */ + +export const numericSort = { + asc: (a,b) => a[0] - b[0], + desc: (a,b) => b[0] - a[0], +} +export const stringSort = { + asc: (a,b) => a[0].localeCompare(b[0]), + desc: (a,b) => b[0].localeCompare(a[0]), +} +export const orderByFn = (s='name asc') => { + const [field='name', direction='asc'] = s.split(' ') + let mapFn, sortFn + switch (field) { + case 'id': + mapFn = a => [parseInt(a.id) || 0, a] + sortFn = numericSort[direction] + break + case 'epoch': + mapFn = a => [parseInt(a.epoch || a.epochs) || 0, a] + sortFn = numericSort[direction] + break + case 'size': + mapFn = a => [parseInt(a.size) || 0, a] + sortFn = numericSort[direction] + break + case 'start_ts': + mapFn = a => [parseFloat(a.start_ts) || 0, a] + sortFn = numericSort[direction] + break + case 'date': + mapFn = a => [+new Date(a.date || a.created_at), a] + sortFn = numericSort[direction] + break + case 'updated_at': + mapFn = a => [+new Date(a.updated_at), a] + sortFn = numericSort[direction] + break + case 'priority': + mapFn = a => [parseInt(a.priority) || parseInt(a.id) || 1000, a] + sortFn = numericSort[direction] + break + case 'title': + mapFn = a => [a.title || "", a] + sortFn = stringSort[direction] + break + case 'author': + mapFn = a => { + let author = (a.author || "").split(' and ')[0].split(' ') + author.unshift(author.pop()) + author = author.join(' ') + return [author, a] + } + sortFn = stringSort[direction] + break + case 'name': + default: + mapFn = a => [a.name || "", a] + sortFn = stringSort[direction] + break + } + return { mapFn, sortFn } +} +export const getOrderedIds = (objects, sort, prepend) => { + if (!prepend) { + prepend = [] + } + const { mapFn, sortFn } = orderByFn(sort) + return prepend.concat(objects.map(mapFn).sort(sortFn).map(a => a[1].id)) +} +export const getOrderedIdsFromLookup = (lookup, sort) => { + return getOrderedIds(Object.keys(lookup).map(key => lookup[key]), sort) +} + +/* parallel promises */ + +export const allProgress = (promises, progress_cb) => { + let d = 0 + progress_cb(0, 0, promises.length) + promises.forEach((p) => { + p.then((s) => { + d += 1 + progress_cb(Math.floor((d * 100) / promises.length), d, promises.length) + return s + }) + }) + return Promise.all(promises) +} + +/* Clipboard */ + +export function writeToClipboard(str) { + return new Promise((resolve, reject) => { + let success = false + function listener(e) { + e.clipboardData.setData("text/plain", str) + e.preventDefault() + success = true + } + document.addEventListener("copy", listener) + document.execCommand("copy") + document.removeEventListener("copy", listener) + if (success) { + resolve() + } else { + reject() + } + }) +}
\ No newline at end of file diff --git a/animism-align/frontend/app/views/align/align.actions.js b/animism-align/frontend/app/views/align/align.actions.js new file mode 100644 index 0000000..5176d81 --- /dev/null +++ b/animism-align/frontend/app/views/align/align.actions.js @@ -0,0 +1,93 @@ +import * as types from 'app/types' +import { store, history, dispatch } from 'app/store' +import { api, post, pad, preloadImage } from 'app/utils' +import actions from 'app/actions' +// import { session } from 'app/session' +import throttle from 'lodash.throttle' +import debounce from 'lodash.debounce' + +import { ZOOM_STEPS } from 'app/constants' +import { getFirstPunctuationMarkIndex, cutFirstSentence } from 'app/views/align/align.util' + +export const setScrollPosition = start_ts => dispatch => ( + dispatch({ type: types.align.set_display_setting, key: 'start_ts', value: start_ts }) +) + +export const setZoom = zoom => dispatch => { + if (0 <= zoom && zoom < ZOOM_STEPS.length) { + dispatch({ type: types.align.set_display_setting, key: 'zoom', value: zoom }) + } +} +export const throttledSetZoom = throttle(zoom => dispatch => { + setZoom(zoom)(dispatch) +}, 250, { leading: true }) + +export const setCursor = cursor_ts => dispatch => ( + dispatch({ type: types.align.set_display_setting, key: 'cursor_ts', value: cursor_ts }) +) + +export const setSelectedAnnotation = annotation => dispatch => { + dispatch({ type: types.align.set_selected_annotation, data: annotation }) + debouncedUpdateAnnotation.flush() +} +export const clearSelectedAnnotation = () => dispatch => { + dispatch({ type: types.align.clear_selected_annotation }) + debouncedUpdateAnnotation.flush() +} +export const updateSelectedAnnotation = annotation => dispatch => { + dispatch({ type: types.align.set_selected_annotation, data: { ...annotation } }) + debouncedUpdateAnnotation(annotation) +} +export const debouncedUpdateAnnotation = debounce(annotation => { + console.log('updating annotation', annotation) + actions.annotation.update(annotation) +}, 2000, { leading: false, trailing: true }) + + +export const setSelectedParagraph = paragraph_id => dispatch => { + dispatch({ type: types.align.set_display_setting, key: 'selected_paragraph_id', value: paragraph_id }) +} +export const clearSelectedParagraph = paragraph_id => dispatch => { + dispatch({ type: types.align.set_display_setting, key: 'selected_paragraph_id', value: -1 }) +} + +export const showNewAnnotationForm = (start_ts, text) => dispatch => { + let croppedText; + if (store.getState().align.annotation.start_ts) { + croppedText = store.getState().align.annotation.text + } else { + croppedText = cutFirstSentence(text) + } + // console.log(croppedText) + dispatch({ + type: types.align.set_temporary_annotation, + data: { + id: 'new', + start_ts, + end_ts: 0.0, + text: croppedText, + type: 'sentence', + settings: {}, + } + }) +} +export const showEditAnnotationForm = (annotation) => dispatch => { + dispatch({ + type: types.align.set_temporary_annotation, + data: annotation, + }) +} + +export const updateAnnotationForm = (key, value) => dispatch => { + dispatch({ type: types.align.update_temporary_annotation, key, value }) +} +export const updateAnnotationSettings = (key, value) => dispatch => { + dispatch({ type: types.align.update_temporary_annotation_settings, key, value }) +} + +export const hideAnnotationForm = () => dispatch => { + dispatch({ + type: types.align.set_temporary_annotation, + data: {} + }) +} diff --git a/animism-align/frontend/app/views/align/align.container.js b/animism-align/frontend/app/views/align/align.container.js new file mode 100644 index 0000000..a659fdd --- /dev/null +++ b/animism-align/frontend/app/views/align/align.container.js @@ -0,0 +1,29 @@ +import React, { Component } from 'react' +import { Route } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import './align.css' + +import Timeline from 'app/views/align/containers/timeline.container.js' +import Script from 'app/views/align/containers/script.container.js' +import actions from 'app/actions' + +class Container extends Component { + componentDidMount() { + document.body.scrollTo(0, 0) + document.body.parentNode.scrollTo(0, 0) + } + render() { + return ( + <div className='body'> + <div className='row'> + <Timeline /> + </div> + <Script /> + </div> + ) + } +} + +export default Container diff --git a/animism-align/frontend/app/views/align/align.css b/animism-align/frontend/app/views/align/align.css new file mode 100644 index 0000000..bbf3bc2 --- /dev/null +++ b/animism-align/frontend/app/views/align/align.css @@ -0,0 +1,215 @@ +* { + +} +.body.loading > div { + padding: 1rem; +} +.body { + width: 100%; + height: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + background: linear-gradient( + 0deg, + rgba(0, 0, 64, 0.5), + rgba(64, 64, 128, 0.5) + ); + padding: 0; +} + +/* Timeline */ + +canvas { + display: block; +} +.timeline { + display: flex; + flex-direction: row; + position: relative; + width: 300px; + cursor: crosshair; +} +.timelineColumn { + position: relative; +} +.ticks .tick { + position: absolute; + right: 0; + width: 4px; + height: 1px; + background: #ddd; +} +.ticks .tickLabel { + position: absolute; + right: 6px; + font-size: 12px; + width: 40px; + margin-top: -7px; + text-align: right; + text-shadow: 0 0 2px #00f; +} +.timeline .cursor { + width: 100%; + position: absolute; + left: 0; + pointer-events: none; +} +.timeline .cursor .line { + width: 100%; + height: 1px; + background: #00f; +} +.timeline .cursor.playCursor .line { + background: #ddd; +} +.timeline .cursor .tickLabel { + position: absolute; + pointer-events: none; + right: 6px; + font-size: 12px; + width: 40px; + margin-top: -7px; + text-align: right; + text-shadow: 0 0 2px #000, 0 0 2px #000, 0 0 2px #000; +} + +/* Audio player */ + +.playButton { + /*position: absolute;*/ + /*top: 0; left: 0;*/ + width: 3rem; height: 3rem; + padding: 1rem; + background: transparent; + cursor: pointer; + background-size: contain; + background-repeat: no-repeat; + background-position: center; +} +.playButton.playing { + background-image: url('/static/img/icons_pause_white.svg'); +} +.playButton.paused { + background-image: url('/static/img/icons_play_white.svg'); +} + +/* Script */ + +.script { + height: calc(100vh - 3.15rem); + z-index: 1; +} + +/* Annotations */ + +.annotations { + position: relative; + width: 450px; +} + +/* Annotation form */ + +.annotationForm { + width: 401px; + padding: 0.5rem; + position: absolute; + left: 0.25rem; + background: #448; + box-shadow: 0 0 2px #000, 0 0 4px #000; + z-index: 10; +} +.annotationForm textarea { + width: 100%; +} +.annotationForm .row { + justify-content: space-between; + align-items: center; +} +.annotationForm .row > div { + display: flex; + align-items: center; +} +.annotationForm .buttons { + margin-bottom: 0.5rem; +} +.annotationForm .ts { + color: #fff; +} +.annotationForm .select.media_id { + width: 100%; + margin-right: 0; +} + +/* Annotation index */ + +.annotationIndex { + width: 800px; +} +.annotationIndex .annotation { + position: absolute; + left: 5px; + max-width: 400px; + padding: 0.25rem 0.375rem; + box-shadow: 0px 0px 3px rgba(0,0,0,1.0); + border: 1px solid transparent; + border-radius: 2px; + font-size: 12px; + cursor: pointer; + user-select: none; + background-color: #768; +} +.annotation.selected { + border-color: #bbf; + box-shadow: 0px 0px 4px rgba(0,0,0,1.0), 0px 0px 2px rgba(0,0,0,1.0); + z-index: 1; + background-image: linear-gradient(rgba(0,0,0,0.5), rgba(0,0,0,0.4)); +} +.annotationIndex .annotation.media { + left: calc(405px + 0.5rem); +} +.annotation.sentence.even { + background-color: #83b; +} +.annotation.sentence.odd { + background-color: #537; +} +.annotation.header { + background-color: #838; +} +.annotation.paragraph_end { + background-color: #003; + border-top: 1px solid #888; + width: 100%; + padding: 1px; +} + +/* Condensed layout (first lines) */ + +.annotationIndex.condensed .annotation.sentence { + z-index: 0; + white-space: pre; + overflow: hidden; + text-overflow: ellipsis; +} +.annotationIndex.condensed .annotation.header { + z-index: 1; +} +.annotationIndex.condensed .annotation.paragraph_end { + border-top-color: #888; +} + +/* Collapsed layout (borders) */ + +.annotationIndex.collapsed .annotation.sentence { + height: 2px; overflow: hidden; padding: 0; z-index: 0; +} +.annotationIndex.collapsed .annotation.sentence.selected { + z-index: 1; +} +.annotationIndex.collapsed .annotation.header { + z-index: 2; +} +.annotationIndex.collapsed .annotation.paragraph_end { + border-top-color: #333; +} diff --git a/animism-align/frontend/app/views/align/align.reducer.js b/animism-align/frontend/app/views/align/align.reducer.js new file mode 100644 index 0000000..1f79180 --- /dev/null +++ b/animism-align/frontend/app/views/align/align.reducer.js @@ -0,0 +1,85 @@ +import * as types from 'app/types' +import { session, getDefault, getDefaultInt } from 'app/session' + +const initialState = { + timeline: { + cursor_ts: -1, + start_ts: 0, + zoom: 1, + duration: 0, + selected_annotation_id: -1, + selected_paragraph_id: -1, + }, + annotation: {}, + selectedAnnotation: {}, + options: { + } +} + +export default function alignReducer(state = initialState, action) { + // console.log(action.type, action) + switch (action.type) { + case types.peaks.loaded: + console.log('peaks duration:', action.data.length / 10) + return state + + case types.align.set_display_setting: + return { + ...state, + timeline: { + ...state.timeline, + [action.key]: action.value, + } + } + + case types.align.set_selected_annotation: + return { + ...state, + timeline: { + ...state.timeline, + selected_annotation_id: action.data.id, + }, + selectedAnnotation: action.data, + } + + case types.align.clear_selected_annotation: + return { + ...state, + timeline: { + ...state.timeline, + selected_annotation_id: -1, + }, + selectedAnnotation: {}, + } + + case types.align.set_temporary_annotation: + return { + ...state, + annotation: action.data, + } + + case types.align.update_temporary_annotation: + return { + ...state, + annotation: { + ...state.annotation, + [action.key]: action.value, + } + } + + case types.align.update_temporary_annotation_settings: + return { + ...state, + annotation: { + ...state.annotation, + settings: { + ...state.annotation.settings, + [action.key]: action.value, + } + } + } + + default: + return state + } +} diff --git a/animism-align/frontend/app/views/align/align.util.js b/animism-align/frontend/app/views/align/align.util.js new file mode 100644 index 0000000..e873bbf --- /dev/null +++ b/animism-align/frontend/app/views/align/align.util.js @@ -0,0 +1,65 @@ +import { ZOOM_STEPS } from 'app/constants' +import { clamp } from 'app/utils' +import actions from 'app/actions' + +import { HEADER_MARGIN, INNER_HEIGHT } from 'app/constants' + +export const positionToTime = (y, { start_ts, zoom, duration }) => { + y -= HEADER_MARGIN + const secondsPerPixel = ZOOM_STEPS[zoom] * 0.1 + const widthTimeDuration = INNER_HEIGHT * secondsPerPixel + const timeMin = start_ts + const timeMax = Math.min(start_ts + widthTimeDuration, duration) + const timeWidth = timeMax - timeMin + return clamp(y * secondsPerPixel + start_ts, 0, timeMax) +} + +export const timeToPosition = (ts, { start_ts, zoom, duration }) => { + const secondsPerPixel = ZOOM_STEPS[zoom] * 0.1 + const widthTimeDuration = INNER_HEIGHT * secondsPerPixel + const timeMin = start_ts + const timeMax = Math.min(start_ts + widthTimeDuration, duration) + const timeWidth = timeMax - timeMin + const timeHalfHeight = INNER_HEIGHT * secondsPerPixel / 2 + if (ts < timeMin - timeHalfHeight) { + return -9999 + } + if (ts > timeMax) { + return -9999 + } + return (ts - timeMin) / timeWidth * INNER_HEIGHT +} + +export const getFirstPunctuationMarkIndex = text => { + const indexes = [ + text.indexOf('. '), + text.indexOf('? '), + text.indexOf('! '), + text.indexOf('." '), + text.indexOf('?" '), + text.indexOf('!" '), + text.indexOf('.” '), + text.indexOf('?” '), + text.indexOf('!” '), + ] + + return indexes.reduce((a, b) => { + if (b < 0) return a + return Math.min(a, b) + }, Infinity) + 1 +} + +export const cutFirstSentence = text => { + const textToCrop = text.trim().replace("\n", " ").split("\n")[0] + let cropIndex = getFirstPunctuationMarkIndex(textToCrop) + if (!cropIndex) cropIndex = textToCrop.length + const croppedText = textToCrop.substr(0, cropIndex).trim() + const updatedText = text.trim().replace(croppedText, '').trim() + actions.site.updateText(updatedText) + return croppedText +} + +export const thumbnailURL = data => { + if (data.type === 'video') return data.settings.video.thumbnail_url + if (data.type === 'image') return data.settings.thumbnail.url +} diff --git a/animism-align/frontend/app/views/align/components/annotations/annotation.form.js b/animism-align/frontend/app/views/align/components/annotations/annotation.form.js new file mode 100644 index 0000000..7d66272 --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotation.form.js @@ -0,0 +1,182 @@ +import React, { Component } from 'react' +// import { Link } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import actions from 'app/actions' + +import { ZOOM_STEPS } from 'app/constants' +import { clamp, timestamp, capitalize } from 'app/utils' +import { timeToPosition } from 'app/views/align/align.util' +import { Select } from 'app/common' + +import { + AnnotationFormVideo, + AnnotationFormImage, +} from './annotationForms' + +const ANNOTATION_TYPES = [ + 'sentence', 'header', 'paragraph_end', 'video', 'image', 'image_carousel', +].map(name => ({ name, label: capitalize(name.replace('_', ' ')) })) + +class AnnotationForm extends Component { + constructor(props){ + super(props) + this.handleChange = this.handleChange.bind(this) + this.handleSelect = this.handleSelect.bind(this) + this.handleSettingsSelect = this.handleSettingsSelect.bind(this) + this.handleKeyDown = this.handleKeyDown.bind(this) + this.handleSubmit = this.handleSubmit.bind(this) + this.handleDestroy = this.handleDestroy.bind(this) + this.textareaRef = React.createRef() + } + componentDidMount() { + if (this.textareaRef && this.textareaRef.current) { + this.textareaRef.current.focus() + } + } + handleKeyDown(e) { + if (e.keyCode === 27) { // escape + actions.align.hideAnnotationForm() + return + } + // console.log(e.keyCode) + if (!e.metaKey && !e.ctrlKey) return + let { start_ts } = this.props.annotation + switch (e.keyCode) { + case 38: // up + e.preventDefault() + start_ts -= 0.1 + actions.align.updateAnnotationForm('start_ts', Math.max(0, start_ts)) + actions.audio.seek(start_ts) + actions.align.setCursor(start_ts) + break + case 40: // down + e.preventDefault() + start_ts += 0.1 + actions.align.updateAnnotationForm('start_ts', Math.max(0, start_ts)) + actions.audio.seek(start_ts) + actions.align.setCursor(start_ts) + break + case 83: // ctrl-S + e.preventDefault() + this.handleSubmit() + default: + break + } + } + handleChange(e) { + const { name, value } = e.target + this.handleSelect(name, value) + } + handleSelect(name, value) { + actions.align.updateAnnotationForm(name, value) + } + handleSettingsSelect(name, value) { + if (name.indexOf('_id') !== -1) value = parseInt(value) || 0 + actions.align.updateAnnotationSettings(name, value) + } + handleSubmit() { + const { annotation } = this.props + if (annotation.type === 'paragraph_end') { + annotation.text = '' + } + if (annotation.id === 'new') { + delete annotation.id + actions.annotation.create(annotation) + .then(response => { + console.log(response) + actions.align.hideAnnotationForm() + }) + } else { + actions.annotation.update(annotation) + .then(response => { + console.log(response) + actions.align.hideAnnotationForm() + }) + } + } + handleDestroy() { + const { annotation } = this.props + if (annotation.id === 'new') { + actions.align.hideAnnotationForm() + } else { + actions.annotation.destroy(annotation) + .then(response => { + console.log(response) + actions.align.hideAnnotationForm() + }) + } + } + render() { + const { timeline, annotation, media } = this.props + if (!annotation.start_ts) return <div></div> + return ( + <div + className='annotationForm' + style={{ + top: timeToPosition(annotation.start_ts, timeline), + }} + > + {this.renderButtons()} + {annotation.type === 'sentence' && this.renderTextarea()} + {annotation.type === 'header' && this.renderTextarea()} + {annotation.type === 'video' && + <AnnotationFormVideo annotation={annotation} media={media} handleSettingsSelect={this.handleSettingsSelect} /> + } + {annotation.type === 'image' && + <AnnotationFormImage annotation={annotation} media={media} handleSettingsSelect={this.handleSettingsSelect} /> + } + {annotation.type === 'image_carousel' && + <AnnotationFormImageCarousel annotation={annotation} media={media} handleSettingsSelect={this.handleSettingsSelect} /> + } + </div> + ) + } + renderButtons() { + const { annotation } = this.props + return ( + <div className='row buttons'> + <div> + <Select + name='type' + selected={annotation.type} + options={ANNOTATION_TYPES} + defaultOption='text' + onChange={this.handleSelect} + /> + <div className='ts'>{timestamp(annotation.start_ts, 1, true)}</div> + </div> + <div> + {annotation.id !== 'new' && <button onClick={this.handleDestroy}>Delete</button>} + <button onClick={this.handleSubmit}>Save</button> + </div> + </div> + ) + } + renderTextarea() { + const { annotation } = this.props + return ( + <div> + <textarea + name='text' + value={annotation.text} + onKeyDown={this.handleKeyDown} + onChange={this.handleChange} + ref={this.textareaRef} + /> + </div> + ) + } +} + +const mapStateToProps = state => ({ + annotation: state.align.annotation, + timeline: state.align.timeline, + media: state.media.index, +}) + +const mapDispatchToProps = dispatch => ({ +}) + +export default connect(mapStateToProps, mapDispatchToProps)(AnnotationForm) diff --git a/animism-align/frontend/app/views/align/components/annotations/annotation.index.js b/animism-align/frontend/app/views/align/components/annotations/annotation.index.js new file mode 100644 index 0000000..aa31268 --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotation.index.js @@ -0,0 +1,126 @@ +import React, { Component } from 'react' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import actions from 'app/actions' + +import { ZOOM_STEPS, INNER_HEIGHT } from 'app/constants' +import { clamp } from 'app/utils' +import { positionToTime, timeToPosition } from 'app/views/align/align.util' + +import { AnnotationElementLookup } from './annotationTypes' + +class AnnotationIndex extends Component { + state = { + items: [], + } + constructor(props){ + super(props) + this.handleClick = this.handleClick.bind(this) + } + componentDidUpdate(prevProps) { + if (this.props.index.loading) return + if (prevProps.timeline !== this.props.timeline || prevProps.index !== this.props.index) { + this.update() + } + } + update() { + let { timeline, index } = this.props + let { start_ts, zoom, duration } = this.props.timeline + const { order, lookup } = index + + let secondsPerPixel = ZOOM_STEPS[zoom] * 0.1 // 0.1 sec / step + let widthTimeDuration = INNER_HEIGHT * secondsPerPixel // secs per pixel + + let timeMin = start_ts - 50.0 + let timeMax = Math.min(start_ts + widthTimeDuration, duration) + + const items = order.filter(id => { + const { start_ts: ts } = lookup[id] + return (timeMin < ts && ts < timeMax) + }).map(id => lookup[id]).reverse() + this.setState({ items }) + } + handleClick(e, annotation) { + e.stopPropagation() + if (e.shiftKey) { + e.preventDefault() + this.handleParagraphSelection(annotation, e.metaKey) + } + actions.audio.seek(annotation.start_ts) + actions.align.setSelectedAnnotation(annotation) + } + handleParagraphSelection(annotation, shouldClear) { + const { selected_paragraph_id } = this.props.timeline + if (!selected_paragraph_id || selected_paragraph_id === -1 || shouldClear) { + if (annotation.paragraph_id && !shouldClear) { + actions.align.setSelectedParagraph(annotation.paragraph_id) + } else { + actions.paragraph.create({ + type: 'paragraph', + start_ts: annotation.start_ts, + }).then(data => { + actions.align.setSelectedParagraph(data.res.id) + annotation.paragraph_id = data.res.id + actions.annotation.update(annotation) + }) + } + } else if (selected_paragraph_id !== annotation.paragraph_id) { + annotation.paragraph_id = selected_paragraph_id + actions.annotation.update(annotation) + } + } + handleDoubleClick(e, annotation) { + e.stopPropagation() + actions.align.showEditAnnotationForm(annotation) + } + render() { + const { timeline, media, annotationInForm, selectedAnnotation } = this.props + const { start_ts, zoom, selected_annotation_id } = timeline + const { items } = this.state + const className = (zoom < 2) + ? 'annotationIndex' + : (zoom < 3) + ? 'annotationIndex condensed' + : 'annotationIndex collapsed' + return ( + <div className={className}> + {items.map(annotation => { + if (annotationInForm && annotation.id === annotationInForm.id) { + return null + } + if (annotation.id === selected_annotation_id) { + annotation = selectedAnnotation + } + const { id, type, start_ts } = annotation + const AnnotationElement = AnnotationElementLookup[type] + const y = timeToPosition(start_ts, timeline) + return ( + <AnnotationElement + key={id} + y={y} + selected={annotation.id === selected_annotation_id} + annotation={annotation} + media={media} + onClick={this.handleClick} + onDoubleClick={this.handleDoubleClick} + /> + ) + })} + </div> + ) + } +} + +const mapStateToProps = state => ({ + timeline: state.align.timeline, + annotationInForm: state.align.annotation, + selectedAnnotation: state.align.selectedAnnotation, + index: state.annotation.index, + media: state.media.index.lookup, +}) + +const mapDispatchToProps = dispatch => ({ +}) + +export default connect(mapStateToProps, mapDispatchToProps)(AnnotationIndex) diff --git a/animism-align/frontend/app/views/align/components/annotations/annotationForms/annotationForm.image.js b/animism-align/frontend/app/views/align/components/annotations/annotationForms/annotationForm.image.js new file mode 100644 index 0000000..8457b68 --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotationForms/annotationForm.image.js @@ -0,0 +1,27 @@ +import React, { Component } from 'react' + +import { Select } from 'app/common' + +export const AnnotationFormImage = ({ annotation, media, handleSettingsSelect }) => { + if (!media.lookup) return <div /> + const { lookup, order } = media + const image_list_items = order.filter(id => lookup[id].type === 'image').map(id => { + const image = lookup[id] + return { + name: image.id, + label: image.author + ' - ' + image.title + } + }) + return ( + <div> + <Select + name='media_id' + className="media_id" + selected={annotation.settings.media_id} + options={image_list_items} + defaultOption='Choose an image' + onChange={handleSettingsSelect} + /> + </div> + ) +} diff --git a/animism-align/frontend/app/views/align/components/annotations/annotationForms/annotationForm.video.js b/animism-align/frontend/app/views/align/components/annotations/annotationForms/annotationForm.video.js new file mode 100644 index 0000000..1fb552b --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotationForms/annotationForm.video.js @@ -0,0 +1,27 @@ +import React, { Component } from 'react' + +import { Select } from 'app/common' + +export const AnnotationFormVideo = ({ annotation, media, handleSettingsSelect }) => { + if (!media.lookup) return <div /> + const { lookup, order } = media + const video_list_items = order.filter(id => lookup[id].type === 'video').map(id => { + const video = lookup[id] + return { + name: video.id, + label: video.author + ' - ' + video.title + } + }) + return ( + <div> + <Select + name='media_id' + className="media_id" + selected={annotation.settings.media_id} + options={video_list_items} + defaultOption='Choose a video' + onChange={handleSettingsSelect} + /> + </div> + ) +} diff --git a/animism-align/frontend/app/views/align/components/annotations/annotationForms/index.js b/animism-align/frontend/app/views/align/components/annotations/annotationForms/index.js new file mode 100644 index 0000000..1411efc --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotationForms/index.js @@ -0,0 +1,12 @@ +import { + AnnotationFormVideo, +} from './annotationForm.video' + +import { + AnnotationFormImage, +} from './annotationForm.image' + +export { + AnnotationFormImage, + AnnotationFormVideo, +} diff --git a/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.image.js b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.image.js new file mode 100644 index 0000000..ec4d25e --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.image.js @@ -0,0 +1,33 @@ +import React, { Component } from 'react' + +import { thumbnailURL } from 'app/views/align/align.util' + +import { checkAnnotationMediaNotReady, AnnotationMediaLoading } from './annotationTypes.util' + +export const AnnotationImage = ({ y, annotation, media, selected, onClick, onDoubleClick }) => { + const { start_ts, text } = annotation + const className = selected ? 'annotation media image selected' : 'annotation media image' + if (checkAnnotationMediaNotReady(annotation, media)) { + return <AnnotationMediaLoading y={y} className={className} onClick={onClick} onDoubleClick={onDoubleClick} /> + } + const data = media[annotation.settings.media_id] + return ( + <div + className={className} + style={{ top: y }} + onClick={e => onClick(e, annotation)} + onDoubleClick={e => onDoubleClick(e, annotation)} + > + <div className='img'> + <img src={thumbnailURL(data)} alt={data.title} /> + </div> + <div className='meta center'> + <div> + <i>{data.title}</i><br /> + {data.author}<br /> + {data.date} + </div> + </div> + </div> + ) +} diff --git a/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.text.js b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.text.js new file mode 100644 index 0000000..be4674f --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.text.js @@ -0,0 +1,49 @@ +import React, { Component } from 'react' + +export const AnnotationSentence = ({ y, annotation, selected, onClick, onDoubleClick }) => { + const { start_ts, text, paragraph_id } = annotation + let className = !paragraph_id + ? 'annotation sentence' + : (paragraph_id % 2) + ? 'annotation sentence odd' + : 'annotation sentence even' + if (selected) className += ' selected' + return ( + <div + className={className} + style={{ top: y }} + onClick={e => onClick(e, annotation)} + onDoubleClick={e => onDoubleClick(e, annotation)} + dangerouslySetInnerHTML={{ __html: text }} + /> + ) +} + +export const AnnotationHeader = ({ y, annotation, selected, onClick, onDoubleClick }) => { + const { start_ts, text } = annotation + const className = selected ? 'annotation header selected' : 'annotation header' + return ( + <div + className={className} + style={{ top: y }} + onClick={e => onClick(e, annotation)} + onDoubleClick={e => onDoubleClick(e, annotation)} + > + {text} + </div> + ) +} + +export const AnnotationParagraphEnd = ({ y, annotation, selected, onClick, onDoubleClick }) => { + const { start_ts, text } = annotation + const className = selected ? 'annotation paragraph_end selected' : 'annotation paragraph_end' + return ( + <div + className={className} + style={{ top: y }} + onClick={e => onClick(e, annotation)} + onDoubleClick={e => onDoubleClick(e, annotation)} + > + </div> + ) +} diff --git a/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.util.js b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.util.js new file mode 100644 index 0000000..17abebd --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.util.js @@ -0,0 +1,28 @@ +import React, { Component } from 'react' + +export const checkAnnotationMediaNotReady = (annotation, media) => { + return (!media) || (!(annotation.settings.media_id in media)) +} + +export const AnnotationMediaLoading = ({ y, className, onClick, onDoubleClick }) => { + if (!media) { + return ( + <div + className={className} + style={{ top: y }} + onClick={e => onClick(e, annotation)} + onDoubleClick={e => onDoubleClick(e, annotation)} + >LOADING...</div> + ) + } + if (!(annotation.settings.media_id in media)) { + return ( + <div + className={className} + style={{ top: y }} + onClick={e => onClick(e, annotation)} + onDoubleClick={e => onDoubleClick(e, annotation)} + >MEDIA NOT FOUND</div> + ) + } +} diff --git a/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.video.js b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.video.js new file mode 100644 index 0000000..f51ac71 --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.video.js @@ -0,0 +1,33 @@ +import React, { Component } from 'react' + +import { thumbnailURL } from 'app/views/align/align.util' +import { checkAnnotationMediaNotReady, AnnotationMediaLoading } from './annotationTypes.util' + +export const AnnotationVideo = ({ y, annotation, media, selected, onClick, onDoubleClick }) => { + const { start_ts, text } = annotation + const className = selected ? 'annotation media video selected' : 'annotation media video' + if (checkAnnotationMediaNotReady(annotation, media)) { + return <AnnotationMediaLoading y={y} className={className} onClick={onClick} onDoubleClick={onDoubleClick} /> + } + const data = media[annotation.settings.media_id] + return ( + <div + className={className} + style={{ top: y }} + onClick={e => onClick(e, annotation)} + onDoubleClick={e => onDoubleClick(e, annotation)} + > + <div className='img'> + <img src={thumbnailURL(data)} alt={data.title} /> + </div> + <div className='meta center'> + <div> + <i>{data.title}</i><br /> + {data.author}<br /> + {data.date} + </div> + </div> + </div> + ) +} + diff --git a/animism-align/frontend/app/views/align/components/annotations/annotationTypes/index.js b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/index.js new file mode 100644 index 0000000..560063b --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/index.js @@ -0,0 +1,22 @@ +import React from 'react' + +import { + AnnotationSentence, AnnotationHeader, + AnnotationParagraphEnd, +} from './annotationTypes.text' + +import { + AnnotationVideo, +} from './annotationTypes.video' + +import { + AnnotationImage, +} from './annotationTypes.image' + +export const AnnotationElementLookup = { + sentence: React.memo(AnnotationSentence), + header: React.memo(AnnotationHeader), + paragraph_end: React.memo(AnnotationParagraphEnd), + video: React.memo(AnnotationVideo), + image: React.memo(AnnotationImage), +} diff --git a/animism-align/frontend/app/views/align/components/player/playButton.component.js b/animism-align/frontend/app/views/align/components/player/playButton.component.js new file mode 100644 index 0000000..c6a8487 --- /dev/null +++ b/animism-align/frontend/app/views/align/components/player/playButton.component.js @@ -0,0 +1,31 @@ +import React, { Component } from 'react' +// import { Link } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import actions from 'app/actions' +// import * as alignActions from '../align.actions' + +import { ZOOM_STEPS } from 'app/constants' +import { clamp } from 'app/utils' + +const PlayButton = ({ audio }) => { + return ( + <div + className={audio.playing ? 'playButton playing' : 'playButton paused'} + onClick={() => { + audio.playing ? actions.audio.pause() : actions.audio.play() + }} + /> + ) +} + +const mapStateToProps = state => ({ + audio: state.audio, +}) + +const mapDispatchToProps = dispatch => ({ + // alignActions: bindActionCreators({ ...alignActions }, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(PlayButton) diff --git a/animism-align/frontend/app/views/align/components/timeline/cursor.component.js b/animism-align/frontend/app/views/align/components/timeline/cursor.component.js new file mode 100644 index 0000000..4a94100 --- /dev/null +++ b/animism-align/frontend/app/views/align/components/timeline/cursor.component.js @@ -0,0 +1,26 @@ +import React, { Component } from 'react' + +import { ZOOM_STEPS } from 'app/constants' +import { timestamp } from 'app/utils' + +const Cursor = ({ timeline, annotation }) => { + const { start_ts, zoom, cursor_ts, duration } = timeline + const ts = annotation.start_ts || cursor_ts + const secondsPerPixel = ZOOM_STEPS[zoom] * 0.1 + const y = (ts - start_ts) / secondsPerPixel + return ( + <div + className='cursor' + style={{ + top: y, + }} + > + <div className='line' /> + <div className='tickLabel'> + {timestamp(ts, 1)} + </div> + </div> + ) +} + +export default Cursor
\ No newline at end of file diff --git a/animism-align/frontend/app/views/align/components/timeline/playCursor.component.js b/animism-align/frontend/app/views/align/components/timeline/playCursor.component.js new file mode 100644 index 0000000..854c43b --- /dev/null +++ b/animism-align/frontend/app/views/align/components/timeline/playCursor.component.js @@ -0,0 +1,36 @@ +import React, { Component } from 'react' +// import { Link } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import { ZOOM_STEPS } from 'app/constants' +import { timestamp } from 'app/utils' + +const PlayCursor = ({ timeline, audio }) => { + const { start_ts, zoom, duration } = timeline + const { play_ts } = audio + const secondsPerPixel = ZOOM_STEPS[zoom] * 0.1 + const y = (play_ts - start_ts) / secondsPerPixel + // console.log(play_ts, y) + return ( + <div + className='cursor playCursor' + style={{ + top: y, + }} + > + <div className='line' /> + </div> + ) +} + +const mapStateToProps = state => ({ + timeline: state.align.timeline, + audio: state.audio, +}) + +const mapDispatchToProps = dispatch => ({ + // alignActions: bindActionCreators({ ...alignActions }, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(PlayCursor) diff --git a/animism-align/frontend/app/views/align/components/timeline/ticks.component.js b/animism-align/frontend/app/views/align/components/timeline/ticks.component.js new file mode 100644 index 0000000..4530863 --- /dev/null +++ b/animism-align/frontend/app/views/align/components/timeline/ticks.component.js @@ -0,0 +1,88 @@ +import React, { Component } from 'react' + +import { ZOOM_STEPS, ZOOM_LABEL_STEPS, ZOOM_TICK_STEPS, INNER_HEIGHT } from 'app/constants' +import { timestamp } from 'app/utils' + +export default class Ticks extends Component { + render() { + let { start_ts, zoom, duration } = this.props.timeline + + let secondsPerPixel = ZOOM_STEPS[zoom] * 0.1 // 0.1 sec / step + + let widthTimeDuration = INNER_HEIGHT * secondsPerPixel // secs per pixel + + let timeMin = start_ts + let timeMax = Math.min(start_ts + widthTimeDuration, duration) + let timeWidth = timeMax - timeMin + + let pixelMin = timeMin / secondsPerPixel + + let secondsPerLabel = ZOOM_LABEL_STEPS[zoom] // secs + let pixelsPerLabel = secondsPerLabel / secondsPerPixel + let secondsPerTick = ZOOM_TICK_STEPS[zoom] + let pixelsPerTick = secondsPerTick / secondsPerPixel + + let startOffset = pixelsPerLabel - (pixelMin % pixelsPerLabel) + let startTiming = (pixelMin + startOffset) * secondsPerPixel + + let labelCount = Math.ceil(INNER_HEIGHT / pixelsPerLabel) + 1 + let offset, timing, tickLabels = [], ticks = [] + for (var i = -1; i < labelCount; i++) { + offset = i * pixelsPerLabel + startOffset + if (offset > INNER_HEIGHT) continue + timing = i * secondsPerLabel + startTiming + if (timing > duration) { + break + } + tickLabels.push( + <div className='tickLabel' key={"tickLabel_" + i} + style={{ + top: Math.floor(offset) + }}> + {timestamp(timing)} + </div> + ) + } + + let durationOffset = duration / secondsPerPixel - pixelMin + if (timing > duration) { + tickLabels.push( + <div className='tickLabel tickLabelTotal' key={"tickLabel_total"} + style={{ + top: durationOffset + }}> + {timestamp(duration, 1)} + </div> + ) + ticks.push( + <div className='tick' key={"tick_total"} + style={{ + top: Math.floor(durationOffset), + }} + /> + ) + } + let tickCount = Math.ceil(INNER_HEIGHT / pixelsPerTick) + 6 + for (var i = 0; i < tickCount; i += 1) { + offset = i * pixelsPerTick + startOffset - pixelsPerLabel + if (offset > durationOffset) { + break + } + ticks.push( + <div className='tick' key={"tick_" + i} + style={{ + top: Math.floor(offset), + }} + /> + ) + } + // console.log(ticks.length) + + return ( + <div className='ticks'> + {ticks} + {tickLabels} + </div> + ) + } +} diff --git a/animism-align/frontend/app/views/align/components/timeline/waveform.component.js b/animism-align/frontend/app/views/align/components/timeline/waveform.component.js new file mode 100644 index 0000000..023b877 --- /dev/null +++ b/animism-align/frontend/app/views/align/components/timeline/waveform.component.js @@ -0,0 +1,101 @@ +import React, { Component } from 'react' +// import { Link } from 'react-router-dom' +// import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import actions from 'app/actions' + +import { + WAVEFORM_SIZE, INNER_HEIGHT, + ZOOM_STEPS, ZOOM_LABEL_STEPS, ZOOM_TICK_STEPS, +} from 'app/constants' + +class Waveform extends Component { + constructor(props){ + super(props) + this.canvasRef = React.createRef() + } + componentDidMount() { + this.resize() + this.draw() + } + componentDidUpdate() { + this.draw() + } + resize() { + const canvas = this.canvasRef.current + canvas.width = WAVEFORM_SIZE + canvas.height = INNER_HEIGHT + } + draw() { + const canvas = this.canvasRef.current + const ctx = canvas.getContext('2d') + const h = INNER_HEIGHT + this.clearCanvas(ctx, h) + this.drawCurve(ctx, h) + } + clearCanvas(ctx, h) { + const w = WAVEFORM_SIZE + ctx.clearRect(0, 0, w, h) + ctx.fillStyle = 'rgba(0,0,0,0.5)' + ctx.fillRect(0, 0, w, h) + ctx.fillStyle = 'rgba(64,128,192,0.5)' + } + drawCurve(ctx, h) { + let { peaks, timeline } = this.props + let { start_ts, zoom, duration } = timeline + + let secondsPerPixel = ZOOM_STEPS[zoom] * 0.1 // 0.1 sec / step + let stepsPerPixel = ZOOM_STEPS[zoom] // 0.1 sec / step + + let widthTimeDuration = h * secondsPerPixel // secs per pixel + + let timeMin = Math.round(start_ts / secondsPerPixel) * secondsPerPixel + let timeMax = Math.min(timeMin + widthTimeDuration, duration) + let timeWidth = timeMax - timeMin + + let stepMin = Math.floor(timeMin * 10) + let pixelWidth = Math.ceil(timeWidth / secondsPerPixel) + + let i = 0 + let step = stepMin + let waveformPeak = WAVEFORM_SIZE / 2 + let origin = (1 - peaks[step]) * waveformPeak + let y + let peak + // console.log(stepMin, pixelWidth * stepsPerPixel + stepMin) + ctx.beginPath() + ctx.moveTo(origin, 0) + for (i = 0; i < pixelWidth; i++) { + step = i * stepsPerPixel + stepMin + peak = peaks[step] + y = (1 - peak) * waveformPeak + ctx.lineTo(y, i) + } + for (i = pixelWidth - 1; i > 0; i--) { + step = i * stepsPerPixel + stepMin + peak = peaks[step] + y = (1 + peak) * waveformPeak + ctx.lineTo(y, i) + } + ctx.lineTo(origin, 0) + ctx.fillStyle = 'rgba(255,255,255,0.8)' + ctx.fill() + } + render() { + return ( + <canvas ref={this.canvasRef} onClick={this.props.onClick} /> + ) + } +} + +const mapStateToProps = state => ({ + timeline: state.align.timeline, + peaks: state.site.peaks, +}) + +const mapDispatchToProps = dispatch => ({ + // uploadActions: bindActionCreators({ ...uploadActions }, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(Waveform) diff --git a/animism-align/frontend/app/views/align/containers/annotations.container.js b/animism-align/frontend/app/views/align/containers/annotations.container.js new file mode 100644 index 0000000..e199bd6 --- /dev/null +++ b/animism-align/frontend/app/views/align/containers/annotations.container.js @@ -0,0 +1,40 @@ +import React, { Component } from 'react' +// import { Link } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import actions from 'app/actions' +// import * as alignActions from '../align.actions' + +import { ZOOM_STEPS } from 'app/constants' +import { clamp } from 'app/utils' +import { positionToTime } from 'app/views/align/align.util' + +import AnnotationForm from 'app/views/align/components/annotations/annotation.form' +import AnnotationIndex from 'app/views/align/components/annotations/annotation.index' + +class Annotations extends Component { + constructor(props){ + super(props) + } + render() { + return ( + <div className='annotations'> + <AnnotationIndex /> + {this.props.annotation.start_ts && + <AnnotationForm /> + } + </div> + ) + } +} + +const mapStateToProps = state => ({ + timeline: state.align.timeline, + annotation: state.align.annotation, +}) + +const mapDispatchToProps = dispatch => ({ +}) + +export default connect(mapStateToProps, mapDispatchToProps)(Annotations) diff --git a/animism-align/frontend/app/views/align/containers/script.container.js b/animism-align/frontend/app/views/align/containers/script.container.js new file mode 100644 index 0000000..cc340c6 --- /dev/null +++ b/animism-align/frontend/app/views/align/containers/script.container.js @@ -0,0 +1,33 @@ +import React, { Component } from 'react' +// import { Link } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import actions from 'app/actions' + +class Timeline extends Component { + constructor(props){ + super(props) + } + render() { + if (this.props.text.loading) return <div /> + return ( + <textarea + className='script' + onChange={e => actions.site.updateText(e.target.value)} + value={this.props.text} + /> + ) + } +} + + +const mapStateToProps = state => ({ + text: state.site.text, +}) + +const mapDispatchToProps = dispatch => ({ + // alignActions: bindActionCreators({ ...alignActions }, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(Timeline) diff --git a/animism-align/frontend/app/views/align/containers/timeline.container.js b/animism-align/frontend/app/views/align/containers/timeline.container.js new file mode 100644 index 0000000..c208e08 --- /dev/null +++ b/animism-align/frontend/app/views/align/containers/timeline.container.js @@ -0,0 +1,171 @@ +import React, { Component } from 'react' +// import { Link } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import actions from 'app/actions' +// import * as alignActions from '../align.actions' + +import Annotations from 'app/views/align/containers/annotations.container' +import Waveform from 'app/views/align/components/timeline/waveform.component' +import Ticks from 'app/views/align/components/timeline/ticks.component' +import Cursor from 'app/views/align/components/timeline/cursor.component' +import PlayButton from 'app/views/align/components/player/playButton.component' +import PlayCursor from 'app/views/align/components/timeline/playCursor.component' + +import { WAVEFORM_SIZE, ZOOM_STEPS, INNER_HEIGHT } from 'app/constants' +import { clamp } from 'app/utils' +import { positionToTime } from 'app/views/align/align.util' + +class Timeline extends Component { + constructor(props){ + super(props) + this.handleKeydown = this.handleKeydown.bind(this) + this.handleMouseMove = this.handleMouseMove.bind(this) + this.handleWheel = this.handleWheel.bind(this) + this.handleContainerClick = this.handleContainerClick.bind(this) + this.handleTimelineClick = this.handleTimelineClick.bind(this) + } + componentDidMount() { + this.bind() + } + componentWillUnmount() { + this.unbind() + } + shouldComponentUpdate(nextProps) { + return ( + nextProps.timeline !== this.props.timeline || + nextProps.annotation !== this.props.annotation + ) + } + bind() { + document.addEventListener('keydown', this.handleKeydown) + } + unbind() { + document.removeEventListener('keydown', this.handleKeydown) + } + handleKeydown(e) { + if (document.activeElement !== document.body) { + return + } + // console.log(e.keyCode) + if (e.metaKey && this.props.selectedAnnotation.id) { + const { selectedAnnotation } = this.props + switch (e.keyCode) { + case 38: // up + e.preventDefault() + selectedAnnotation.start_ts = Math.max(selectedAnnotation.start_ts - (e.shiftKey ? 1 : 0.1), 0) + actions.align.updateSelectedAnnotation(selectedAnnotation) + actions.audio.seek(selectedAnnotation.start_ts) + actions.align.setCursor(selectedAnnotation.start_ts) + break + case 40: // down + e.preventDefault() + selectedAnnotation.start_ts += e.shiftKey ? 1 : 0.1 + actions.align.updateSelectedAnnotation(selectedAnnotation) + actions.audio.seek(selectedAnnotation.start_ts) + actions.align.setCursor(selectedAnnotation.start_ts) + break + } + return + } + if (e.shiftKey) { + switch (e.keyCode) { + case 187: // plus + actions.align.setZoom(this.props.timeline.zoom - 1) + break + case 189: // minus + actions.align.setZoom(this.props.timeline.zoom + 1) + break + } + } else { + // console.log(e.keyCode) + switch (e.keyCode) { + case 27: // escape + actions.align.hideAnnotationForm() + break + case 65: // A - add + e.preventDefault() + actions.align.showNewAnnotationForm(this.props.audio.play_ts, this.props.text) + break + case 32: // spacebar + actions.audio.toggle() + break + case 38: // up + actions.audio.jump(- ZOOM_STEPS[this.props.timeline.zoom] * 0.1) + break + case 40: // down + actions.audio.jump(ZOOM_STEPS[this.props.timeline.zoom] * 0.1) + break + } + } + } + handleWheel(e) { + let { start_ts, zoom, duration } = this.props.timeline + let { deltaY } = e + let secondsPerPixel = ZOOM_STEPS[zoom] * 0.1 // 0.1 sec / step + let widthTimeDuration = INNER_HEIGHT * secondsPerPixel // secs per pixel + start_ts += Math.round((deltaY) * ZOOM_STEPS[zoom]) + start_ts = clamp(start_ts, 0, Math.max(0, duration - widthTimeDuration / 2)) + if (e.shiftKey) { + if (Math.abs(deltaY) < 2) return + if (e.deltaY > 0) { + actions.align.throttledSetZoom(this.props.timeline.zoom + 1) + } else { + actions.align.throttledSetZoom(this.props.timeline.zoom - 1) + } + } else if (e.altKey) { + actions.audio.jump(e.deltaY * ZOOM_STEPS[zoom]) + } else { + actions.align.setScrollPosition(start_ts) + } + } + handleMouseMove(e) { + const cursor_ts = positionToTime(e.pageY, this.props.timeline) + actions.align.setCursor(cursor_ts) + } + handleContainerClick(e) { + actions.align.clearSelectedAnnotation() + actions.align.clearSelectedParagraph() + } + handleTimelineClick(e) { + const play_ts = positionToTime(e.pageY, this.props.timeline) + if (e.pageX < WAVEFORM_SIZE * 0.67) { + actions.audio.seek(play_ts) + } else { + actions.align.showNewAnnotationForm(play_ts, this.props.text) + } + } + render() { + return ( + <div + className='timeline' + onClick={this.handleContainerClick} + onWheel={this.handleWheel} + onMouseMove={this.handleMouseMove} + > + <div className='timelineColumn'> + <Waveform onClick={this.handleTimelineClick} /> + <Ticks timeline={this.props.timeline} /> + <Cursor timeline={this.props.timeline} annotation={this.props.annotation} /> + </div> + <Annotations timeline={this.props.timeline} /> + <PlayCursor /> + </div> + ) + } +} + +const mapStateToProps = state => ({ + timeline: state.align.timeline, + annotation: state.align.annotation, + selectedAnnotation: state.align.selectedAnnotation, + audio: state.audio, + text: state.site.text, +}) + +const mapDispatchToProps = dispatch => ({ + // alignActions: bindActionCreators({ ...alignActions }, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(Timeline) diff --git a/animism-align/frontend/app/views/annotation/annotation.reducer.js b/animism-align/frontend/app/views/annotation/annotation.reducer.js new file mode 100644 index 0000000..50232a9 --- /dev/null +++ b/animism-align/frontend/app/views/annotation/annotation.reducer.js @@ -0,0 +1,20 @@ +import * as types from 'app/types' + +import { crudState, crudReducer } from 'app/api/crud.reducer' + +const initialState = crudState('annotation', { + options: { + sort: 'start_ts asc', + } +}) + +const reducer = crudReducer('annotation') + +export default function annotationReducer(state = initialState, action) { + // console.log(action.type, action) + state = reducer(state, action) + switch (action.type) { + default: + return state + } +} diff --git a/animism-align/frontend/app/views/audio/audio.actions.js b/animism-align/frontend/app/views/audio/audio.actions.js new file mode 100644 index 0000000..bd256a4 --- /dev/null +++ b/animism-align/frontend/app/views/audio/audio.actions.js @@ -0,0 +1,40 @@ +import * as types from 'app/types' +import { store, history, dispatch } from 'app/store' +import actions from 'app/actions' +import { session } from 'app/session' + +const audioPlayer = document.createElement('audio') +audioPlayer.src = '/static/data_store/peaks/animismA080720.mp3' +audioPlayer.addEventListener('loadedmetadata', () => { + console.log('audio duration:', audioPlayer.duration) + dispatch({ type: types.align.set_display_setting, key: 'duration', value: audioPlayer.duration }) +}) +audioPlayer.addEventListener('play', () => { + dispatch({ type: types.audio.play }) +}) +audioPlayer.addEventListener('pause', () => { + dispatch({ type: types.audio.pause }) +}) +audioPlayer.addEventListener('timeupdate', () => { + dispatch({ type: types.audio.update_time, play_ts: audioPlayer.currentTime }) +}) + +export const play = () => dispatch => { + audioPlayer.play() +} +export const pause = () => dispatch => { + audioPlayer.pause() +} +export const seek = play_ts => dispatch => { + audioPlayer.currentTime = play_ts +} +export const jump = delta_ts => dispatch => { + audioPlayer.currentTime += delta_ts +} +export const toggle = () => dispatch => { + if (store.getState().audio.playing) { + pause()(dispatch) + } else { + play()(dispatch) + } +} diff --git a/animism-align/frontend/app/views/audio/audio.reducer.js b/animism-align/frontend/app/views/audio/audio.reducer.js new file mode 100644 index 0000000..6149ca6 --- /dev/null +++ b/animism-align/frontend/app/views/audio/audio.reducer.js @@ -0,0 +1,30 @@ +import * as types from 'app/types' +import { session, getDefault, getDefaultInt } from 'app/session' + +const initialState = { + playing: false, + play_ts: 0, +} + +export default function alignReducer(state = initialState, action) { + // console.log(action.type, action) + switch (action.type) { + case types.audio.play: + return { + ...state, + playing: true, + } + case types.audio.pause: + return { + ...state, + playing: false, + } + case types.audio.update_time: + return { + ...state, + play_ts: action.play_ts, + } + default: + return state + } +} diff --git a/animism-align/frontend/app/views/index.js b/animism-align/frontend/app/views/index.js new file mode 100644 index 0000000..2c9ee78 --- /dev/null +++ b/animism-align/frontend/app/views/index.js @@ -0,0 +1,4 @@ +export { default as align } from './align/align.container' +export { default as paragraph } from './paragraph/paragraph.container' +export { default as upload } from './upload/upload.container' +export { default as media } from './media/media.container' diff --git a/animism-align/frontend/app/views/media/components/media.form.js b/animism-align/frontend/app/views/media/components/media.form.js new file mode 100644 index 0000000..af53014 --- /dev/null +++ b/animism-align/frontend/app/views/media/components/media.form.js @@ -0,0 +1,270 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' + +import { capitalize } from 'app/utils' + +import { TextInput, LabelDescription, Select, TextArea, Checkbox, SubmitButton, Loader } from 'app/common' + +import MediaImageForm from './media.formImage' +import MediaVideoForm from './media.formVideo' + +const newMedia = () => ({ + type: 'image', + tag: 'media', + url: '', + title: '', + author: '', + pre_title: '', + post_title: '', + translated_title: '', + date: '', + source: '', + medium: '', + start_ts: 0, + settings: {}, +}) + +const MEDIA_TYPES = [ + 'image', 'video' +].map(name => ({ name, label: capitalize(name) })) + +export default class MediaForm extends Component { + state = { + title: "", + submitTitle: "", + data: { ...newMedia() }, + errorFields: new Set([]), + } + + constructor(props) { + super(props) + this.handleKeyDown = this.handleKeyDown.bind(this) + this.handleSelect = this.handleSelect.bind(this) + this.handleChange = this.handleChange.bind(this) + this.handleSettingsChange = this.handleSettingsChange.bind(this) + this.handleSettingsChangeEvent = this.handleSettingsChangeEvent.bind(this) + this.handleSubmit = this.handleSubmit.bind(this) + } + + componentDidMount() { + const { data, isNew } = this.props + const title = isNew ? 'New media' : 'Editing ' + data.title + const submitTitle = isNew ? "Add Media" : "Save Changes" + this.setState({ + title, + submitTitle, + errorFields: new Set([]), + data: { + ...newMedia(), + ...data + }, + }) + window.addEventListener('keydown', this.handleKeyDown) + } + + componentWillUnmount() { + window.removeEventListener('keydown', this.handleKeyDown) + } + + handleKeyDown(e) { + // console.log(e, e.keyCode) + if ((e.ctrlKey || e.metaKey) && e.keyCode === 83) { + if (e) { + e.preventDefault() + } + this.handleSubmit() + } + } + + handleChange(e) { + const { name, value } = e.target + this.handleSelect(name, value) + } + + handleSelect(name, value) { + const { errorFields } = this.state + if (errorFields.has(name)) { + errorFields.delete(name) + } + this.setState({ + errorFields, + data: { + ...this.state.data, + [name]: value, + } + }) + } + + handleSettingsChangeEvent(e) { + const { name, value } = e.target + this.handleSettingsChange(name, value) + } + + handleSettingsChange(name, value) { + console.log(name, value) + if (name !== 'multiple') { + value = { [name]: value } + } + this.setState({ + data: { + ...this.state.data, + settings: { + ...this.state.data.settings, + ...value, + } + } + }) + } + + handleSubmit(e) { + if (e) { + e.preventDefault() + } + const { isNew, onSubmit } = this.props + const { data } = this.state + const requiredKeys = "author title date".split(" ") + const validKeys = "type tag url title author pre_title post_title translated_title date source medium start_ts settings".split(" ") + const validData = validKeys.reduce((a,b) => { a[b] = data[b]; return a }, {}) + const errorFields = requiredKeys.filter(key => !validData[key]) + if (errorFields.length) { + console.log('error', errorFields, validData) + this.setState({ errorFields: new Set(errorFields) }) + } else { + if (isNew) { + // + } else { + validData.id = data.id + } + console.log('submit', validData) + onSubmit(validData) + } + } + + render() { + const { isNew } = this.props + const { title, submitTitle, errorFields, data } = this.state + // console.log(data) + return ( + <div className='form'> + <h1>{title}</h1> + <form onSubmit={this.handleSubmit}> + <Select + title='Media Type' + name='type' + selected={data.type} + options={MEDIA_TYPES} + onChange={this.handleSelect} + /> + + {data.type === 'image' && + <MediaImageForm + data={data} + onChange={this.handleSelect} + onSettingsChange={this.handleSettingsChange} + /> + } + + {data.type === 'video' && + <MediaVideoForm + data={data} + onChange={this.handleSelect} + onSettingsChange={this.handleSettingsChange} + /> + } + + <TextInput + title="Author" + name="author" + required + data={data} + onChange={this.handleChange} + autoComplete="off" + /> + <TextInput + title="Title" + name="title" + required + data={data} + onChange={this.handleChange} + autoComplete="off" + /> + <TextInput + title="Title Prefix" + name="pre_title" + data={data} + onChange={this.handleChange} + autoComplete="off" + /> + <TextInput + title="Title Suffix" + name="post_title" + data={data} + onChange={this.handleChange} + autoComplete="off" + /> + <TextInput + title="Translated Title" + name="translated_title" + data={data} + onChange={this.handleChange} + autoComplete="off" + /> + <TextInput + title="Date" + name="date" + required + data={data} + onChange={this.handleChange} + autoComplete="off" + /> + <TextInput + title="Medium" + name="medium" + required + data={data} + onChange={this.handleChange} + /> + <TextInput + title="Source" + name="source" + placeholder="Courtesy of / Copyright" + required + data={data} + onChange={this.handleChange} + autoComplete="off" + /> + <TextArea + title="Citation" + name="bibliography" + placeholder="Use if special HTML formatting needed" + data={data.settings} + onChange={this.handleSettingsChangeEvent} + /> + <Checkbox + label="Hide in list of works" + name="hide_in_bibliography" + checked={data.settings.hide_in_bibliography} + onChange={this.handleSettingsChange} + autoComplete="off" + /> + <TextArea + title="Description" + name="description" + data={data} + onChange={this.handleChange} + /> + <SubmitButton + title={submitTitle} + onClick={this.handleSubmit} + /> + {!!errorFields.size && + <label> + <span></span> + <span>Please complete the required fields</span> + </label> + } + </form> + </div> + ) + } +} diff --git a/animism-align/frontend/app/views/media/components/media.formImage.js b/animism-align/frontend/app/views/media/components/media.formImage.js new file mode 100644 index 0000000..b6e38be --- /dev/null +++ b/animism-align/frontend/app/views/media/components/media.formImage.js @@ -0,0 +1,172 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' + +import { session } from 'app/session' +import actions from 'app/actions' +import { capitalize, preloadImage, cropImage } from 'app/utils' + +import { TextInput, LabelDescription, UploadImage, Select, TextArea, Checkbox, SubmitButton, Loader } from 'app/common' +import { renderThumbnail } from 'app/common/upload.helpers' + +import ImageSelection from './media.formImageSelection' + +const DISPLAY_SIZE = 1024 +const DISPLAY_QUALITY= 80 +const THUMBNAIL_SIZE = 320 +const THUMBNAIL_QUALITY = 80 + +export default class MediaImageForm extends Component { + state = { + img: null, + } + + constructor(props) { + super(props) + this.handleSelect = this.handleSelect.bind(this) + this.handleChange = this.handleChange.bind(this) + this.handleSettingsChange = this.handleSettingsChange.bind(this) + this.handleUpload = this.handleUpload.bind(this) + this.handleCrop = this.handleCrop.bind(this) + this.replaceTaggedSize = this.replaceTaggedSize.bind(this) + this.uploadTaggedSize = this.uploadTaggedSize.bind(this) + } + + componentDidMount() { + // this.setState({ }) + if (this.props.data.settings.fullsize) { + preloadImage(this.props.data.settings.fullsize.url) + .then(img => this.setState({ img })) + } + } + + handleChange(e) { + const { name, value } = e.target + this.handleSelect(name, value) + } + + handleSelect(name, value) { + this.props.onSelect(name, value) + } + + handleSettingsChange(name, value) { + this.props.onSettingsChange(name, value) + } + + handleUpload({ file, img, canvas, blob }) { + // sizes: fullsize, display, thumbnail + this.replaceTaggedSize(file, 'fullsize') + .then(data => { + this.setState({ img }) + this.props.onSettingsChange('multiple', { + fullsize: data, + crop: {}, + }) + return this.replaceTaggedSize(blob, 'display', file.name) + }).then(data => { + this.props.onSettingsChange('multiple', { + display: data, + }) + this.uploadThumbnail(img) + }) + } + + uploadThumbnail(img) { + const { fn } = this.props.data.settings.fullsize + const thumbnailCanvas = renderThumbnail(img, { maxSide: THUMBNAIL_SIZE }) + thumbnailCanvas.toBlob(thumbnail => { + this.replaceTaggedSize(thumbnail, 'thumbnail', fn).then(data => { + this.props.onSettingsChange('multiple', { + thumbnail: data, + }) + }) + }, 'image/jpeg', THUMBNAIL_QUALITY) + } + + replaceTaggedSize(image, tag, fn) { + // when we upload an image, if the image already exists in this "position" + // on the record, we should also delete it + if (this.props.data.settings[tag] && this.props.data.settings[tag].id) { + return new Promise((resolve, reject) => { + actions.upload.destroy(this.props.data.settings[tag]) + .then(() => { + console.log('destroy successful') + this.uploadTaggedSize(image, tag, fn).then(data => resolve(data)) + }) + .catch(() => { + console.log('error deleting the image') + this.uploadTaggedSize(image, tag, fn).then(data => resolve(data)) + }) + }) + } + return this.uploadTaggedSize(image, tag, fn) + } + + uploadTaggedSize(image, tag, fn) { + console.log('uploading size', tag) + const uploadData = { + image, + tag, + username: 'animism', + } + if (fn) { + uploadData['__image_filename'] = fn + } + // console.log(uploadData) + return actions.upload.upload(uploadData).then(data => { + // console.log(data) + return data.res + }) + } + + handleCrop(crop) { + // when cropping an image, re-upload the display image and thumbnail + // console.log(crop) + cropImage(this.state.img, crop, DISPLAY_SIZE) + .then(canvas => { + canvas.toBlob(blob => { + // console.log(canvas, canvas.width, canvas.height, blob) + this.replaceTaggedSize(blob, 'display', this.props.data.settings.fullsize.fn) + .then(data => { + this.props.onSettingsChange('multiple', { + crop, + display: data, + }) + this.uploadThumbnail(canvas) + }) + }, 'image/jpeg', DISPLAY_QUALITY) + }) + } + + render() { + const { data } = this.props + // console.log(data) + return ( + <div className='imageForm'> + {!data.url && + <label className={'text fileInput'}> + <span>{"Upload image"}</span> + <div className="row"> + <button> + {"Choose image"} + </button> + <UploadImage + onUpload={this.handleUpload} + maxSide={DISPLAY_SIZE} + quality={DISPLAY_QUALITY} + /> + </div> + </label> + } + {data.settings.fullsize && + <div> + <ImageSelection + url={data.settings.fullsize.url} + crop={data.settings.crop} + onCrop={this.handleCrop} + /> + </div> + } + </div> + ) + } +} diff --git a/animism-align/frontend/app/views/media/components/media.formImageSelection.js b/animism-align/frontend/app/views/media/components/media.formImageSelection.js new file mode 100644 index 0000000..966eb58 --- /dev/null +++ b/animism-align/frontend/app/views/media/components/media.formImageSelection.js @@ -0,0 +1,213 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' +import toBlob from 'data-uri-to-blob' + +import { clamp } from 'app/utils' +import { Loader } from 'app/common' + +const defaultState = { + dragging: false, + draggingBox: false, + bounds: null, + mouseX: 0, + mouseY: 0, + box: { + x: 0, + y: 0, + w: 0, + h: 0, + } +} + +export default class ImageSelection extends Component { + state = { + ...defaultState + } + + constructor() { + super() + // bind these events in the constructor, so we can remove event listeners later + this.handleMouseDown = this.handleMouseDown.bind(this) + this.handleMouseDownOnBox = this.handleMouseDownOnBox.bind(this) + this.handleMouseMove = this.handleMouseMove.bind(this) + this.handleMouseUp = this.handleMouseUp.bind(this) + this.handleWindowResize = this.handleWindowResize.bind(this) + } + + componentDidMount() { + document.body.addEventListener('mousemove', this.handleMouseMove) + document.body.addEventListener('mouseup', this.handleMouseUp) + window.addEventListener('resize', this.handleWindowResize) + } + + componentDidUpdate(prevProps) { + if (this.state.bounds && this.props.url !== prevProps.url) { + this.setState({ + ...defaultState, + bounds: this.getBoundingClientRect(), + box: this.props.crop || defaultState.box, + }) + } + } + + componentWillUnmount() { + document.body.removeEventListener('mousemove', this.handleMouseMove) + document.body.removeEventListener('mouseup', this.handleMouseUp) + window.removeEventListener('resize', this.handleWindowResize) + } + + getBoundingClientRect() { + if (!this.imgRef) return null + const rect = this.imgRef.getBoundingClientRect() + const scrollTop = document.body.scrollTop || document.body.parentNode.scrollTop + const scrollLeft = document.body.scrollLeft || document.body.parentNode.scrollLeft + const bounds = { + top: rect.top + scrollTop, + left: rect.left + scrollLeft, + width: rect.width, + height: rect.height, + } + return bounds + } + + handleLoad() { + const bounds = this.getBoundingClientRect() + const box = this.props.crop || defaultState.box + this.setState({ bounds, box }) + } + + handleWindowResize() { + if (!this.imgRef) return + const bounds = this.getBoundingClientRect() + this.setState({ bounds }) + } + + handleMouseDown(e) { + e.preventDefault() + const bounds = this.getBoundingClientRect() + const mouseX = e.pageX + const mouseY = e.pageY + const x = (mouseX - bounds.left) / bounds.width + const y = (mouseY - bounds.top) / bounds.height + const w = 1 / bounds.width + const h = 1 / bounds.height + this.setState({ + dragging: true, + bounds, + mouseX, + mouseY, + box: { + x, y, w, h, + } + }) + } + + handleMouseDownOnBox(e) { + const bounds = this.getBoundingClientRect() + const mouseX = e.pageX + const mouseY = e.pageY + this.setState({ + draggingBox: true, + bounds, + mouseX, + mouseY, + initialBox: { + ...this.state.box + }, + box: { + ...this.state.box + } + }) + } + + handleMouseMove(e) { + const { + dragging, draggingBox, + bounds, mouseX, mouseY, initialBox, box + } = this.state + if (dragging) { + e.preventDefault() + let { x, y } = box + let dx = (e.pageX - mouseX) / bounds.width + let dy = (e.pageY - mouseY) / bounds.height + let w = clamp(dx, 0.0, 1.0 - x) + let h = clamp(dy, 0.0, 1.0 - y) + this.setState({ + box: { + x, y, w, h, + } + }) + } else if (draggingBox) { + e.preventDefault() + let { x, y, w, h } = initialBox + let dx = (e.pageX - mouseX) / bounds.width + let dy = (e.pageY - mouseY) / bounds.height + this.setState({ + box: { + x: clamp(x + dx, 0, 1.0 - w), + y: clamp(y + dy, 0, 1.0 - h), + w, + h, + } + }) + } + } + + handleMouseUp(e) { + const { onCrop } = this.props + const { dragging, draggingBox, bounds, box } = this.state + if (!dragging && !draggingBox) return + e.preventDefault() + const { x, y, w, h } = box + let url = window.location.pathname + this.setState({ + dragging: false, + draggingBox: false, + }) + if (w < 10 / bounds.width || h < 10 / bounds.height) { + this.setState({ box: { ...defaultState.box }}) + onCrop({}) + } else { + // pass the box dimensions up - do the search again + onCrop(box) + } + } + + render() { + const { url } = this.props + const { bounds, box } = this.state + const { x, y, w, h } = box + return ( + <div className="imageSelection"> + <img + src={url} + ref={ref => this.imgRef = ref} + onMouseDown={this.handleMouseDown} + onLoad={this.handleLoad.bind(this)} + crossOrigin='anonymous' + /> + {!!w && + <div + className="box" + style={{ + left: x * bounds.width, + top: y * bounds.height, + width: w * bounds.width, + height: h * bounds.height, + }} + onMouseDown={this.handleMouseDownOnBox} + /> + } + </div> + ) + } +} + +const boxToFixed = ({ x, y, w, h }) => ({ + x: x.toFixed(3), + y: y.toFixed(3), + w: w.toFixed(3), + h: h.toFixed(3), +}) diff --git a/animism-align/frontend/app/views/media/components/media.formVideo.js b/animism-align/frontend/app/views/media/components/media.formVideo.js new file mode 100644 index 0000000..c6b1cf9 --- /dev/null +++ b/animism-align/frontend/app/views/media/components/media.formVideo.js @@ -0,0 +1,111 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' +import VimeoPlayer from '@u-wave/react-vimeo' + +import { capitalize } from 'app/utils' +import { TextInput, LabelDescription, Select, TextArea, Checkbox, SubmitButton, Loader } from 'app/common' + +import { getVimeoMetadata } from 'app/views/media/media.actions' + +export default class MediaVideoForm extends Component { + state = { + } + + constructor(props) { + super(props) + this.handleSelect = this.handleSelect.bind(this) + this.handleChange = this.handleChange.bind(this) + this.handleSettingsChange = this.handleSettingsChange.bind(this) + } + + handleChange(e) { + let { name, value } = e.target + return this.handleSelect(name, value) + } + + handleSelect(name, value) { + value = value.trim() + if (name === 'url') { + getVimeoMetadata(value) + .then(data => { + console.log('video metadata', data) + this.props.onChange(name, value) + setTimeout(() => { + this.props.onSettingsChange('video', { + thumbnail_url: data.thumbnail_url, + duration: data.duration, + video_id: data.video_id, + }) + }, 20) + }) + } else { + this.props.onChange(name, value) + } + } + + handleSettingsChange(e) { + let { name, value } = e.target + this.props.onSettingsChange(name, value) + } + + handleSettingsSelect(name, value) { + this.props.onSettingsChange(name, value) + } + + render() { + const { data } = this.props + return ( + <div className='videoForm'> + <TextInput + title="Video URL" + name="url" + required + data={data} + onChange={this.handleChange} + autoComplete="off" + /> + + {data.url && + <div> + <LabelDescription className='video'> + <VimeoPlayer video={data.url} /> + </LabelDescription> + + {data.settings.video && data.settings.video.thumbnail && + <LabelDescription className='thumbnail'> + <img src={data.settings.video.thumbnail} /> + </LabelDescription> + } + + <TextInput + title="Start time" + name="video_start_time" + data={data.settings} + placeholder="0:00" + onChange={this.handleSettingsChange} + autoComplete="off" + /> + + <TextInput + title="End time" + name="video_end_time" + data={data.settings} + placeholder="0:00" + onChange={this.handleSettingsChange} + autoComplete="off" + /> + + <TextInput + title="Original duration" + name="original_duration" + data={data.settings} + placeholder="0:00" + onChange={this.handleSettingsChange} + autoComplete="off" + /> + </div> + } + </div> + ) + } +} diff --git a/animism-align/frontend/app/views/media/components/media.indexOptions.js b/animism-align/frontend/app/views/media/components/media.indexOptions.js new file mode 100644 index 0000000..09a0f74 --- /dev/null +++ b/animism-align/frontend/app/views/media/components/media.indexOptions.js @@ -0,0 +1,65 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import actions from 'app/actions' + +import { Select, Checkbox } from 'app/common' + +const thumbnailOptions = [ + { name: 'th', label: 'Thumbnails', }, + { name: 'sm', label: 'Small', }, + { name: 'md', label: 'Medium', }, + { name: 'lg', label: 'Large', }, + { name: 'orig', label: 'Original', }, +] + +const sortOptions = [ + { name: 'id-asc', label: 'Most recent' }, + { name: 'id-desc', label: 'Oldest first' }, + { name: 'username-asc', label: 'Username (A-Z)' }, + { name: 'username-desc', label: 'Username (Z-A)' }, + { name: 'author-asc', label: 'Author (A-Z)' }, + { name: 'author-desc', label: 'Author (Z-A)' }, + { name: 'title-asc', label: 'Title (A-Z)' }, + { name: 'title-desc', label: 'Title (Z-A)' }, + // { name: '-asc', label: '' }, + // { name: '-desc', label: '' }, + // { name: '-asc', label: '' }, + // { name: '-desc', label: '' }, + // { name: '-asc', label: '' }, + // { name: '-desc', label: '' }, +] + +class IndexOptions extends Component { + render() { + const { options } = this.props + return ( + <div className='row menubar'> + <div /> + <Select + name={'sort'} + options={sortOptions} + selected={options.sort} + onChange={actions.upload.updateOption} + /> + <Select + name={'thumbnailSize'} + options={thumbnailOptions} + selected={options.thumbnailSize} + onChange={actions.upload.updateOption} + /> + </div> + ) + } +} + +const mapStateToProps = state => ({ + options: state.upload.options, +}) + +const mapDispatchToProps = dispatch => ({ +}) + +export default connect(mapStateToProps, mapDispatchToProps)(IndexOptions) diff --git a/animism-align/frontend/app/views/media/components/media.menu.js b/animism-align/frontend/app/views/media/components/media.menu.js new file mode 100644 index 0000000..b782cdc --- /dev/null +++ b/animism-align/frontend/app/views/media/components/media.menu.js @@ -0,0 +1,58 @@ +import React, { Component } from 'react' +import { Route, Link } from 'react-router-dom' +import { connect } from 'react-redux' + +import { history } from 'app/store' +import actions from 'app/actions' +import { MenuButton, FileInput } from 'app/common' + +const mapStateToProps = state => ({ + media: state.media, +}) + +export default class MediaMenu extends Component { + render() { + return ( + <div className='menuButtons'> + <Route exact path='/media/:id/show/' component={MediaShowMenu} /> + <Route exact path='/media/:id/edit/' component={MediaEditMenu} /> + <Route exact path='/media/new/' component={MediaNewMenu} /> + <Route exact path='/media/' component={MediaIndexMenu} /> + </div> + ) + } +} + +const MediaIndexMenu = () => ([ + <MenuButton key='new' name="new" href="/media/new/" />, +]) + +const MediaShowMenu = connect(mapStateToProps)((props) => ([ + <MenuButton key='back' name="back" href="/media/" />, + <MenuButton key='edit' name="edit" href={"/media/" + props.match.params.id + "/edit/"} />, + <MenuButton key='delete' name="delete" onClick={() => { + const { res: media } = props.media.show + if (confirm("Really delete this media?")) { + actions.media.destroy(media).then(() => { + history.push('/media/') + }) + } + }} />, +])) + +const MediaNewMenu = (props) => ([ + <MenuButton key='back' name="back" href="/media/" />, +]) + +const MediaEditMenu = connect(mapStateToProps)((props) => ([ + <MenuButton key='back' name="back" href="/media/" />, + <MenuButton key='copy' name="copy" href={"/media/" + props.match.params.id + '/copy/'} label="Make a copy" />, + <MenuButton key='delete' name="delete" onClick={() => { + const { res: media } = props.media.show + if (confirm("Really delete this media?")) { + actions.media.destroy(media).then(() => { + history.push('/media/') + }) + } + }} />, +])) diff --git a/animism-align/frontend/app/views/media/containers/media.edit.js b/animism-align/frontend/app/views/media/containers/media.edit.js new file mode 100644 index 0000000..cf5f671 --- /dev/null +++ b/animism-align/frontend/app/views/media/containers/media.edit.js @@ -0,0 +1,57 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' +import { connect } from 'react-redux' + +import { history } from 'app/store' +import actions from 'app/actions' + +import { Loader } from 'app/common' + +import MediaForm from '../components/media.form' +import MediaMenu from '../components/media.menu' + +class MediaEdit extends Component { + componentDidMount() { + console.log(this.props.match.params.id) + actions.media.show(this.props.match.params.id) + } + + handleSubmit(data) { + actions.media.update(data) + .then(response => { + // response + console.log(response) + history.push('/media/') + }) + } + + render() { + const { show } = this.props.media + if (show.loading || !show.res) { + return ( + <div className='form'> + <Loader /> + </div> + ) + } + return ( + <div className='row formContainer'> + <MediaMenu mediaActions={this.props.mediaActions} /> + <MediaForm + data={show.res} + onSubmit={this.handleSubmit.bind(this)} + /> + </div> + ) + } +} + +const mapStateToProps = state => ({ + media: state.media, +}) + +const mapDispatchToProps = dispatch => ({ + // mediaActions: bindActionCreators({ ...mediaActions }, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(MediaEdit) diff --git a/animism-align/frontend/app/views/media/containers/media.index.js b/animism-align/frontend/app/views/media/containers/media.index.js new file mode 100644 index 0000000..eaf9db2 --- /dev/null +++ b/animism-align/frontend/app/views/media/containers/media.index.js @@ -0,0 +1,115 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import { formatDateTime } from 'app/utils' +import { MenuButton, SmallMenuButton, Loader } from 'app/common' +import actions from 'app/actions' + +import { thumbnailURL } from 'app/views/align/align.util' + +import MediaIndexOptions from '../components/media.indexOptions' +import MediaMenu from '../components/media.menu' + +// const { result, collectionLookup } = this.props + +class MediaIndex extends Component { + componentDidMount() { + // this.fetch(false) + } + + componentDidUpdate(prevProps) { + if (this.props.media.options.sort !== prevProps.media.options.sort) { + this.fetch(false) + } + } + + fetch(load_more) { + const { options, index } = this.props.media + const { order: index_order } = index + const [ sort, order ] = options.sort.split(' ') + actions.media.index({ + sort, order, limit: 5000, // offset: load_more ? index_order.length : 0, + }, load_more) + } + + render() { + const { mediaActions } = this.props + const { options } = this.props.media + const { loading, lookup, order } = this.props.media.index + if (loading) { + return ( + <section> + <MediaIndexOptions /> + <div className="row"> + {order && !!order.length && + <div className={'results ' + options.thumbnailSize}> + {order.map(id => <MediaItem key={id} data={lookup[id]} />)} + </div> + } + </div> + <Loader /> + </section> + ) + } + if (!lookup || !order.length) { + return ( + <section> + <MediaIndexOptions /> + <div className="row"> + <MediaMenu /> + <p className='gray'> + {"No media"} + </p> + </div> + </section> + ) + } + return ( + <section> + <MediaIndexOptions /> + <div className="row"> + <MediaMenu /> + <div className={'results ' + options.thumbnailSize}> + <h2>Images</h2> + {order.filter(id => lookup[id].type === 'image').map(id => <MediaItem key={id} data={lookup[id]} />)} + <h2>Video</h2> + {order.filter(id => lookup[id].type === 'video').map(id => <MediaItem key={id} data={lookup[id]} />)} + </div> + </div> + {order.length >= 50 && <button className='loadMore' onClick={() => this.fetch(true)}>Load More</button>} + </section> + ) + } +} + +const MediaItem = ({ data }) => { + // console.log(data) + return ( + <div className='cell'> + <div className='img'> + <Link to={"/media/" + data.id + "/edit/"}> + <img src={thumbnailURL(data)} alt={data.title} /> + </Link> + </div> + <div className='meta center'> + <div> + <i>{data.title}</i><br /> + {data.author}<br /> + {data.date} + </div> + </div> + </div> + ) +} + +const mapStateToProps = state => ({ + media: state.media, +}) + +const mapDispatchToProps = dispatch => ({ + // uploadActions: bindActionCreators({ ...uploadActions }, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(MediaIndex) diff --git a/animism-align/frontend/app/views/media/containers/media.new.js b/animism-align/frontend/app/views/media/containers/media.new.js new file mode 100644 index 0000000..c193c2f --- /dev/null +++ b/animism-align/frontend/app/views/media/containers/media.new.js @@ -0,0 +1,81 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' +import { connect } from 'react-redux' + +import { history } from 'app/store' +import actions from 'app/actions' + +import MediaForm from '../components/media.form' +import MediaMenu from '../components/media.menu' + +class MediaNew extends Component { + state = { + loading: true, + initialData: {}, + } + + componentDidMount() { + // console.log(this.props.match.params.id) + if (this.props.match.params && this.props.match.params.id) { + actions.media.show(this.props.match.params.id) + .then(data => { + const { id, ...initialData } = data.res + delete initialData.settings.video + delete initialData.settings.crop + delete initialData.settings.display + delete initialData.settings.fullsize + delete initialData.settings.thumbnail + delete initialData.settings.bibliography + console.log("copying", id) + this.setState({ + loading: false, + initialData, + }) + }) + } else { + this.setState({ loading: false }) + } + } + + handleSubmit(data) { + console.log(data) + actions.media.create(data) + .then(res => { + console.log(res) + if (res.res && res.res.id) { + history.push('/media/') + } + }) + .catch(err => { + console.error('error') + }) + } + + render() { + if (this.state.loading) { + return ( + <div className='row formContainer' /> + ) + } + return ( + <div className='row formContainer'> + <MediaMenu /> + <MediaForm + isNew + data={this.state.initialData} + onSubmit={this.handleSubmit.bind(this)} + /> + </div> + ) + } +} + +const mapStateToProps = state => ({ + media: state.media, +}) + +const mapDispatchToProps = dispatch => ({ + // uploadActions: bindActionCreators({ ...uploadActions }, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(MediaNew) diff --git a/animism-align/frontend/app/views/media/media.actions.js b/animism-align/frontend/app/views/media/media.actions.js new file mode 100644 index 0000000..a6d9bbd --- /dev/null +++ b/animism-align/frontend/app/views/media/media.actions.js @@ -0,0 +1,9 @@ +import * as types from 'app/types' +import { capitalize, api } from 'app/utils' + +export const getVimeoMetadata = url => { + return api(() => {}, types.vimeo, 'vimeo', 'https://vimeo.com/api/oembed.json', { url }) + .then(data => { + return data + }) +} diff --git a/animism-align/frontend/app/views/media/media.container.js b/animism-align/frontend/app/views/media/media.container.js new file mode 100644 index 0000000..b597a6c --- /dev/null +++ b/animism-align/frontend/app/views/media/media.container.js @@ -0,0 +1,38 @@ +import React, { Component } from 'react' +import { Route, Link } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import './media.css' + +import actions from 'app/actions' + +import MediaIndex from './containers/media.index' +// import MediaShow from './containers/media.show' +import MediaNew from './containers/media.new' +import MediaEdit from './containers/media.edit' + +class Container extends Component { + render() { + return ( + <div className='media'> + <Route exact path='/media/:id/copy/' component={MediaNew} /> + <Route exact path='/media/:id/edit/' component={MediaEdit} /> + <Route exact path='/media/new/' component={MediaNew} /> + <Route exact path='/media/' component={MediaIndex} /> + </div> + ) + } +} +/* + <Route exact path='/media/:id/show/' component={MediaShow} /> +*/ +const mapStateToProps = state => ({ + media: state.media, +}) + +const mapDispatchToProps = dispatch => ({ + // uploadActions: bindActionCreators({ ...uploadActions }, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(Container) diff --git a/animism-align/frontend/app/views/media/media.css b/animism-align/frontend/app/views/media/media.css new file mode 100644 index 0000000..a2d95c8 --- /dev/null +++ b/animism-align/frontend/app/views/media/media.css @@ -0,0 +1,70 @@ +.app > .media { + width: 100%; + height: calc(100% - 3.125rem); + overflow: scroll; +} + +.results .cell { + margin-bottom: 1rem; + margin-right: 1rem; +} +.results h2 { + display: block; + width: 100%; +} +.media .results .meta > div { + max-width: 100%; +} + +/* new / edit media forms */ + +.formContainer { + padding-top: 1rem; +} + +.imageForm, +.videoForm { + padding: 1rem 1rem 0.5rem 1rem; + margin: 1rem 0; + position: relative; + left: -1rem; + border-radius: 10px; +} + +/* image form */ + +.imageForm { + background: #315; +} +.imageForm .fileInput .row { + position: relative; +} + +/* video form */ + +.videoForm { + background: #314; +} +.videoForm .thumbnail img { + max-height: 200px; +} + +/* image crop */ + +.imageSelection { + width: 30rem; + position: relative; +} +.imageSelection img { + display: block; + max-width: 100%; + max-height: 20rem; +} +.imageSelection img.loading { + opacity: 0.5; +} +.imageSelection .box { + position: absolute; + background: rgba(255,32,64,0.05); + border: 1px solid #f24; +} diff --git a/animism-align/frontend/app/views/media/media.reducer.js b/animism-align/frontend/app/views/media/media.reducer.js new file mode 100644 index 0000000..f13f9de --- /dev/null +++ b/animism-align/frontend/app/views/media/media.reducer.js @@ -0,0 +1,22 @@ +import * as types from 'app/types' +import { session, getDefault, getDefaultInt } from 'app/session' + +import { crudState, crudReducer } from 'app/api/crud.reducer' + +const initialState = crudState('media', { + options: { + sort: 'author asc', + thumbnailSize: getDefault('upload.thumbnailSize', 'small'), + } +}) + +const reducer = crudReducer('media') + +export default function mediaReducer(state = initialState, action) { + // console.log(action.type, action) + state = reducer(state, action) + switch (action.type) { + default: + return state + } +} diff --git a/animism-align/frontend/app/views/nav/header.component.js b/animism-align/frontend/app/views/nav/header.component.js new file mode 100644 index 0000000..b03d833 --- /dev/null +++ b/animism-align/frontend/app/views/nav/header.component.js @@ -0,0 +1,42 @@ +import React from 'react' +// import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' +import { Link } from 'react-router-dom' + +import PlayButton from 'app/views/align/components/player/playButton.component' + +import './nav.css' + +function Header(props) { + return ( + <header> + <PlayButton /> + <div> + <Link to="/align">Align</Link> + <Link to="/paragraph">Transcript</Link> + <Link to="/media">Media</Link> + </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/app/views/nav/nav.css b/animism-align/frontend/app/views/nav/nav.css new file mode 100644 index 0000000..485ace2 --- /dev/null +++ b/animism-align/frontend/app/views/nav/nav.css @@ -0,0 +1,73 @@ +/* header */ + +header { + height: 3.125rem; + font-size: 0.875rem; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + background: rgba(32,16,64,0.8); + color: white; + z-index: 50; + position: relative; +} +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 > 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; +}
\ No newline at end of file diff --git a/animism-align/frontend/app/views/paragraph/components/paragraph.form.js b/animism-align/frontend/app/views/paragraph/components/paragraph.form.js new file mode 100644 index 0000000..751ec7f --- /dev/null +++ b/animism-align/frontend/app/views/paragraph/components/paragraph.form.js @@ -0,0 +1,87 @@ +import React, { Component } from 'react' +// import { Link } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import actions from 'app/actions' + +import { clamp, timestamp, capitalize } from 'app/utils' +import { Select } from 'app/common' + +const PARAGRAPH_TYPES = [ + 'paragraph', 'blockquote', 'hidden', +].map(name => ({ name, label: capitalize(name.replace('_', ' ')) })) + +class ParagraphForm extends Component { + constructor(props){ + super(props) + this.handleChange = this.handleChange.bind(this) + this.handleSelect = this.handleSelect.bind(this) + this.handleSubmit = this.handleSubmit.bind(this) + } + componentDidMount() { + if (this.textareaRef && this.textareaRef.current) { + this.textareaRef.current.focus() + } + } + handleChange(e) { + const { name, value } = e.target + this.handleSelect(name, value) + } + handleSelect(name, value) { + const { onUpdate, paragraph } = this.props + onUpdate({ + ...paragraph, + [name]: value, + }) + } + handleSubmit() { + const { paragraph, onClose } = this.props + actions.paragraph.update(paragraph) + .then(response => { + console.log(response) + onClose() + }) + } + render() { + const { paragraph, y } = this.props + return ( + <div + className='paragraphForm' + style={{ + top: y, + }} + > + {this.renderButtons()} + </div> + ) + } + renderButtons() { + const { paragraph } = this.props + return ( + <div className='row buttons'> + <div className='row'> + <Select + name='type' + selected={paragraph.type} + options={PARAGRAPH_TYPES} + defaultOption='text' + onChange={this.handleSelect} + /> + <div className='ts'>{timestamp(paragraph.start_ts, 1, true)}</div> + </div> + <div> + <button onClick={this.handleSubmit}>Save</button> + </div> + </div> + ) + } +} + +const mapStateToProps = state => ({ +}) + +const mapDispatchToProps = dispatch => ({ +}) + +export default connect(mapStateToProps, mapDispatchToProps)(ParagraphForm) diff --git a/animism-align/frontend/app/views/paragraph/components/paragraph.list.js b/animism-align/frontend/app/views/paragraph/components/paragraph.list.js new file mode 100644 index 0000000..1b8a0ac --- /dev/null +++ b/animism-align/frontend/app/views/paragraph/components/paragraph.list.js @@ -0,0 +1,165 @@ +import React, { Component } from 'react' +import { Route } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import { floatLT, floatLTE } from 'app/utils' +import actions from 'app/actions' +import ParagraphForm from '../components/paragraph.form' + +const MEDIA_TYPES = new Set([ + 'image', 'gallery', 'vitrine', + 'video', +]) + +class ParagraphList extends Component { + state = { + paragraphs: [], + currentParagraph: -1, + currentAnnotation: -1, + } + + componentDidMount() { + this.build() + } + + componentDidUpdate(prevProps) { + if (this.props.paragraph !== prevProps.paragraph) { + this.build() + } + if (this.props.audio.play_ts === prevProps.audio.play_ts) return + this.setCurrentParagraph() + } + + setCurrentParagraph() { + const { play_ts } = this.props.audio + const insideParagraph = this.state.paragraphs.some(paragraph => { + if (floatLTE(paragraph.start_ts, play_ts) && floatLT(play_ts, paragraph.end_ts)) { + this.setCurrentAnnotation(paragraph, play_ts) + return true + } + return false + }) + if (!insideParagraph) { + this.setState({ + currentParagraph: -1, + currentAnnotation: -1, + }) + } + } + + setCurrentAnnotation(paragraph, play_ts) { + const { id: currentParagraph, annotations } = paragraph + let currentAnnotation + let annotation + let i = 0 + let len = annotations.length + for (let i = 0; i < len - 1; i++) { + if (floatLT(play_ts, annotations[i+1].start_ts)) { + currentAnnotation = annotations[i].id + break + } + } + if (!currentAnnotation) { + currentAnnotation = annotations[len-1].id + } + this.setState({ currentParagraph, currentAnnotation }) + } + + build() { + const { order: annotationOrder, lookup: annotationLookup } = this.props.annotation + const { lookup: paragraphLookup } = this.props.paragraph + let currentParagraph = {} + const paragraphs = [] + // loop over the annotations in time order + annotationOrder.forEach((annotation_id, i) => { + const annotation = annotationLookup[annotation_id] + const paragraph = paragraphLookup[annotation.paragraph_id] + // if this annotation is media, insert it after the current paragraph + if (MEDIA_TYPES.has(annotation.type)) { + paragraphs.push({ + id: ('index_' + i), + type: annotation.type, + start_ts: annotation.start_ts, + end_ts: 0, + annotations: [annotation], + }) + return + } + // if this annotation is from a different paragraph, make a new paragraph + if (annotation.paragraph_id !== currentParagraph.id) { + const paragraph_type = getParagraphType(annotation, paragraph) + currentParagraph = { + id: annotation.paragraph_id || ('index_' + i), + type: paragraph_type, + start_ts: annotation.start_ts, + end_ts: 0, + annotations: [], + } + paragraphs.push(currentParagraph) + } + // if this annotation is a paragraph_end, set the end timestamp + if (annotation.type === 'paragraph_end') { + currentParagraph.end_ts = annotation.start_ts + } + // otherwise, just append this annotation to the paragraph + else { + currentParagraph.annotations.push(annotation) + } + }) + for (let i = 0; i < (paragraphs.length - 1); i++) { + if (!paragraphs[i].end_ts) { + paragraphs[i].end_ts = paragraphs[i+1].start_ts - 0.1 + } + } + this.setState({ paragraphs }) + } + + render() { + const { + media, paragraphElementLookup, selectedParagraph, + onAnnotationClick, onParagraphDoubleClick + } = this.props + const { paragraphs, currentParagraph, currentAnnotation } = this.state + return paragraphs.map(paragraph => { + if (selectedParagraph && selectedParagraph.id === paragraph.id) { + paragraph = selectedParagraph + } + if (paragraph.type in paragraphElementLookup) { + const ParagraphElement = paragraphElementLookup[paragraph.type] + return ( + <ParagraphElement + key={paragraph.id} + paragraph={paragraph} + media={media} + currentParagraph={paragraph.id === currentParagraph} + currentAnnotation={paragraph.id === currentParagraph && currentAnnotation} + onAnnotationClick={onAnnotationClick} + onDoubleClick={onParagraphDoubleClick} + /> + ) + } else { + return <div key={paragraph.id}>{'(waiting to implement' + paragraph.type + ')'}</div> + } + }) + } +} + +const getParagraphType = (annotation, paragraph) => { + if (!paragraph) { + return annotation.type + } + return paragraph.type +} + +const mapStateToProps = state => ({ + paragraph: state.paragraph.index, + annotation: state.annotation.index, + audio: state.audio, + media: state.media.index, +}) + +const mapDispatchToProps = dispatch => ({ +}) + +export default connect(mapStateToProps, mapDispatchToProps)(ParagraphList) diff --git a/animism-align/frontend/app/views/paragraph/components/paragraphTypes/index.js b/animism-align/frontend/app/views/paragraph/components/paragraphTypes/index.js new file mode 100644 index 0000000..62b4a49 --- /dev/null +++ b/animism-align/frontend/app/views/paragraph/components/paragraphTypes/index.js @@ -0,0 +1,22 @@ +import React from 'react' + +import { + Paragraph, ParagraphHeader +} from './paragraphTypes.text' + +import { + MediaVideo +} from './paragraphTypes.video' + +import { + MediaImage +} from './paragraphTypes.image' + +export const paragraphElementLookup = { + paragraph: React.memo(Paragraph), + hidden: React.memo(Paragraph), + blockquote: React.memo(Paragraph), + header: React.memo(ParagraphHeader), + video: React.memo(MediaVideo), + image: React.memo(MediaImage), +} diff --git a/animism-align/frontend/app/views/paragraph/components/paragraphTypes/paragraphTypes.image.js b/animism-align/frontend/app/views/paragraph/components/paragraphTypes/paragraphTypes.image.js new file mode 100644 index 0000000..36c72e9 --- /dev/null +++ b/animism-align/frontend/app/views/paragraph/components/paragraphTypes/paragraphTypes.image.js @@ -0,0 +1,17 @@ +import React, { Component } from 'react' + +export const MediaImage = ({ paragraph, media, currentParagraph, currentAnnotation, onAnnotationClick, onDoubleClick }) => { + if (!media.lookup) return <div /> + const className = currentParagraph ? 'media image current' : 'media image' + const annotation = paragraph.annotations[0] + const item = media.lookup[annotation.settings.media_id] + if (!item) return <div>Media not found: {annotation.settings.media_id}</div> + return ( + <div + className={className} + onDoubleClick={e => onDoubleClick(e, paragraph)} + > + <img src={item.settings.display.url} /> + </div> + ) +} diff --git a/animism-align/frontend/app/views/paragraph/components/paragraphTypes/paragraphTypes.text.js b/animism-align/frontend/app/views/paragraph/components/paragraphTypes/paragraphTypes.text.js new file mode 100644 index 0000000..c2ebcd7 --- /dev/null +++ b/animism-align/frontend/app/views/paragraph/components/paragraphTypes/paragraphTypes.text.js @@ -0,0 +1,35 @@ +import React, { Component } from 'react' + +export const Paragraph = ({ paragraph, currentParagraph, currentAnnotation, onAnnotationClick, onDoubleClick }) => { + let className = paragraph.type + if (className !== 'paragraph') className += ' paragraph' + if (currentParagraph) className += ' current' + return ( + <div + className={className} + onDoubleClick={e => onDoubleClick(e, paragraph)} + > + {paragraph.annotations.map(annotation => ( + <span + key={annotation.id} + className={annotation.id === currentAnnotation ? 'current' : ''} + onClick={e => onAnnotationClick(e, paragraph, annotation)} + dangerouslySetInnerHTML={{ __html: ' ' + annotation.text + ' ' }} + /> + ))} + </div> + ) +} + +export const ParagraphHeader = ({ paragraph, currentParagraph, currentAnnotation, onAnnotationClick, onDoubleClick }) => { + let className = currentParagraph ? 'header current' : 'header' + const text = paragraph.annotations.map(annotation => annotation.text).join(' ') + return ( + <div + className={className} + onDoubleClick={e => onDoubleClick(e, paragraph)} + > + {text} + </div> + ) +} diff --git a/animism-align/frontend/app/views/paragraph/components/paragraphTypes/paragraphTypes.video.js b/animism-align/frontend/app/views/paragraph/components/paragraphTypes/paragraphTypes.video.js new file mode 100644 index 0000000..423864b --- /dev/null +++ b/animism-align/frontend/app/views/paragraph/components/paragraphTypes/paragraphTypes.video.js @@ -0,0 +1,19 @@ +import React, { Component } from 'react' + +import VimeoPlayer from '@u-wave/react-vimeo' + +export const MediaVideo = ({ paragraph, media, currentParagraph, currentAnnotation, onAnnotationClick, onDoubleClick }) => { + if (!media.lookup) return <div /> + const className = currentParagraph ? 'media current' : 'media' + const annotation = paragraph.annotations[0] + const item = media.lookup[annotation.settings.media_id] + if (!item) return <div>Media not found: {annotation.settings.media_id}</div> + return ( + <div + className={className} + onDoubleClick={e => onDoubleClick(e, paragraph)} + > + <VimeoPlayer video={item.url} muted width="650" /> + </div> + ) +} diff --git a/animism-align/frontend/app/views/paragraph/containers/paragraphEditor.container.js b/animism-align/frontend/app/views/paragraph/containers/paragraphEditor.container.js new file mode 100644 index 0000000..12ab618 --- /dev/null +++ b/animism-align/frontend/app/views/paragraph/containers/paragraphEditor.container.js @@ -0,0 +1,85 @@ +import React, { Component } from 'react' +import { Route } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import actions from 'app/actions' +import ParagraphForm from '../components/paragraph.form' +import ParagraphList from '../components/paragraph.list' +import { paragraphElementLookup } from '../components/paragraphTypes' + +class ParagraphEditor extends Component { + state = { + selectedParagraph: null, + selectedParagraphOffset: 0, + } + + constructor(props) { + super(props) + this.handleAnnotationClick = this.handleAnnotationClick.bind(this) + this.handleParagraphDoubleClick = this.handleParagraphDoubleClick.bind(this) + this.handleCloseParagraphForm = this.handleCloseParagraphForm.bind(this) + this.updateSelectedParagraph = this.updateSelectedParagraph.bind(this) + } + + handleAnnotationClick(e, paragraph, annotation){ + actions.audio.seek(annotation.start_ts) + } + + handleParagraphDoubleClick(e, paragraph) { + console.log(e.target.parentNode) + let paragraphNode = e.target + if (!paragraphNode.classList.contains('paragraph')) { + paragraphNode = paragraphNode.parentNode + } + this.setState({ + selectedParagraph: { ...paragraph }, + selectedParagraphOffset: paragraphNode.offsetTop + }) + } + + updateSelectedParagraph(selectedParagraph) { + this.setState({ selectedParagraph }) + } + + handleCloseParagraphForm() { + this.setState({ selectedParagraph: null }) + } + + render() { + // const { media } = this.props + const { paragraphs, selectedParagraph, currentParagraph, currentAnnotation } = this.state + return ( + <div className='paragraphs'> + <div className='content'> + <ParagraphList + paragraphElementLookup={paragraphElementLookup} + selectedParagraph={selectedParagraph} + onAnnotationClick={this.handleAnnotationClick} + onParagraphDoubleClick={this.handleParagraphDoubleClick} + /> + {selectedParagraph && + <ParagraphForm + paragraph={selectedParagraph} + onUpdate={this.updateSelectedParagraph} + onClose={this.handleCloseParagraphForm} + y={selectedParagraphOffset} + /> + } + </div> + </div> + ) + } +} + +const mapStateToProps = state => ({ + // paragraph: state.paragraph.index, + // annotation: state.annotation.index, + // audio: state.audio, + // media: state.media.index, +}) + +const mapDispatchToProps = dispatch => ({ +}) + +export default connect(mapStateToProps, mapDispatchToProps)(ParagraphEditor) diff --git a/animism-align/frontend/app/views/paragraph/paragraph.container.js b/animism-align/frontend/app/views/paragraph/paragraph.container.js new file mode 100644 index 0000000..ea1c797 --- /dev/null +++ b/animism-align/frontend/app/views/paragraph/paragraph.container.js @@ -0,0 +1,69 @@ +import React, { Component } from 'react' +import { Route } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import './paragraph.css' + +import actions from 'app/actions' +import { Loader } from 'app/common' + +import ParagraphEditor from './containers/paragraphEditor.container' + +class ParagraphContainer extends Component { + componentDidMount() { + this.bind() + } + componentWillUnmount() { + this.unbind() + } + bind() { + document.addEventListener('keydown', this.handleKeydown) + } + unbind() { + document.removeEventListener('keydown', this.handleKeydown) + } + handleKeydown(e) { + if (document.activeElement !== document.body) { + return + } + // console.log(e.keyCode) + switch (e.keyCode) { + case 32: // spacebar + e.preventDefault() + actions.audio.toggle() + break + case 37: // left + case 38: // up + e.preventDefault() + actions.audio.jump(-5.0) + break + case 39: // right + case 40: // down + e.preventDefault() + actions.audio.jump(5.0) + break + } + } + render() { + if (!this.props.annotation.lookup || !this.props.paragraph.lookup) { + return <div className='body loading'><Loader /></div> + } + return ( + <div className='body'> + <ParagraphEditor /> + </div> + ) + } +} + +const mapStateToProps = state => ({ + paragraph: state.paragraph.index, + annotation: state.annotation.index, +}) + +const mapDispatchToProps = dispatch => ({ + // alignActions: bindActionCreators({ ...alignActions }, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(ParagraphContainer) diff --git a/animism-align/frontend/app/views/paragraph/paragraph.css b/animism-align/frontend/app/views/paragraph/paragraph.css new file mode 100644 index 0000000..8cd502c --- /dev/null +++ b/animism-align/frontend/app/views/paragraph/paragraph.css @@ -0,0 +1,93 @@ +.paragraphs { + width: 100%; + height: calc(100% - 3.125rem); + overflow: scroll; + background: white; + color: black; + padding: 1rem; +} + +/* general paragraph styles */ + +.paragraphs .content { + font-family: 'Georgia', serif; + width: 650px; + margin: 0 auto; + padding-bottom: 6rem; + position: relative; +} + +.paragraphs .content > div { + margin-bottom: 16px; +} + +/* paragraph subtypes */ + +.paragraphs .header { + font-size: 32px; +} + +.paragraphs .paragraph { + font-size: 16px; + line-height: 24px; +} + +.paragraphs .blockquote { + padding-left: 3rem; +} + +.paragraphs .hidden { + opacity: 0.5; +} + +/* media image */ + +.paragraphs .media.image img { + width: 100%; +} + +/* current paragraph */ + +.paragraphs .paragraph.current { + background: rgba(0,0,0,0.0); +} + +/* sentences */ + +.paragraphs span { + margin-right: 4px; + cursor: pointer; +} + +.paragraphs .paragraph .current { + box-shadow: -2px -3px 0 #fff, + 2px -3px 0 #fff, + -2px 3px 0 #fff, + 2px 3px 0 #fff; + box-decoration-break: clone; + background: black; + color: white; +} + +/* paragraph form */ + +.paragraphForm { + position: absolute; + right: -305px; + width: 300px; + padding: 0.5rem; + background: #ddd; + box-shadow: 2px 2px 4px rgba(0,0,0,0.2); +} +.paragraphForm .select div { + color: #ddd; + font-family: 'Roboto', sans-serif; +} +.paragraphForm .row { + justify-content: space-between; + align-items: center; +} +.paragraphForm .row > div { + display: flex; + align-items: center; +} diff --git a/animism-align/frontend/app/views/paragraph/paragraph.reducer.js b/animism-align/frontend/app/views/paragraph/paragraph.reducer.js new file mode 100644 index 0000000..c3babc8 --- /dev/null +++ b/animism-align/frontend/app/views/paragraph/paragraph.reducer.js @@ -0,0 +1,20 @@ +import * as types from 'app/types' +import { session, getDefault, getDefaultInt } from 'app/session' + +import { crudState, crudReducer } from 'app/api/crud.reducer' + +const initialState = crudState('paragraph', { + options: { + } +}) + +const reducer = crudReducer('paragraph') + +export default function paragraphReducer(state = initialState, action) { + // console.log(action.type, action) + state = reducer(state, action) + switch (action.type) { + default: + return state + } +} diff --git a/animism-align/frontend/app/views/site/component.template.js b/animism-align/frontend/app/views/site/component.template.js new file mode 100644 index 0000000..5b22206 --- /dev/null +++ b/animism-align/frontend/app/views/site/component.template.js @@ -0,0 +1,28 @@ +import React, { Component } from 'react' +// import { Link } from 'react-router-dom' +// import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import actions from 'app/actions' +// import * as uploadActions from './upload.actions' + +class ComponentTemplate extends Component { + componentDidMount() { + } + render() { + const { } = this.props + return ( + <div className=""> + </div> + ) + } +} + +const mapStateToProps = state => ({ +}) + +const mapDispatchToProps = dispatch => ({ + // uploadActions: bindActionCreators({ ...uploadActions }, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(ComponentTemplate) diff --git a/animism-align/frontend/app/views/site/site.actions.js b/animism-align/frontend/app/views/site/site.actions.js new file mode 100644 index 0000000..b894c64 --- /dev/null +++ b/animism-align/frontend/app/views/site/site.actions.js @@ -0,0 +1,14 @@ +import * as types from 'app/types' +import { api, post, pad, preloadImage } from 'app/utils' + +export const loadPeaks = (asdf) => dispatch => { + api(dispatch, types.peaks, 'peaks', '/static/data_store/peaks/peaks.json') +} + +export const loadText = (asdf) => dispatch => { + api(dispatch, types.text, 'text', '/static/data_store/peaks/text.txt') +} + +export const updateText = text => dispatch => { + dispatch({ type: types.text.loaded, data: text }) +}
\ No newline at end of file diff --git a/animism-align/frontend/app/views/site/site.reducer.js b/animism-align/frontend/app/views/site/site.reducer.js new file mode 100644 index 0000000..fbb53fc --- /dev/null +++ b/animism-align/frontend/app/views/site/site.reducer.js @@ -0,0 +1,24 @@ +import * as types from 'app/types' + +const initialState = { + peaks: { loading: true }, + text: { loading: true }, +} + +export default function siteReducer(state = initialState, action) { + // console.log(action.type, action) + switch (action.type) { + case types.peaks.loaded: + return { + ...state, + peaks: action.data, + } + case types.text.loaded: + return { + ...state, + text: action.data, + } + default: + return state + } +} diff --git a/animism-align/frontend/app/views/upload/components/upload.form.js b/animism-align/frontend/app/views/upload/components/upload.form.js new file mode 100644 index 0000000..e35bfaa --- /dev/null +++ b/animism-align/frontend/app/views/upload/components/upload.form.js @@ -0,0 +1,16 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' + +import { MenuButton, FileInput } from 'app/common' + +export default class UploadForm extends Component { + render() { + return ( + <div className='uploadForm'> + <MenuButton name="upload" label={false}> + <FileInput onChange={this.props.uploadActions.upload} /> + </MenuButton> + </div> + ) + } +} diff --git a/animism-align/frontend/app/views/upload/components/upload.index.js b/animism-align/frontend/app/views/upload/components/upload.index.js new file mode 100644 index 0000000..d60231d --- /dev/null +++ b/animism-align/frontend/app/views/upload/components/upload.index.js @@ -0,0 +1,98 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' + +import { uploadUri, formatDateTime } from 'app/utils' +import { MenuButton, SmallMenuButton, Loader } from 'app/common' +import actions from 'app/actions' + +import UploadIndexOptions from './upload.indexOptions' +import UploadMenu from './upload.menu' + +// const { result, collectionLookup } = this.props + +export default class UploadIndex extends Component { + componentDidMount() { + this.fetch(false) + } + + componentDidUpdate(prevProps) { + if (this.props.upload.options.sort !== prevProps.upload.options.sort) { + this.fetch(false) + } + } + + fetch(load_more) { + const { options, index } = this.props.upload + const { order: index_order } = index + const [ sort, order ] = options.sort.split('-') + actions.upload.index({ + sort, order, limit: 50, offset: load_more ? index_order.length : 0, + }, load_more) + } + + render() { + const { uploadActions } = this.props + const { options } = this.props.upload + const { loading, lookup, order } = this.props.upload.index + if (loading) { + return ( + <section> + <UploadIndexOptions /> + <div className="row"> + {order && !!order.length && + <div className={'results ' + options.thumbnailSize}> + {order.map(id => <UploadItem key={id} data={lookup[id]} />)} + </div> + } + </div> + <Loader /> + </section> + ) + } + if (!lookup || !order.length) { + return ( + <section> + <UploadIndexOptions /> + <div className="row"> + <UploadMenu uploadActions={uploadActions} /> + <p className='gray'> + {"No uploads"} + </p> + </div> + </section> + ) + } + return ( + <section> + <UploadIndexOptions /> + <div className="row"> + <UploadMenu uploadActions={uploadActions} /> + <div className={'results ' + options.thumbnailSize}> + {order.map(id => <UploadItem key={id} data={lookup[id]} />)} + </div> + </div> + {order.length >= 50 && <button className='loadMore' onClick={() => this.fetch(true)}>Load More</button>} + </section> + ) + } +} + +const UploadItem = ({ data }) => { + // console.log(data) + // const imageUri = uploadUri(data) + return ( + <div className='cell'> + <div className='img'> + <Link to={"/upload/" + data.id + "/show/"}> + <img src={data.url} alt={"Uploaded image"} /> + </Link> + </div> + <div className='meta center'> + <div> + {formatDateTime(data.created_at)} + </div> + </div> + </div> + ) +} + diff --git a/animism-align/frontend/app/views/upload/components/upload.indexOptions.js b/animism-align/frontend/app/views/upload/components/upload.indexOptions.js new file mode 100644 index 0000000..75fdffc --- /dev/null +++ b/animism-align/frontend/app/views/upload/components/upload.indexOptions.js @@ -0,0 +1,61 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import actions from 'app/actions' + +import { Select, Checkbox } from 'app/common' + +const thumbnailOptions = [ + { name: 'th', label: 'Thumbnails', }, + { name: 'sm', label: 'Small', }, + { name: 'md', label: 'Medium', }, + { name: 'lg', label: 'Large', }, + { name: 'orig', label: 'Original', }, +] + +const sortOptions = [ + { name: 'id-asc', label: 'Most recent' }, + { name: 'id-desc', label: 'Oldest first' }, + { name: 'username-asc', label: 'Username (A-Z)' }, + { name: 'username-desc', label: 'Username (Z-A)' }, + // { name: '-asc', label: '' }, + // { name: '-desc', label: '' }, + // { name: '-asc', label: '' }, + // { name: '-desc', label: '' }, + // { name: '-asc', label: '' }, + // { name: '-desc', label: '' }, +] + +class IndexOptions extends Component { + render() { + const { options } = this.props + return ( + <div className='row menubar'> + <div /> + <Select + name={'sort'} + options={sortOptions} + selected={options.sort} + onChange={actions.upload.updateOption} + /> + <Select + name={'thumbnailSize'} + options={thumbnailOptions} + selected={options.thumbnailSize} + onChange={actions.upload.updateOption} + /> + </div> + ) + } +} + +const mapStateToProps = state => ({ + options: state.upload.options, +}) + +const mapDispatchToProps = dispatch => ({ +}) + +export default connect(mapStateToProps, mapDispatchToProps)(IndexOptions) diff --git a/animism-align/frontend/app/views/upload/components/upload.menu.js b/animism-align/frontend/app/views/upload/components/upload.menu.js new file mode 100644 index 0000000..485d06f --- /dev/null +++ b/animism-align/frontend/app/views/upload/components/upload.menu.js @@ -0,0 +1,18 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' + +import { MenuButton, FileInput } from 'app/common' + +import actions from 'app/actions' + +export default class UploadMenu extends Component { + render() { + return ( + <div className='menuButtons'> + <MenuButton name="upload"> + <FileInput onChange={this.props.uploadActions.upload} /> + </MenuButton> + </div> + ) + } +} diff --git a/animism-align/frontend/app/views/upload/components/upload.show.js b/animism-align/frontend/app/views/upload/components/upload.show.js new file mode 100644 index 0000000..0498cac --- /dev/null +++ b/animism-align/frontend/app/views/upload/components/upload.show.js @@ -0,0 +1,69 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' +import { connect } from 'react-redux' + +import actions from 'app/actions' +import { formatDate, formatTime, formatAge, uploadUri } from 'app/utils' +import { history } from 'app/store' +import { Loader, MenuButton } from 'app/common' + +class UploadShow extends Component { + componentDidMount() { + actions.upload.show(this.props.match.params.id) + } + + componentDidUpdate(prevProps) { + if (prevProps.match.params.id !== this.props.match.params.id) { + actions.upload.show(this.props.match.params.id) + } + } + + handleDestroy() { + const { res: data } = this.props.upload.show + if (confirm("Really delete this upload?")) { + actions.upload.destroy(data).then(() => { + history.push('/upload/') + }) + } + } + + render() { + const { show, destroy } = this.props.upload + if (show.loading || destroy.loading) { + return <Loader /> + } + if (!show.loading && !show.res || show.not_found) { + return <div className='gray'>Upload {this.props.match.params.id} not found</div> + } + const { res: data } = show + return ( + <section className="row uploadShow"> + <div className="menuButtons"> + <MenuButton name="delete" onClick={this.handleDestroy.bind(this)} /> + </div> + <div> + <img src={data.url} /> + <div className='byline'> + {'Uploaded by '} + {data.username} + {' on '} + {formatDate(data.created_at)} + {' at '} + {formatTime(data.created_at)} + {'. '} + </div> + </div> + </section> + ) + } +} + +const mapStateToProps = state => ({ + upload: state.upload, +}) + +const mapDispatchToProps = dispatch => ({ + // searchActions: bindActionCreators({ ...searchActions }, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(UploadShow) diff --git a/animism-align/frontend/app/views/upload/upload.actions.js b/animism-align/frontend/app/views/upload/upload.actions.js new file mode 100644 index 0000000..73c8e16 --- /dev/null +++ b/animism-align/frontend/app/views/upload/upload.actions.js @@ -0,0 +1,18 @@ +import * as types from 'app/types' +import { store, history } from 'app/store' +import { api, post, pad, preloadImage } from 'app/utils' +import actions from 'app/actions' +import { session } from 'app/session' + +export const upload = (image, tag='upload') => dispatch => { + const formData = { + image, + tag, + username: 'animism', // session('username'), + } + // console.log(formData) + return actions.upload.upload(formData).then(data => { + // console.log(data.res) + return data.res + }) +} diff --git a/animism-align/frontend/app/views/upload/upload.container.js b/animism-align/frontend/app/views/upload/upload.container.js new file mode 100644 index 0000000..7753711 --- /dev/null +++ b/animism-align/frontend/app/views/upload/upload.container.js @@ -0,0 +1,36 @@ +import React, { Component } from 'react' +import { Route, Link } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import './upload.css' + +import actions from 'app/actions' +import * as uploadActions from './upload.actions' + +import UploadMenu from './components/upload.menu' +import UploadIndex from './components/upload.index' +import UploadShow from './components/upload.show' + +class Container extends Component { + render() { + return ( + <div className='row upload'> + <div> + <Route exact path='/upload/:id/show/' component={UploadShow} /> + <UploadIndex {...this.props} /> + </div> + </div> + ) + } +} + +const mapStateToProps = state => ({ + upload: state.upload, +}) + +const mapDispatchToProps = dispatch => ({ + uploadActions: bindActionCreators({ ...uploadActions }, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(Container) diff --git a/animism-align/frontend/app/views/upload/upload.css b/animism-align/frontend/app/views/upload/upload.css new file mode 100644 index 0000000..28ce33d --- /dev/null +++ b/animism-align/frontend/app/views/upload/upload.css @@ -0,0 +1,182 @@ +.uploadShow { + margin-top: 1rem; +} +.uploadShow img { + max-width: 30rem; + max-height: 20rem; +} +.upload { + height: 100%; +} +.upload > div:last-child { + flex: 1; + width: 100%; +} + +/* results */ + +.resultsContainer { +} +.results { + display: flex; + flex-flow: row wrap; + justify-content: flex-start; + align-items: flex-end; +} +.results .result { + display: inline-block; + margin-right: 1.125rem; + margin-bottom: 1.125rem; +} +.result > a { + display: block; +} +.result > a > div { + position: relative; +} +.result img { + max-width: 100%; + display: block; + cursor: pointer; +} +.result > a { + border: 2px solid transparent; +} +.results .active img, +.desktop .result > a:hover { + border-color: #11f; +} +.results.th .result { + max-width: 10rem; +} +.results.sm .result { + max-width: 20rem; +} +.results.md .result { + max-width: 40rem; +} +.results.lg .result { + max-width: 80rem; +} +.results.orig .result { + max-width: 100%; +} +.results.th img { + max-height: 120px; +} +.results.sm img { + max-height: 160px; +} +.results.md img { + max-height: 480px; +} +.results.lg img { + max-height: 960px; +} +.results.orig img { + max-width: none; + max-height: none; +} +.results .img { + width: 100%; +} +.results .img > a { + display: inline-block; + position: relative; +} +.results .meta { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: baseline; + font-size: 0.75rem; + color: #888; + padding: 0.125rem; +} +.results .meta.center, +.row.center { + align-items: center; +} +.results .meta > div { + overflow: hidden; + max-width: 75%; + text-overflow: ellipsis; + white-space: nowrap; +} +.results .meta > span { + padding-right: 0.125rem; +} +.results .meta .buttons { +} +.score.good { + color: #11f; + font-weight: bold; +} +.score.ok { + color: #44d; + font-weight: bold; +} +.score.poor { + color: #66b; +} +.score { + color: #888; +} +.resultGroup { + display: flex; + flex-direction: row; + flex-wrap: wrap; + position: relative; + border: 2px solid #888; + max-width: 20rem; + margin-right: 1.5rem; + margin-bottom: 1.5rem; + background: #fff; + box-shadow: 0 2px 4px #888; +} +.resultGroup .sha256 { + position: absolute; + background: white; + padding: 0.25rem 0.25rem 0rem 0.25rem; + left: 0.25rem; + top: -0.75rem; + font-size: 0.75rem; + color: #333; + text-transform: uppercase; + max-width: 95%; + overflow: hidden; + text-overflow: ellipsis; +} +.results.grouped { + background: #fff; +} +.results.grouped.sm, +.results.grouped.md, +.results.grouped.lg, +.results.grouped.orig { + flex-flow: column nowrap; +} +.results.th .resultGroup { + max-width: 33.5rem; +} +.results.sm .resultGroup { + max-width: 56rem; +} +.results.md .resultGroup { + max-width: 79rem; +} +.results.lg .resultGroup { + max-width: 100%; +} +.results.orig .resultGroup { + max-width: 100%; +} +.results .resultGroup .result { + margin: 0.5rem; +} +.loadMore { + width: 100%; +} +.loadMore button { + width: 100%; +} diff --git a/animism-align/frontend/app/views/upload/upload.reducer.js b/animism-align/frontend/app/views/upload/upload.reducer.js new file mode 100644 index 0000000..818be88 --- /dev/null +++ b/animism-align/frontend/app/views/upload/upload.reducer.js @@ -0,0 +1,22 @@ +import * as types from 'app/types' +import { session, getDefault, getDefaultInt } from 'app/session' + +import { crudState, crudReducer } from 'app/api/crud.reducer' + +const initialState = crudState('upload', { + options: { + sort: getDefault('upload.sort', 'id-desc'), + thumbnailSize: getDefault('upload.thumbnailSize', 'small'), + } +}) + +const reducer = crudReducer('upload') + +export default function uploadReducer(state = initialState, action) { + // console.log(action.type, action) + state = reducer(state, action) + switch (action.type) { + default: + return state + } +} |
