summaryrefslogtreecommitdiff
path: root/animism-align/frontend/app
diff options
context:
space:
mode:
Diffstat (limited to 'animism-align/frontend/app')
-rw-r--r--animism-align/frontend/app/actions.js30
-rw-r--r--animism-align/frontend/app/api/crud.actions.js51
-rw-r--r--animism-align/frontend/app/api/crud.fetch.js105
-rw-r--r--animism-align/frontend/app/api/crud.reducer.js194
-rw-r--r--animism-align/frontend/app/api/crud.types.js35
-rw-r--r--animism-align/frontend/app/api/crud.upload.js115
-rw-r--r--animism-align/frontend/app/api/index.js17
-rw-r--r--animism-align/frontend/app/common/app.css354
-rw-r--r--animism-align/frontend/app/common/copyToClipboardButton.component.js24
-rw-r--r--animism-align/frontend/app/common/fonts.css55
-rw-r--r--animism-align/frontend/app/common/form.component.js222
-rw-r--r--animism-align/frontend/app/common/form.css327
-rw-r--r--animism-align/frontend/app/common/imageCrop.component.js41
-rw-r--r--animism-align/frontend/app/common/index.js31
-rw-r--r--animism-align/frontend/app/common/loader.css125
-rw-r--r--animism-align/frontend/app/common/menubutton.component.js133
-rw-r--r--animism-align/frontend/app/common/miscellaneous.component.js82
-rw-r--r--animism-align/frontend/app/common/miscellaneous.css18
-rw-r--r--animism-align/frontend/app/common/modal.component.js9
-rw-r--r--animism-align/frontend/app/common/modal.css20
-rw-r--r--animism-align/frontend/app/common/slider.component.js115
-rw-r--r--animism-align/frontend/app/common/table.component.js128
-rw-r--r--animism-align/frontend/app/common/table.css96
-rw-r--r--animism-align/frontend/app/common/tableIndex.component.js129
-rw-r--r--animism-align/frontend/app/common/upload.css26
-rw-r--r--animism-align/frontend/app/common/upload.helpers.js192
-rw-r--r--animism-align/frontend/app/common/uploadImage.component.js74
-rw-r--r--animism-align/frontend/app/constants.js34
-rw-r--r--animism-align/frontend/app/router.js42
-rw-r--r--animism-align/frontend/app/session.js19
-rw-r--r--animism-align/frontend/app/store.js52
-rw-r--r--animism-align/frontend/app/types.js31
-rw-r--r--animism-align/frontend/app/utils/index.js371
-rw-r--r--animism-align/frontend/app/views/align/align.actions.js93
-rw-r--r--animism-align/frontend/app/views/align/align.container.js29
-rw-r--r--animism-align/frontend/app/views/align/align.css215
-rw-r--r--animism-align/frontend/app/views/align/align.reducer.js85
-rw-r--r--animism-align/frontend/app/views/align/align.util.js65
-rw-r--r--animism-align/frontend/app/views/align/components/annotations/annotation.form.js182
-rw-r--r--animism-align/frontend/app/views/align/components/annotations/annotation.index.js126
-rw-r--r--animism-align/frontend/app/views/align/components/annotations/annotationForms/annotationForm.image.js27
-rw-r--r--animism-align/frontend/app/views/align/components/annotations/annotationForms/annotationForm.video.js27
-rw-r--r--animism-align/frontend/app/views/align/components/annotations/annotationForms/index.js12
-rw-r--r--animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.image.js33
-rw-r--r--animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.text.js49
-rw-r--r--animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.util.js28
-rw-r--r--animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.video.js33
-rw-r--r--animism-align/frontend/app/views/align/components/annotations/annotationTypes/index.js22
-rw-r--r--animism-align/frontend/app/views/align/components/player/playButton.component.js31
-rw-r--r--animism-align/frontend/app/views/align/components/timeline/cursor.component.js26
-rw-r--r--animism-align/frontend/app/views/align/components/timeline/playCursor.component.js36
-rw-r--r--animism-align/frontend/app/views/align/components/timeline/ticks.component.js88
-rw-r--r--animism-align/frontend/app/views/align/components/timeline/waveform.component.js101
-rw-r--r--animism-align/frontend/app/views/align/containers/annotations.container.js40
-rw-r--r--animism-align/frontend/app/views/align/containers/script.container.js33
-rw-r--r--animism-align/frontend/app/views/align/containers/timeline.container.js171
-rw-r--r--animism-align/frontend/app/views/annotation/annotation.reducer.js20
-rw-r--r--animism-align/frontend/app/views/audio/audio.actions.js40
-rw-r--r--animism-align/frontend/app/views/audio/audio.reducer.js30
-rw-r--r--animism-align/frontend/app/views/index.js4
-rw-r--r--animism-align/frontend/app/views/media/components/media.form.js270
-rw-r--r--animism-align/frontend/app/views/media/components/media.formImage.js172
-rw-r--r--animism-align/frontend/app/views/media/components/media.formImageSelection.js213
-rw-r--r--animism-align/frontend/app/views/media/components/media.formVideo.js111
-rw-r--r--animism-align/frontend/app/views/media/components/media.indexOptions.js65
-rw-r--r--animism-align/frontend/app/views/media/components/media.menu.js58
-rw-r--r--animism-align/frontend/app/views/media/containers/media.edit.js57
-rw-r--r--animism-align/frontend/app/views/media/containers/media.index.js115
-rw-r--r--animism-align/frontend/app/views/media/containers/media.new.js81
-rw-r--r--animism-align/frontend/app/views/media/media.actions.js9
-rw-r--r--animism-align/frontend/app/views/media/media.container.js38
-rw-r--r--animism-align/frontend/app/views/media/media.css70
-rw-r--r--animism-align/frontend/app/views/media/media.reducer.js22
-rw-r--r--animism-align/frontend/app/views/nav/header.component.js42
-rw-r--r--animism-align/frontend/app/views/nav/nav.css73
-rw-r--r--animism-align/frontend/app/views/paragraph/components/paragraph.form.js87
-rw-r--r--animism-align/frontend/app/views/paragraph/components/paragraph.list.js165
-rw-r--r--animism-align/frontend/app/views/paragraph/components/paragraphTypes/index.js22
-rw-r--r--animism-align/frontend/app/views/paragraph/components/paragraphTypes/paragraphTypes.image.js17
-rw-r--r--animism-align/frontend/app/views/paragraph/components/paragraphTypes/paragraphTypes.text.js35
-rw-r--r--animism-align/frontend/app/views/paragraph/components/paragraphTypes/paragraphTypes.video.js19
-rw-r--r--animism-align/frontend/app/views/paragraph/containers/paragraphEditor.container.js85
-rw-r--r--animism-align/frontend/app/views/paragraph/paragraph.container.js69
-rw-r--r--animism-align/frontend/app/views/paragraph/paragraph.css93
-rw-r--r--animism-align/frontend/app/views/paragraph/paragraph.reducer.js20
-rw-r--r--animism-align/frontend/app/views/site/component.template.js28
-rw-r--r--animism-align/frontend/app/views/site/site.actions.js14
-rw-r--r--animism-align/frontend/app/views/site/site.reducer.js24
-rw-r--r--animism-align/frontend/app/views/upload/components/upload.form.js16
-rw-r--r--animism-align/frontend/app/views/upload/components/upload.index.js98
-rw-r--r--animism-align/frontend/app/views/upload/components/upload.indexOptions.js61
-rw-r--r--animism-align/frontend/app/views/upload/components/upload.menu.js18
-rw-r--r--animism-align/frontend/app/views/upload/components/upload.show.js69
-rw-r--r--animism-align/frontend/app/views/upload/upload.actions.js18
-rw-r--r--animism-align/frontend/app/views/upload/upload.container.js36
-rw-r--r--animism-align/frontend/app/views/upload/upload.css182
-rw-r--r--animism-align/frontend/app/views/upload/upload.reducer.js22
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
+ }
+}