summaryrefslogtreecommitdiff
path: root/animism-align/frontend/app/views/editor
diff options
context:
space:
mode:
authorJules Laplace <julescarbon@gmail.com>2021-03-08 22:11:55 +0100
committerJules Laplace <julescarbon@gmail.com>2021-03-08 22:11:55 +0100
commitd2cb17038b8537a609be06be2ed7013dbe27117e (patch)
tree028ceac9edddafc03ce80c49d5a05981bec3fcbe /animism-align/frontend/app/views/editor
parentb5ceb782f40fc1e402d1e58bc1ced2e4038fd787 (diff)
beginning the BIG refactor. moving editor stuff into per-episode hierarchy
Diffstat (limited to 'animism-align/frontend/app/views/editor')
-rw-r--r--animism-align/frontend/app/views/editor/align/align.actions.js136
-rw-r--r--animism-align/frontend/app/views/editor/align/align.container.js27
-rw-r--r--animism-align/frontend/app/views/editor/align/align.css160
-rw-r--r--animism-align/frontend/app/views/editor/align/align.reducer.js97
-rw-r--r--animism-align/frontend/app/views/editor/align/components/annotations/annotation.form.css67
-rw-r--r--animism-align/frontend/app/views/editor/align/components/annotations/annotation.form.js190
-rw-r--r--animism-align/frontend/app/views/editor/align/components/annotations/annotation.index.css125
-rw-r--r--animism-align/frontend/app/views/editor/align/components/annotations/annotation.index.js125
-rw-r--r--animism-align/frontend/app/views/editor/align/components/annotations/annotationForms/annotationForm.gallery.js132
-rw-r--r--animism-align/frontend/app/views/editor/align/components/annotations/annotationForms/annotationForm.image.js120
-rw-r--r--animism-align/frontend/app/views/editor/align/components/annotations/annotationForms/annotationForm.text.js153
-rw-r--r--animism-align/frontend/app/views/editor/align/components/annotations/annotationForms/annotationForm.utility.js172
-rw-r--r--animism-align/frontend/app/views/editor/align/components/annotations/annotationForms/annotationForm.video.js160
-rw-r--r--animism-align/frontend/app/views/editor/align/components/annotations/annotationForms/index.js46
-rw-r--r--animism-align/frontend/app/views/editor/align/components/annotations/annotationTypes/annotationTypes.gallery.js75
-rw-r--r--animism-align/frontend/app/views/editor/align/components/annotations/annotationTypes/annotationTypes.image.js54
-rw-r--r--animism-align/frontend/app/views/editor/align/components/annotations/annotationTypes/annotationTypes.text.js121
-rw-r--r--animism-align/frontend/app/views/editor/align/components/annotations/annotationTypes/annotationTypes.utility.js107
-rw-r--r--animism-align/frontend/app/views/editor/align/components/annotations/annotationTypes/annotationTypes.video.js51
-rw-r--r--animism-align/frontend/app/views/editor/align/components/annotations/annotationTypes/index.js57
-rw-r--r--animism-align/frontend/app/views/editor/align/components/player/playButton.component.js22
-rw-r--r--animism-align/frontend/app/views/editor/align/components/sidebar/script.component.js23
-rw-r--r--animism-align/frontend/app/views/editor/align/components/sidebar/tableOfContents.component.js30
-rw-r--r--animism-align/frontend/app/views/editor/align/components/timeline/cursor.component.js26
-rw-r--r--animism-align/frontend/app/views/editor/align/components/timeline/cursorRegion.component.js28
-rw-r--r--animism-align/frontend/app/views/editor/align/components/timeline/playCursor.component.js36
-rw-r--r--animism-align/frontend/app/views/editor/align/components/timeline/ticks.component.js88
-rw-r--r--animism-align/frontend/app/views/editor/align/components/timeline/waveform.component.js105
-rw-r--r--animism-align/frontend/app/views/editor/align/containers/annotations.container.js28
-rw-r--r--animism-align/frontend/app/views/editor/align/containers/sidebar.container.js35
-rw-r--r--animism-align/frontend/app/views/editor/align/containers/timeline.container.js196
-rw-r--r--animism-align/frontend/app/views/editor/annotation/annotation.reducer.js20
-rw-r--r--animism-align/frontend/app/views/editor/editor.container.js22
-rw-r--r--animism-align/frontend/app/views/editor/editor.gate.js32
-rw-r--r--animism-align/frontend/app/views/editor/media/components/media.form.js287
-rw-r--r--animism-align/frontend/app/views/editor/media/components/media.formFile.js67
-rw-r--r--animism-align/frontend/app/views/editor/media/components/media.formGallery.js338
-rw-r--r--animism-align/frontend/app/views/editor/media/components/media.formGalleryImage.js129
-rw-r--r--animism-align/frontend/app/views/editor/media/components/media.formImage.js169
-rw-r--r--animism-align/frontend/app/views/editor/media/components/media.formImageSelection.js213
-rw-r--r--animism-align/frontend/app/views/editor/media/components/media.formVideo.js118
-rw-r--r--animism-align/frontend/app/views/editor/media/components/media.indexOptions.js59
-rw-r--r--animism-align/frontend/app/views/editor/media/components/media.menu.js58
-rw-r--r--animism-align/frontend/app/views/editor/media/containers/media.edit.js57
-rw-r--r--animism-align/frontend/app/views/editor/media/containers/media.index.js160
-rw-r--r--animism-align/frontend/app/views/editor/media/containers/media.new.js81
-rw-r--r--animism-align/frontend/app/views/editor/media/media.actions.js9
-rw-r--r--animism-align/frontend/app/views/editor/media/media.container.js38
-rw-r--r--animism-align/frontend/app/views/editor/media/media.css110
-rw-r--r--animism-align/frontend/app/views/editor/media/media.reducer.js22
-rw-r--r--animism-align/frontend/app/views/editor/paragraph/components/paragraph.form.js107
-rw-r--r--animism-align/frontend/app/views/editor/paragraph/components/paragraph.list.js103
-rw-r--r--animism-align/frontend/app/views/editor/paragraph/components/paragraphTypes/index.js26
-rw-r--r--animism-align/frontend/app/views/editor/paragraph/components/paragraphTypes/paragraphTypes.image.js17
-rw-r--r--animism-align/frontend/app/views/editor/paragraph/components/paragraphTypes/paragraphTypes.text.js55
-rw-r--r--animism-align/frontend/app/views/editor/paragraph/components/paragraphTypes/paragraphTypes.video.js16
-rw-r--r--animism-align/frontend/app/views/editor/paragraph/containers/paragraphEditor.container.js93
-rw-r--r--animism-align/frontend/app/views/editor/paragraph/paragraph.container.js66
-rw-r--r--animism-align/frontend/app/views/editor/paragraph/paragraph.css126
-rw-r--r--animism-align/frontend/app/views/editor/paragraph/paragraph.reducer.js28
-rw-r--r--animism-align/frontend/app/views/editor/paragraph/transcript.actions.js10
61 files changed, 5428 insertions, 0 deletions
diff --git a/animism-align/frontend/app/views/editor/align/align.actions.js b/animism-align/frontend/app/views/editor/align/align.actions.js
new file mode 100644
index 0000000..1583e4e
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/align.actions.js
@@ -0,0 +1,136 @@
+import * as types from 'app/types'
+import { store } from 'app/store'
+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 { timestampToSeconds, post } from 'app/utils'
+import { cutFirstSentence } from 'app/utils/align.utils'
+import { annotationFadeTimings } from 'app/utils/annotation.utils'
+
+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 setCursorRegion = (a_ts, b_ts) => dispatch => (
+ dispatch({ type: types.align.set_display_setting, key: 'cursor_region', value: { a_ts, b_ts } })
+)
+export const clearCursorRegion = () => dispatch => (
+ dispatch({ type: types.align.set_display_setting, key: 'cursor_region', value: null })
+)
+
+export const setSelectedAnnotation = annotation => dispatch => {
+ debouncedUpdateAnnotation.flush()
+ dispatch({ type: types.align.set_selected_annotation, data: annotation })
+}
+export const clearSelectedAnnotation = () => dispatch => {
+ debouncedUpdateAnnotation.flush()
+ dispatch({ type: types.align.clear_selected_annotation })
+}
+export const updateSelectedAnnotation = annotation => dispatch => {
+ debouncedUpdateAnnotation(annotation)
+ dispatch({ type: types.align.set_selected_annotation, data: { ...annotation } })
+}
+export const debouncedUpdateAnnotation = debounce(annotation => {
+ console.log('updating annotation', annotation)
+ actions.annotation.update(annotation)
+}, 2000, { leading: false, trailing: true })
+
+export const cloneSelectedAnnotation = annotation => dispatch => {
+ const newAnnotation = { ...annotation }
+ delete newAnnotation.id
+ if (annotation.settings.fullscreen) {
+ const {
+ fadeInDuration, fadeOutDuration, duration,
+ start_ts, end_ts, fade_in_end_ts, fade_out_start_ts,
+ } = annotationFadeTimings(annotation)
+ newAnnotation.start_ts += duration - fadeOutDuration - fadeInDuration
+ } else {
+ newAnnotation.start_ts += 1
+ }
+ actions.annotation.create(newAnnotation)
+ .then(res => {
+ console.log('cloned annotation', res.res)
+ setSelectedParagraph(res.res)
+ })
+}
+
+export const setSelectedParagraph = paragraph_id => dispatch => {
+ dispatch({ type: types.align.set_display_setting, key: 'selected_paragraph_id', value: paragraph_id })
+}
+export const clearSelectedParagraph = () => 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: {}
+ })
+}
+
+
+export const spliceTime = start_ts => dispatch => {
+ let duration = timestampToSeconds(prompt("How many seconds to add or remove? Enter a positive / negative number"))
+ if (!duration) {
+ return
+ }
+ console.log(start_ts, duration)
+ const data = {
+ start_ts, duration,
+ }
+ post(dispatch, types.api, 'splice', '/api/v1/annotation/splice', data)
+ .then(res => {
+ console.log(res)
+ alert(res.count + ' records updated!')
+ actions.annotation.index()
+ })
+} \ No newline at end of file
diff --git a/animism-align/frontend/app/views/editor/align/align.container.js b/animism-align/frontend/app/views/editor/align/align.container.js
new file mode 100644
index 0000000..281fd35
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/align.container.js
@@ -0,0 +1,27 @@
+import React, { Component } from 'react'
+
+import './align.css'
+import './components/annotations/annotation.form.css'
+import './components/annotations/annotation.index.css'
+
+import Timeline from 'app/views/editor/align/containers/timeline.container.js'
+import Sidebar from 'app/views/editor/align/containers/sidebar.container.js'
+
+class Container extends Component {
+ componentDidMount() {
+ document.body.scrollTo(0, 0)
+ document.body.parentNode.scrollTo(0, 0)
+ }
+ render() {
+ return (
+ <div className='body alignmentEditor'>
+ <div className='row'>
+ <Timeline />
+ </div>
+ <Sidebar />
+ </div>
+ )
+ }
+}
+
+export default Container
diff --git a/animism-align/frontend/app/views/editor/align/align.css b/animism-align/frontend/app/views/editor/align/align.css
new file mode 100644
index 0000000..d10b601
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/align.css
@@ -0,0 +1,160 @@
+* {
+
+}
+.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;
+}
+
+.body.alignmentEditor {
+ height: calc(100% - 3rem);
+}
+
+/* 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;
+ pointer-events: none;
+}
+.ticks .tickLabel {
+ pointer-events: none;
+ 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;
+ user-select: none;
+}
+.timeline .cursor_region {
+ position: absolute;
+ left: 0;
+ width: 100%;
+ border-top: 1px solid #33f;
+ border-bottom: 1px solid #33f;
+ background: rgba(32,64,255,0.2);
+}
+.timeline .cursor_region .tickLabel {
+ position: absolute;
+ top: 2px;
+ left: 6px;
+ user-select: none;
+}
+
+/* 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 */
+
+.sidebar {
+ position: absolute;
+ top: 0;
+ right: 0;
+ min-width: 4rem;
+ z-index: 8;
+}
+.sidebar textarea {
+ height: calc(100vh - 3.15rem);
+}
+.sidebar button {
+ position: absolute;
+ top: 0.25rem;
+ right: 0.25rem;
+ z-index: 9;
+ border: 0;
+ background: transparent;
+}
+
+/* Annotations */
+
+.annotations {
+ position: relative;
+ width: 450px;
+}
+
+/* table of contents */
+
+.toc {
+ background: #222;
+ width: 15rem;
+ padding: 0.5rem 0;
+}
+.toc div {
+ width: 15rem;
+ padding: 0.25rem 0.75rem;
+ cursor: pointer;
+}
+.toc div:hover {
+ background: #213;
+} \ No newline at end of file
diff --git a/animism-align/frontend/app/views/editor/align/align.reducer.js b/animism-align/frontend/app/views/editor/align/align.reducer.js
new file mode 100644
index 0000000..d37cd6d
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/align.reducer.js
@@ -0,0 +1,97 @@
+import * as types from 'app/types'
+// import { session, getDefault, getDefaultInt } from 'app/session'
+
+const initialState = {
+ timeline: {
+ cursor_ts: -1,
+ cursor_region: null,
+ start_ts: 0,
+ zoom: 1,
+ duration: 0,
+ selected_annotation_id: -1,
+ selected_paragraph_id: -1,
+ },
+ peaks: { loading: true },
+ text: { loading: true },
+ 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,
+ peaks: action.data,
+ }
+
+ case types.text.loaded:
+ return {
+ ...state,
+ text: action.data,
+ }
+
+ 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/editor/align/components/annotations/annotation.form.css b/animism-align/frontend/app/views/editor/align/components/annotations/annotation.form.css
new file mode 100644
index 0000000..fa663c7
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/components/annotations/annotation.form.css
@@ -0,0 +1,67 @@
+/* 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;
+}
+.annotationForm div.textarea {
+ margin-bottom: 0.5rem;
+}
+.annotationForm img {
+ max-width: 100%;
+ max-height: 6rem;
+}
+
+.annotationForm .options label span:first-child {
+ display: inline-block;
+ width: 6rem;
+}
+.annotationForm .options .description {
+ font-size: 0.75rem;
+ margin-top: 0.25rem;
+ margin-bottom: 0.5rem;
+}
+.annotationForm .color input[type="text"].number {
+ width: 8rem;
+}
+.annotationForm .color input[type="text"] {
+ width: 8rem;
+}
+.annotationForm .color input[type="color"] {
+ background: transparent;
+ border: 0;
+ height: 1.7rem;
+ padding: 0;
+ margin: 0 0.5rem 0 0;
+ width: 1.4rem;
+}
+.annotationForm .checkbox {
+ margin-top: 0.5rem;
+ margin-bottom: 0.5rem;
+} \ No newline at end of file
diff --git a/animism-align/frontend/app/views/editor/align/components/annotations/annotation.form.js b/animism-align/frontend/app/views/editor/align/components/annotations/annotation.form.js
new file mode 100644
index 0000000..7b1918a
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/components/annotations/annotation.form.js
@@ -0,0 +1,190 @@
+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 { ANNOTATION_SELECT_OPTIONS, MEDIA_ANNOTATION_TYPES } from 'app/constants'
+import { timestamp } from 'app/utils'
+import { timeToPosition } from 'app/utils/align.utils'
+import { Select } from 'app/common'
+
+import { annotationFormLookup } from './annotationForms'
+
+const annotationTextTypes = new Set([
+ 'sentence', 'section_heading', 'heading_text', 'pullquote_credit', 'footnote', 'text_plate',
+])
+
+class AnnotationForm extends Component {
+ constructor(props){
+ super(props)
+ this.handleChange = this.handleChange.bind(this)
+ this.handleSelect = this.handleSelect.bind(this)
+ this.handleSettingsChange = this.handleSettingsChange.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)
+ }
+ handleSettingsChange(e) {
+ const { name, value } = e.target
+ this.handleSettingsSelect(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.type in MEDIA_ANNOTATION_TYPES) {
+ if (!annotation.settings.media_id) return
+ 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 } = this.props
+ return (
+ <div
+ className='annotationForm'
+ style={{
+ top: timeToPosition(annotation.start_ts, timeline),
+ }}
+ >
+ {this.renderButtons()}
+ {annotationTextTypes.has(annotation.type) && this.renderTextarea()}
+ {(annotation.type in annotationFormLookup) && this.renderElementForm()}
+ </div>
+ )
+ }
+ renderButtons() {
+ const { annotation } = this.props
+ return (
+ <div className='row buttons'>
+ <div>
+ <Select
+ name='type'
+ selected={annotation.type}
+ options={ANNOTATION_SELECT_OPTIONS}
+ 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 className='textarea'>
+ <textarea
+ name='text'
+ value={annotation.text}
+ onKeyDown={this.handleKeyDown}
+ onChange={this.handleChange}
+ ref={this.textareaRef}
+ />
+ </div>
+ )
+ }
+ renderElementForm() {
+ const { annotation, media } = this.props
+ const AnnotationFormElement = annotationFormLookup[annotation.type]
+ return (
+ <AnnotationFormElement
+ annotation={annotation}
+ media={media}
+ handleSettingsChange={this.handleSettingsChange}
+ handleSettingsSelect={this.handleSettingsSelect}
+ />
+ )
+ }
+}
+
+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/editor/align/components/annotations/annotation.index.css b/animism-align/frontend/app/views/editor/align/components/annotations/annotation.index.css
new file mode 100644
index 0000000..afedbde
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/components/annotations/annotation.index.css
@@ -0,0 +1,125 @@
+/* Annotation index */
+
+.annotationIndex {
+ width: 800px;
+}
+.annotationIndex .annotation {
+ position: absolute;
+ left: 5px;
+ max-width: 300px;
+ 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: 2;
+ background-image: linear-gradient(rgba(0,0,0,0.5), rgba(0,0,0,0.4));
+}
+.annotationIndex .annotation.media {
+ width: 300px;
+ left: calc(405px + 0.5rem);
+}
+.annotationIndex .annotation.sentence.even {
+ background-color: #83b;
+}
+.annotationIndex .annotation.sentence.odd {
+ background-color: #537;
+}
+.annotationIndex .annotation.section_heading {
+ background-color: #983;
+ z-index: 1;
+}
+.annotationIndex .annotation.heading_text {
+ background-color: #838;
+}
+.annotationIndex .annotation.paragraph_end {
+ background-color: #003;
+ border-top: 1px solid #888;
+ width: 300px;
+ padding: 1px;
+}
+.annotationIndex .annotation.utility {
+ left: calc(505px + 0.5rem);
+ width: 200px;
+}
+.annotationIndex .annotation.footnote {
+ left: calc(605px + 0.5rem);
+ width: 200px;
+}
+.annotationIndex .annotation.text_plate {
+ left: calc(605px + 0.5rem);
+ width: 250px;
+}
+.annotationIndex .annotation.utility.curtain {
+ background-image: linear-gradient(rgba(255,255,255,1.0), rgba(255,255,255,1.0));
+ width: 15rem;
+ padding: 0;
+ overflow: hidden;
+}
+.annotationIndex .annotation.utility.curtain span {
+ position: absolute;
+ top: 0; left: 0;
+ z-index: 1;
+ color: black;
+ text-shadow: 0 0 3px #fff, 0 0 2px #fff, 0 0 1px #fff;
+ padding: 0.25rem;
+}
+.annotationIndex .annotation.utility.curtain .fadeIn {
+ z-index: 0;
+ position: absolute;
+ top: 0;
+ width: 100%;
+ background-image: linear-gradient(rgba(0,0,0,1.0), rgba(255,255,255,1.0));
+ background-size: cover;
+}
+.annotationIndex .annotation.utility.curtain .fadeOut {
+ z-index: 0;
+ position: absolute;
+ bottom: 0;
+ width: 100%;
+ background-image: linear-gradient(rgba(255,255,255,1.0), rgba(0,0,0,1.0));
+ background-size: cover;
+}
+
+/* Condensed layout (first lines) */
+
+.annotationIndex.condensed .annotation.sentence {
+ z-index: 0;
+ white-space: pre;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.annotationIndex.condensed .annotation.section_heading {
+ z-index: 2;
+}
+.annotationIndex.condensed .annotation.heading_text {
+ 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: 4;
+}
+.annotationIndex.collapsed .annotation.section_heading {
+ z-index: 3;
+}
+.annotationIndex.collapsed .annotation.heading_text {
+ z-index: 2;
+}
+.annotationIndex.collapsed .annotation.paragraph_end {
+ border-top-color: #333;
+}
diff --git a/animism-align/frontend/app/views/editor/align/components/annotations/annotation.index.js b/animism-align/frontend/app/views/editor/align/components/annotations/annotation.index.js
new file mode 100644
index 0000000..8e1fa43
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/components/annotations/annotation.index.js
@@ -0,0 +1,125 @@
+import React, { PureComponent } 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/utils/align.utils'
+
+import { AnnotationElementLookup } from './annotationTypes'
+
+class AnnotationIndex extends PureComponent {
+ 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 - 60.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])
+ this.setState({ items })
+ }
+ handleClick(e, annotation) {
+ e.stopPropagation()
+ if (!annotation) return
+ 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}
+ timeline={timeline}
+ 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,
+})
+
+export default connect(mapStateToProps)(AnnotationIndex)
diff --git a/animism-align/frontend/app/views/editor/align/components/annotations/annotationForms/annotationForm.gallery.js b/animism-align/frontend/app/views/editor/align/components/annotations/annotationForms/annotationForm.gallery.js
new file mode 100644
index 0000000..0a8b3fb
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/components/annotations/annotationForms/annotationForm.gallery.js
@@ -0,0 +1,132 @@
+import React, { Component } from 'react'
+
+import { CURTAIN_COLOR_SELECT_OPTIONS } from 'app/constants'
+import { TextInput, Select, Checkbox, LabelDescription } from 'app/common'
+import { AnnotationFormFullscreen } from './annotationForm.utility'
+import { makeMediaItems, makeGalleryItems } from 'app/utils/annotation.utils'
+
+export const AnnotationFormGallery = ({ annotation, media, handleSettingsSelect, handleSettingsChange }) => {
+ if (!media.lookup) return <div />
+ const image_list_items = makeMediaItems(media, ['gallery'])
+ return (
+ <div className='options'>
+ <Select
+ name='media_id'
+ className="media_id"
+ selected={annotation.settings.media_id}
+ options={image_list_items}
+ defaultOption='Choose a gallery'
+ onChange={handleSettingsSelect}
+ />
+
+ <TextInput
+ title="Title"
+ name="title"
+ placeholder="Enter title or leave blank"
+ data={annotation.settings}
+ onChange={handleSettingsChange}
+ autoComplete="off"
+ />
+
+ <Checkbox
+ label="Fullscreen"
+ name="fullscreen"
+ checked={annotation.settings.fullscreen}
+ onChange={handleSettingsSelect}
+ />
+
+ <Checkbox
+ label="Inline"
+ name="inline"
+ checked={annotation.settings.inline}
+ onChange={handleSettingsSelect}
+ />
+
+ <Checkbox
+ label="Hide in transcript"
+ name="hide_in_transcript"
+ checked={annotation.settings.hide_in_transcript}
+ onChange={handleSettingsSelect}
+ />
+
+ <Select
+ title='Color'
+ name='color'
+ selected={annotation.settings.color}
+ options={CURTAIN_COLOR_SELECT_OPTIONS}
+ defaultOption='Pick a color'
+ onChange={handleSettingsSelect}
+ />
+
+ <Select
+ title='Arrow Color'
+ name='arrow_color'
+ selected={annotation.settings.arrow_color}
+ options={CURTAIN_COLOR_SELECT_OPTIONS}
+ defaultOption='Color of UI arrows'
+ onChange={handleSettingsSelect}
+ />
+
+ {(annotation.settings.fullscreen && !annotation.settings.inline) && (
+ <AnnotationFormFullscreen
+ annotation={annotation}
+ handleSettingsChange={handleSettingsChange}
+ handleSettingsSelect={handleSettingsSelect}
+ />
+ )}
+ </div>
+ )
+}
+
+export const AnnotationFormGalleryAdvance = ({ annotation, media, handleSettingsSelect, handleSettingsChange }) => {
+ if (!media.lookup) return <div />
+ const image_list_items = makeMediaItems(media, ['gallery'])
+ const { gallery_items, thumbnail } = makeGalleryItems(annotation, media)
+ return (
+ <div className='options'>
+ <Select
+ name='media_id'
+ className="media_id"
+ selected={annotation.settings.media_id}
+ options={image_list_items}
+ defaultOption='Choose a gallery'
+ onChange={handleSettingsSelect}
+ />
+
+ {gallery_items && (
+ <Select
+ name='frame_index'
+ selected={annotation.settings.frame_index}
+ options={gallery_items}
+ defaultOption='Choose an image'
+ onChange={handleSettingsSelect}
+ />
+ )}
+
+ {thumbnail && (
+ <img src={thumbnail.url} />
+ )}
+
+ <Checkbox
+ label="Advance half a frame forward to fit two images in view"
+ name="half_frame"
+ checked={annotation.settings.half_frame}
+ onChange={handleSettingsSelect}
+ />
+
+ <Checkbox
+ label="Hide in transcript"
+ name="hide_in_transcript"
+ checked={annotation.settings.hide_in_transcript}
+ onChange={handleSettingsSelect}
+ />
+
+ <Checkbox
+ label="Show in checklist"
+ name="show_in_checklist"
+ checked={annotation.settings.show_in_checklist}
+ onChange={handleSettingsSelect}
+ />
+ </div>
+ )
+}
diff --git a/animism-align/frontend/app/views/editor/align/components/annotations/annotationForms/annotationForm.image.js b/animism-align/frontend/app/views/editor/align/components/annotations/annotationForms/annotationForm.image.js
new file mode 100644
index 0000000..3de2ac5
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/components/annotations/annotationForms/annotationForm.image.js
@@ -0,0 +1,120 @@
+import React, { Component } from 'react'
+
+import { CURTAIN_COLOR_SELECT_OPTIONS, IMAGE_INLINE_SIZE_OPTIONS } from 'app/constants'
+import { Select, Checkbox, TextInput } from 'app/common'
+import { AnnotationFormFullscreen } from './annotationForm.utility'
+import { makeMediaItems, makeGalleryItems } from 'app/utils/annotation.utils'
+
+export const AnnotationFormImage = ({ annotation, media, handleSettingsSelect, handleSettingsChange }) => {
+ if (!media.lookup) return <div />
+ const image_list_items = makeMediaItems(media, ['image', 'gallery'])
+ const { gallery_items, thumbnail } = makeGalleryItems(annotation, media)
+ console.log(annotation)
+ return (
+ <div className='options'>
+ <Select
+ name='media_id'
+ className="media_id"
+ selected={annotation.settings.media_id}
+ options={image_list_items}
+ defaultOption='Choose an image'
+ onChange={handleSettingsSelect}
+ />
+
+ {gallery_items && (
+ <Select
+ name='frame_index'
+ selected={annotation.settings.frame_index}
+ options={gallery_items}
+ defaultOption='Choose an image'
+ onChange={handleSettingsSelect}
+ />
+ )}
+
+ {thumbnail && (
+ <img src={thumbnail.url} />
+ )}
+
+ <Checkbox
+ label="Fullscreen"
+ name="fullscreen"
+ checked={annotation.settings.fullscreen}
+ onChange={handleSettingsSelect}
+ />
+
+ <Select
+ title='Color'
+ name='color'
+ selected={annotation.settings.color}
+ options={CURTAIN_COLOR_SELECT_OPTIONS}
+ defaultOption='Pick a color'
+ onChange={handleSettingsSelect}
+ />
+
+ {(annotation.settings.fullscreen) && (
+ <div>
+ <Checkbox
+ label="Hide inline image"
+ name="hide_poster_inline"
+ checked={annotation.settings.hide_poster_inline}
+ onChange={handleSettingsSelect}
+ />
+ <Select
+ title='Inline size'
+ name='inline_size'
+ selected={annotation.settings.inline_size}
+ options={IMAGE_INLINE_SIZE_OPTIONS}
+ defaultOption='Pick a size'
+ onChange={handleSettingsSelect}
+ />
+ <Checkbox
+ label="Hide speaker icon"
+ name="hide_speaker_icon"
+ checked={annotation.settings.hide_speaker_icon}
+ onChange={handleSettingsSelect}
+ />
+ <Select
+ title='Inline background color'
+ name='inline_color'
+ selected={annotation.settings.inline_color}
+ options={CURTAIN_COLOR_SELECT_OPTIONS}
+ defaultOption='Pick a color'
+ onChange={handleSettingsSelect}
+ />
+ </div>
+ )}
+
+ <Checkbox
+ label="Hide caption"
+ name="hide_caption"
+ checked={annotation.settings.hide_caption}
+ onChange={handleSettingsSelect}
+ />
+
+ <Checkbox
+ label="Hide in transcript"
+ name="hide_in_transcript"
+ checked={annotation.settings.hide_in_transcript}
+ onChange={handleSettingsSelect}
+ />
+
+ <TextInput
+ title="Override start time"
+ name="override_start_ts"
+ className="number"
+ placeholder="0:00"
+ data={annotation.settings}
+ onChange={handleSettingsChange}
+ autoComplete="off"
+ />
+
+ {(annotation.settings.fullscreen && !annotation.settings.inline) && (
+ <AnnotationFormFullscreen
+ annotation={annotation}
+ handleSettingsChange={handleSettingsChange}
+ handleSettingsSelect={handleSettingsSelect}
+ />
+ )}
+ </div>
+ )
+}
diff --git a/animism-align/frontend/app/views/editor/align/components/annotations/annotationForms/annotationForm.text.js b/animism-align/frontend/app/views/editor/align/components/annotations/annotationForms/annotationForm.text.js
new file mode 100644
index 0000000..8bc7675
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/components/annotations/annotationForms/annotationForm.text.js
@@ -0,0 +1,153 @@
+import React, { Component } from 'react'
+
+import {
+ CURTAIN_COLOR_SELECT_OPTIONS,
+ CURTAIN_STYLE_SELECT_OPTIONS,
+ BLACK_WHITE_SELECT_OPTIONS,
+ IMAGE_BACKGROUND_SIZE_OPTIONS,
+} from 'app/constants'
+import { Select, Checkbox, TextInput, LabelDescription } from 'app/common'
+import { AnnotationFormFullscreen } from './annotationForm.utility'
+import { makeMediaItems } from 'app/utils/annotation.utils'
+
+export const AnnotationFormSectionHeading = ({ annotation, media, handleSettingsSelect, handleSettingsChange }) => {
+ const image_list_items = makeMediaItems(media, ['image', 'video', 'gallery'])
+ return (
+ <div className='options'>
+ <Checkbox
+ label="Hidden"
+ name="hidden"
+ checked={annotation.settings.hidden}
+ onChange={handleSettingsSelect}
+ />
+ <Select
+ title='Background Color'
+ name='color'
+ selected={annotation.settings.color}
+ options={BLACK_WHITE_SELECT_OPTIONS}
+ defaultOption='Pick a color'
+ onChange={handleSettingsSelect}
+ />
+ <Select
+ title='Cover image'
+ name='media_id'
+ className="media_id"
+ selected={annotation.settings.media_id}
+ options={image_list_items}
+ defaultOption='Choose an image'
+ onChange={handleSettingsSelect}
+ />
+ <Select
+ title='Cover style'
+ name='cover_style'
+ className="cover_style"
+ selected={annotation.settings.cover_style}
+ options={IMAGE_BACKGROUND_SIZE_OPTIONS}
+ defaultOption='Cover image caption style'
+ onChange={handleSettingsSelect}
+ />
+ <LabelDescription>
+ {'Background color for section'}
+ </LabelDescription>
+ <Select
+ title='Transition Color'
+ name='transition_color'
+ selected={annotation.settings.transition_color}
+ options={CURTAIN_COLOR_SELECT_OPTIONS}
+ defaultOption='Pick a color'
+ onChange={handleSettingsSelect}
+ />
+ <LabelDescription>
+ {'Player will fade from this color when section begins'}
+ </LabelDescription>
+ {!annotation.settings.no_audio &&
+ <div>
+ <Select
+ title='Section Nav Color'
+ name='section_nav_color'
+ selected={annotation.settings.section_nav_color}
+ options={BLACK_WHITE_SELECT_OPTIONS}
+ defaultOption='Pick a color'
+ onChange={handleSettingsSelect}
+ />
+ <LabelDescription>
+ {'Section nav thumbnail icon color'}
+ </LabelDescription>
+ </div>
+ }
+ <Checkbox
+ label="Section does not have audio"
+ name="no_audio"
+ checked={annotation.settings.no_audio}
+ onChange={handleSettingsSelect}
+ />
+ <LabelDescription>
+ {'Check if this is a text-only section'}
+ </LabelDescription>
+ </div>
+ )
+}
+
+export const AnnotationFormTextPlate = ({ annotation, handleSettingsSelect, handleSettingsChange }) => {
+ return (
+ <div className='options'>
+ <Select
+ title='Color'
+ name='color'
+ selected={annotation.settings.color}
+ options={CURTAIN_COLOR_SELECT_OPTIONS}
+ defaultOption='Pick a color'
+ onChange={handleSettingsSelect}
+ />
+ <Select
+ title='Curtain style'
+ name='transition_color'
+ selected={annotation.settings.transition_color}
+ options={CURTAIN_STYLE_SELECT_OPTIONS}
+ defaultOption='Pick a style'
+ onChange={handleSettingsSelect}
+ />
+ <AnnotationFormFullscreen
+ annotation={annotation}
+ handleSettingsChange={handleSettingsChange}
+ handleSettingsSelect={handleSettingsSelect}
+ />
+ </div>
+ )
+}
+
+export const AnnotationFormSubtitle = ({ annotation, handleSettingsSelect, handleSettingsChange }) => {
+ return (
+ <div className='options'>
+ <Select
+ title='Color'
+ name='color'
+ selected={annotation.settings.color}
+ options={CURTAIN_COLOR_SELECT_OPTIONS}
+ defaultOption='Pick a color'
+ onChange={handleSettingsSelect}
+ />
+
+ <AnnotationFormFullscreen
+ annotation={annotation}
+ handleSettingsChange={handleSettingsChange}
+ handleSettingsSelect={handleSettingsSelect}
+ />
+ </div>
+ )
+}
+
+export const AnnotationFormFootnote = ({ annotation, handleSettingsSelect, handleSettingsChange }) => {
+ return (
+ <div className='options'>
+ <TextInput
+ title="Actual Timestamp"
+ name="actual_ts"
+ placeholder="0:00"
+ data={annotation.settings}
+ onChange={handleSettingsChange}
+ autoComplete="off"
+ />
+ </div>
+ )
+}
diff --git a/animism-align/frontend/app/views/editor/align/components/annotations/annotationForms/annotationForm.utility.js b/animism-align/frontend/app/views/editor/align/components/annotations/annotationForms/annotationForm.utility.js
new file mode 100644
index 0000000..7e823cc
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/components/annotations/annotationForms/annotationForm.utility.js
@@ -0,0 +1,172 @@
+import React, { Component } from 'react'
+
+import { timestamp } from 'app/utils'
+import { TextInput, LabelDescription, Select, Checkbox } from 'app/common'
+import { CURTAIN_COLOR_SELECT_OPTIONS, CURTAIN_STYLE_SELECT_OPTIONS } from 'app/constants'
+import { annotationFadeTimings } from 'app/utils/annotation.utils'
+import { makeMediaItems } from 'app/utils/annotation.utils'
+
+export const AnnotationFormIntro = ({ annotation, media, handleSettingsChange, handleSettingsSelect }) => {
+ if (!media.lookup) return <div />
+ const image_list_items = makeMediaItems(media, 'file')
+ return (
+ <div className='options'>
+ <Select
+ name='media_id'
+ className="media_id"
+ selected={annotation.settings.media_id}
+ options={image_list_items}
+ defaultOption='Choose a file'
+ onChange={handleSettingsSelect}
+ />
+
+ <TextInput
+ title="Title"
+ name="title"
+ placeholder="Enter title or leave blank"
+ data={annotation.settings}
+ onChange={handleSettingsChange}
+ autoComplete="off"
+ />
+
+ <TextInput
+ title="Subtitle"
+ name="subtitle"
+ placeholder="Enter subtitle or leave blank"
+ data={annotation.settings}
+ onChange={handleSettingsChange}
+ autoComplete="off"
+ />
+
+ <Select
+ title='Color'
+ name='color'
+ selected={annotation.settings.color}
+ options={CURTAIN_COLOR_SELECT_OPTIONS}
+ defaultOption='Pick a color'
+ onChange={handleSettingsSelect}
+ />
+
+ <TextInput
+ title="Actual start time"
+ name="intro_start_ts"
+ placeholder="0:00"
+ data={annotation.settings}
+ onChange={handleSettingsChange}
+ autoComplete="off"
+ />
+
+ <LabelDescription>
+ {'Timestamp where voiceover starts, after any intro sound effect.'}
+ </LabelDescription>
+
+ <AnnotationFormFullscreen
+ annotation={annotation}
+ handleSettingsChange={handleSettingsChange}
+ handleSettingsSelect={handleSettingsSelect}
+ />
+ </div>
+ )
+}
+
+export const AnnotationFormCurtain = ({ annotation, handleSettingsChange, handleSettingsSelect }) => {
+ return (
+ <div className='options'>
+ <TextInput
+ title="Curtain text"
+ name="curtain_text"
+ placeholder="Enter text or leave blank"
+ data={annotation.settings}
+ onChange={handleSettingsChange}
+ autoComplete="off"
+ />
+
+ <Select
+ title='Color'
+ name='color'
+ selected={annotation.settings.color}
+ options={CURTAIN_COLOR_SELECT_OPTIONS}
+ defaultOption='Pick a color'
+ onChange={handleSettingsSelect}
+ />
+
+ <Select
+ title='Style'
+ name='curtain_style'
+ selected={annotation.settings.curtain_style}
+ options={CURTAIN_STYLE_SELECT_OPTIONS}
+ defaultOption='Pick a style'
+ onChange={handleSettingsSelect}
+ />
+
+ <Checkbox
+ label="Contains flashing light"
+ name="flashing_light_warning"
+ checked={annotation.settings.flashing_light_warning}
+ onChange={handleSettingsSelect}
+ />
+
+ <AnnotationFormFullscreen
+ alwaysAccessible
+ annotation={annotation}
+ handleSettingsChange={handleSettingsChange}
+ handleSettingsSelect={handleSettingsSelect}
+ />
+ </div>
+ )
+}
+
+export const AnnotationFormFullscreen = ({ annotation, handleSettingsChange, handleSettingsSelect }) => {
+ const {
+ fadeInDuration, fadeOutDuration, duration,
+ start_ts, end_ts, fade_in_end_ts, fade_out_start_ts,
+ } = annotationFadeTimings(annotation)
+ return (
+ <div>
+ <TextInput
+ title="Total duration"
+ name="duration"
+ className="number"
+ placeholder="0:00"
+ data={annotation.settings}
+ onChange={handleSettingsChange}
+ autoComplete="off"
+ />
+ <LabelDescription>
+ {duration}
+ {' seconds, ends at '}
+ {timestamp(end_ts)}
+ </LabelDescription>
+
+ <TextInput
+ title="Fade in duration"
+ name="fade_in_duration"
+ className="number"
+ placeholder="0:00"
+ data={annotation.settings}
+ onChange={handleSettingsChange}
+ autoComplete="off"
+ />
+ <LabelDescription>
+ {fadeInDuration}
+ {' seconds, ends at '}
+ {timestamp(fade_in_end_ts)}
+ </LabelDescription>
+
+ <TextInput
+ title="Fade out duration"
+ name="fade_out_duration"
+ className="number"
+ placeholder="0:00"
+ data={annotation.settings}
+ onChange={handleSettingsChange}
+ autoComplete="off"
+ />
+ <LabelDescription>
+ {fadeOutDuration}
+ {' seconds, starts at '}
+ {timestamp(fade_out_start_ts)}
+ </LabelDescription>
+ </div>
+ )
+}
diff --git a/animism-align/frontend/app/views/editor/align/components/annotations/annotationForms/annotationForm.video.js b/animism-align/frontend/app/views/editor/align/components/annotations/annotationForms/annotationForm.video.js
new file mode 100644
index 0000000..dd5f640
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/components/annotations/annotationForms/annotationForm.video.js
@@ -0,0 +1,160 @@
+import React, { Component } from 'react'
+
+import { CURTAIN_COLOR_SELECT_OPTIONS, IMAGE_BACKGROUND_SIZE_OPTIONS } from 'app/constants'
+import { Select, Checkbox, TextInput, LabelDescription } from 'app/common'
+import { AnnotationFormFullscreen } from './annotationForm.utility'
+import { makeMediaItems } from 'app/utils/annotation.utils'
+
+export const AnnotationFormVideo = ({ annotation, media, handleSettingsSelect, handleSettingsChange }) => {
+ if (!media.lookup) return <div />
+ const video_list_items = makeMediaItems(media, ['video'])
+ return (
+ <div className='options'>
+ <Select
+ name='media_id'
+ className="media_id"
+ selected={annotation.settings.media_id}
+ options={video_list_items}
+ defaultOption='Choose a video'
+ onChange={handleSettingsSelect}
+ />
+ <Checkbox
+ label="Autoplay"
+ name="autoplay"
+ checked={annotation.settings.autoplay}
+ onChange={handleSettingsSelect}
+ />
+ <Checkbox
+ label="Fullscreen"
+ name="fullscreen"
+ checked={annotation.settings.fullscreen}
+ onChange={handleSettingsSelect}
+ />
+ <Checkbox
+ label="Inline"
+ name="inline"
+ checked={annotation.settings.inline}
+ onChange={handleSettingsSelect}
+ />
+ <Checkbox
+ label="Loop"
+ name="loop"
+ checked={annotation.settings.loop}
+ onChange={handleSettingsSelect}
+ />
+ <Checkbox
+ label="Unmute"
+ name="unmuted"
+ checked={annotation.settings.unmuted}
+ onChange={handleSettingsSelect}
+ />
+ {annotation.settings.inline && (
+ <Checkbox
+ label="Hide Controls"
+ name="hide_controls"
+ checked={annotation.settings.hide_controls}
+ onChange={handleSettingsSelect}
+ />
+ )}
+ {annotation.settings.inline && (
+ <Checkbox
+ label="Show poster image"
+ name="poster"
+ checked={annotation.settings.poster}
+ onChange={handleSettingsSelect}
+ />
+ )}
+ {(annotation.settings.fullscreen && !annotation.settings.inline) && (
+ <Checkbox
+ label="Hide inline video poster"
+ name="hide_poster_inline"
+ checked={annotation.settings.hide_poster_inline}
+ onChange={handleSettingsSelect}
+ />
+ )}
+ {(annotation.settings.fullscreen && !annotation.settings.inline) && (
+ <Checkbox
+ label="Can play full video at end of section"
+ name="can_play_full_video"
+ checked={annotation.settings.can_play_full_video}
+ onChange={handleSettingsSelect}
+ />
+ )}
+ <Checkbox
+ label="Hide in transcript"
+ name="hide_in_transcript"
+ checked={annotation.settings.hide_in_transcript}
+ onChange={handleSettingsSelect}
+ />
+ <Select
+ title='Color'
+ name='color'
+ selected={annotation.settings.color}
+ options={CURTAIN_COLOR_SELECT_OPTIONS}
+ defaultOption='Pick a color'
+ onChange={handleSettingsSelect}
+ />
+ <Select
+ title='Poster Size'
+ name='poster_size'
+ selected={annotation.settings.poster_size}
+ options={IMAGE_BACKGROUND_SIZE_OPTIONS}
+ defaultOption='Select size'
+ onChange={handleSettingsSelect}
+ />
+ <Select
+ title='Poster background color'
+ name='poster_background_color'
+ selected={annotation.settings.poster_background_color}
+ options={CURTAIN_COLOR_SELECT_OPTIONS}
+ defaultOption='Pick a color'
+ onChange={handleSettingsSelect}
+ />
+ <TextInput
+ title="Video start time"
+ name="video_start_ts"
+ className="number"
+ placeholder="0:00"
+ data={annotation.settings}
+ onChange={handleSettingsChange}
+ autoComplete="off"
+ />
+ <LabelDescription>
+ {'Auto-advances the video to this point when starting'}
+ </LabelDescription>
+
+ {(annotation.settings.fullscreen && !annotation.settings.inline) && (
+ <AnnotationFormFullscreen
+ annotation={annotation}
+ handleSettingsChange={handleSettingsChange}
+ handleSettingsSelect={handleSettingsSelect}
+ />
+ )}
+ </div>
+ )
+}
+
+export const AnnotationFormVideoSetVolume = ({ annotation, handleSettingsChange }) => {
+ return (
+ <div className='options'>
+ <TextInput
+ title="Volume"
+ name="volume"
+ className="number"
+ placeholder="1.0"
+ data={annotation.settings}
+ onChange={handleSettingsChange}
+ autoComplete="off"
+ />
+ <TextInput
+ title="Fade Time"
+ name="duration"
+ className="number"
+ placeholder="0:01"
+ data={annotation.settings}
+ onChange={handleSettingsChange}
+ autoComplete="off"
+ />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/animism-align/frontend/app/views/editor/align/components/annotations/annotationForms/index.js b/animism-align/frontend/app/views/editor/align/components/annotations/annotationForms/index.js
new file mode 100644
index 0000000..8a1be48
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/components/annotations/annotationForms/index.js
@@ -0,0 +1,46 @@
+import {
+ AnnotationFormSectionHeading,
+ AnnotationFormTextPlate,
+ AnnotationFormFootnote,
+ AnnotationFormSubtitle,
+} from './annotationForm.text'
+
+import {
+ AnnotationFormVideo,
+ AnnotationFormVideoSetVolume,
+} from './annotationForm.video'
+
+import {
+ AnnotationFormImage,
+} from './annotationForm.image'
+
+import {
+ AnnotationFormGallery,
+ AnnotationFormGalleryAdvance,
+} from './annotationForm.gallery'
+
+import {
+ AnnotationFormCurtain,
+ AnnotationFormIntro,
+} from './annotationForm.utility'
+
+export const annotationFormLookup = {
+ section_heading: AnnotationFormSectionHeading,
+ text_plate: AnnotationFormTextPlate,
+ footnote: AnnotationFormFootnote,
+ subtitle: AnnotationFormSubtitle,
+
+ image: AnnotationFormImage,
+ video: AnnotationFormVideo,
+ video_set_volume: AnnotationFormVideoSetVolume,
+
+ gallery: AnnotationFormGallery,
+ carousel: AnnotationFormGallery,
+ grid: AnnotationFormGallery,
+ vitrine: AnnotationFormGallery,
+
+ gallery_advance: AnnotationFormGalleryAdvance,
+
+ intro: AnnotationFormIntro,
+ curtain: AnnotationFormCurtain,
+}
diff --git a/animism-align/frontend/app/views/editor/align/components/annotations/annotationTypes/annotationTypes.gallery.js b/animism-align/frontend/app/views/editor/align/components/annotations/annotationTypes/annotationTypes.gallery.js
new file mode 100644
index 0000000..c7df7f8
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/components/annotations/annotationTypes/annotationTypes.gallery.js
@@ -0,0 +1,75 @@
+import React, { Component } from 'react'
+
+import { durationToHeight } from 'app/utils/align.utils'
+import { capitalize } from 'app/utils'
+import { annotationFadeTimings } from 'app/utils/annotation.utils'
+
+import { checkAnnotationMediaNotReady, AnnotationMediaLoading } from './annotationTypes.utility'
+
+export const AnnotationGallery = ({ y, annotation, media, timeline, selected, onClick, onDoubleClick }) => {
+ const { text } = annotation
+ const className = selected ? 'annotation media gallery selected' : 'annotation media gallery'
+ if (checkAnnotationMediaNotReady(annotation, media)) {
+ return <AnnotationMediaLoading y={y} annotation={annotation} className={className} onClick={onClick} onDoubleClick={onDoubleClick} />
+ }
+ const mediaItem = media[annotation.settings.media_id]
+
+ const {
+ fadeInDuration, fadeOutDuration, duration,
+ start_ts, end_ts, fade_in_end_ts, fade_out_start_ts,
+ } = annotationFadeTimings(annotation)
+ const durationHeight = annotation.settings.fullscreen ? durationToHeight(duration, timeline) : 'auto'
+ const fadeInHeight = durationToHeight(fadeInDuration, timeline)
+ const fadeOutHeight = durationToHeight(fadeOutDuration, timeline)
+
+ const style = {
+ top: y,
+ }
+ if (annotation.settings.fullscreen && !annotation.settings.inline) {
+ style.height = durationHeight
+ }
+
+ return (
+ <div
+ className={className}
+ style={style}
+ onClick={e => onClick(e, annotation)}
+ onDoubleClick={e => onDoubleClick(e, annotation)}
+ >
+ <div className='meta center'>
+ <div>
+ {capitalize(annotation.type)}<br/>
+ <i>{mediaItem.title}</i><br />
+ {mediaItem.author}<br />
+ {mediaItem.date}
+ </div>
+ </div>
+ </div>
+ )
+}
+
+
+export const AnnotationGalleryAdvance = ({ y, annotation, media, timeline, selected, onClick, onDoubleClick }) => {
+ const className = selected ? 'annotation media gallery_advance selected' : 'annotation media gallery_advance'
+ const style = {
+ top: y,
+ }
+ let index = parseInt(annotation.settings.frame_index) + 1
+ if (annotation.settings.half_frame) {
+ index += 0.5
+ }
+ return (
+ <div
+ className={className}
+ style={style}
+ onClick={e => onClick(e, annotation)}
+ onDoubleClick={e => onDoubleClick(e, annotation)}
+ >
+ <div className='meta center'>
+ <div>
+ Advance gallery to frame {index}
+ </div>
+ </div>
+ </div>
+ )
+}
diff --git a/animism-align/frontend/app/views/editor/align/components/annotations/annotationTypes/annotationTypes.image.js b/animism-align/frontend/app/views/editor/align/components/annotations/annotationTypes/annotationTypes.image.js
new file mode 100644
index 0000000..d0a573e
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/components/annotations/annotationTypes/annotationTypes.image.js
@@ -0,0 +1,54 @@
+import React, { Component } from 'react'
+
+import { durationToHeight } from 'app/utils/align.utils'
+import { annotationFadeTimings, thumbnailURL } from 'app/utils/annotation.utils'
+
+import { checkAnnotationMediaNotReady, AnnotationMediaLoading } from './annotationTypes.utility'
+
+export const AnnotationImage = ({ y, annotation, media, timeline, selected, onClick, onDoubleClick }) => {
+ const { text } = annotation
+ const className = selected ? 'annotation media image selected' : 'annotation media image'
+ if (checkAnnotationMediaNotReady(annotation, media)) {
+ return <AnnotationMediaLoading y={y} annotation={annotation} className={className} onClick={onClick} onDoubleClick={onDoubleClick} />
+ }
+ const mediaItem = media[annotation.settings.media_id]
+
+ const {
+ fadeInDuration, fadeOutDuration, duration,
+ start_ts, end_ts, fade_in_end_ts, fade_out_start_ts,
+ } = annotationFadeTimings(annotation)
+ const durationHeight = annotation.settings.fullscreen ? durationToHeight(duration, timeline) : 'auto'
+ const fadeInHeight = durationToHeight(fadeInDuration, timeline)
+ const fadeOutHeight = durationToHeight(fadeOutDuration, timeline)
+
+ const style = {
+ top: y,
+ }
+ if (annotation.settings.fullscreen && !annotation.settings.inline) {
+ style.height = durationHeight
+ }
+
+ return (
+ <div
+ className={className}
+ style={style}
+ onClick={e => onClick(e, annotation)}
+ onDoubleClick={e => onDoubleClick(e, annotation)}
+ >
+ <div className='meta center'>
+ <div>
+ <i>{mediaItem.title}</i>{' - '}{mediaItem.author}
+ {mediaItem.type === 'gallery' && (
+ ' [Frame ' + (1 + parseInt(annotation.settings.frame_index)) + ']'
+ )}
+ </div>
+ </div>
+ </div>
+ )
+}
+
+/*
+ <div className='img'>
+ <img src={thumbnailURL(mediaItem)} alt={mediaItem.title} />
+ </div>
+*/ \ No newline at end of file
diff --git a/animism-align/frontend/app/views/editor/align/components/annotations/annotationTypes/annotationTypes.text.js b/animism-align/frontend/app/views/editor/align/components/annotations/annotationTypes/annotationTypes.text.js
new file mode 100644
index 0000000..ea65610
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/components/annotations/annotationTypes/annotationTypes.text.js
@@ -0,0 +1,121 @@
+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 AnnotationHeadingText = ({ y, annotation, selected, onClick, onDoubleClick }) => {
+ const { start_ts, text } = annotation
+ const className = selected ? 'annotation heading_text selected' : 'annotation heading_text'
+ return (
+ <div
+ className={className}
+ style={{ top: y }}
+ onClick={e => onClick(e, annotation)}
+ onDoubleClick={e => onDoubleClick(e, annotation)}
+ >
+ {text}
+ </div>
+ )
+}
+
+export const AnnotationSectionHeading = ({ y, annotation, selected, onClick, onDoubleClick }) => {
+ const { start_ts, text } = annotation
+ const className = selected ? 'annotation section_heading selected' : 'annotation section_heading'
+ 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>
+ )
+}
+
+export const AnnotationFootnote = ({ y, annotation, selected, onClick, onDoubleClick }) => {
+ const { start_ts, text, paragraph_id } = annotation
+ let className = !paragraph_id
+ ? 'annotation sentence footnote'
+ : (paragraph_id % 2)
+ ? 'annotation sentence footnote odd'
+ : 'annotation sentence footnote 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 AnnotationTextPlate = ({ y, annotation, selected, onClick, onDoubleClick }) => {
+ const { start_ts, text, paragraph_id } = annotation
+ let className = !paragraph_id
+ ? 'annotation text_plate'
+ : (paragraph_id % 2)
+ ? 'annotation text_plate odd'
+ : 'annotation text_plate 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 AnnotationSubtitle = ({ y, annotation, selected, onClick, onDoubleClick }) => {
+ const { start_ts, text, paragraph_id } = annotation
+ let className = !paragraph_id
+ ? 'annotation subtitle'
+ : (paragraph_id % 2)
+ ? 'annotation subtitle odd'
+ : 'annotation subtitle 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 }}
+ />
+ )
+}
diff --git a/animism-align/frontend/app/views/editor/align/components/annotations/annotationTypes/annotationTypes.utility.js b/animism-align/frontend/app/views/editor/align/components/annotations/annotationTypes/annotationTypes.utility.js
new file mode 100644
index 0000000..1238163
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/components/annotations/annotationTypes/annotationTypes.utility.js
@@ -0,0 +1,107 @@
+import React, { Component } from 'react'
+
+import { annotationFadeTimings } from 'app/utils/annotation.utils'
+import { durationToHeight } from 'app/utils/align.utils'
+
+export const AnnotationIntro = ({ y, annotation, timeline, selected, onClick, onDoubleClick }) => {
+ const className = selected ? 'annotation utility intro selected' : 'annotation utility intro'
+ const {
+ fadeInDuration, fadeOutDuration, duration,
+ start_ts, end_ts, fade_in_end_ts, fade_out_start_ts,
+ } = annotationFadeTimings(annotation)
+ const durationHeight = durationToHeight(duration, timeline)
+ const fadeInHeight = durationToHeight(fadeInDuration, timeline)
+ const fadeOutHeight = durationToHeight(fadeOutDuration, timeline)
+ let style = {
+ top: y,
+ }
+ if (annotation.settings.fullscreen) {
+ style.height = durationHeight
+ }
+ return (
+ <div
+ className={className}
+ style={style}
+ onClick={e => onClick(e, annotation)}
+ onDoubleClick={e => onDoubleClick(e, annotation)}
+ >
+ <div style={{ height: fadeInHeight }} className='fadeIn' />
+ <div style={{ height: fadeOutHeight }} className='fadeOut' />
+ <span style={{ top: fadeInHeight }}>
+ Intro:<br/>
+ {annotation.settings.title}<br />
+ {annotation.settings.subtitle}<br />
+ </span>
+ </div>
+ )
+}
+
+export const AnnotationSchedule = ({ y, annotation, timeline, selected, onClick, onDoubleClick }) => {
+ const className = selected ? 'annotation utility schedule selected' : 'annotation utility schedule'
+ let style = {
+ top: y,
+ }
+ return (
+ <div
+ className={className}
+ style={style}
+ onClick={e => onClick(e, annotation)}
+ onDoubleClick={e => onDoubleClick(e, annotation)}
+ >
+ Schedule
+ </div>
+ )
+}
+
+export const AnnotationCurtain = ({ y, annotation, timeline, selected, onClick, onDoubleClick }) => {
+ const className = selected ? 'annotation utility curtain selected' : 'annotation utility curtain'
+ const {
+ fadeInDuration, fadeOutDuration, duration,
+ start_ts, end_ts, fade_in_end_ts, fade_out_start_ts,
+ } = annotationFadeTimings(annotation)
+ const durationHeight = durationToHeight(duration, timeline)
+ const fadeInHeight = durationToHeight(fadeInDuration, timeline)
+ const fadeOutHeight = durationToHeight(fadeOutDuration, timeline)
+ return (
+ <div
+ className={className}
+ style={{ top: y, height: durationHeight }}
+ onClick={e => onClick(e, annotation)}
+ onDoubleClick={e => onDoubleClick(e, annotation)}
+ >
+ <div style={{ height: fadeInHeight }} className='fadeIn' />
+ <div style={{ height: fadeOutHeight }} className='fadeOut' />
+ <span style={{ top: fadeInHeight }}>
+ Curtain: {annotation.settings.color}.
+ {annotation.settings.curtain_text}
+ </span>
+ </div>
+ )
+}
+
+export const checkAnnotationMediaNotReady = (annotation, media) => {
+ return (!media) || (!(annotation.settings.media_id in media))
+}
+
+export const AnnotationMediaLoading = ({ y, annotation, media, className, onClick, onDoubleClick }) => {
+ if (!media) {
+ return (
+ <div
+ className={className}
+ style={{ top: y }}
+ onClick={e => onClick(e, annotation)}
+ onDoubleClick={e => onDoubleClick(e, annotation)}
+ >MEDIA NOT SET</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/editor/align/components/annotations/annotationTypes/annotationTypes.video.js b/animism-align/frontend/app/views/editor/align/components/annotations/annotationTypes/annotationTypes.video.js
new file mode 100644
index 0000000..4ea9595
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/components/annotations/annotationTypes/annotationTypes.video.js
@@ -0,0 +1,51 @@
+import React, { Component } from 'react'
+
+import { annotationFadeTimings } from 'app/utils/annotation.utils'
+import { durationToHeight } from 'app/utils/align.utils'
+import { checkAnnotationMediaNotReady, AnnotationMediaLoading } from './annotationTypes.utility'
+
+export const AnnotationVideo = ({ y, annotation, media, timeline, 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} annotation={annotation} className={className} onClick={onClick} onDoubleClick={onDoubleClick} />
+ }
+ const data = media[annotation.settings.media_id]
+ const {
+ fadeInDuration, fadeOutDuration, duration,
+ } = annotationFadeTimings(annotation)
+ const durationHeight = annotation.settings.fullscreen ? durationToHeight(duration, timeline) : 'auto'
+ return (
+ <div
+ className={className}
+ style={{ top: y, height: durationHeight }}
+ onClick={e => onClick(e, annotation)}
+ onDoubleClick={e => onDoubleClick(e, annotation)}
+ >
+ <div className='meta center'>
+ <div>
+ <i>{data.title}</i><br />
+ {data.author}<br />
+ {data.date}
+ </div>
+ </div>
+ </div>
+ )
+}
+
+export const AnnotationVideoSetVolume = ({ y, annotation, timeline, selected, onClick, onDoubleClick }) => {
+ const className = selected ? 'annotation utility video_set_volume selected' : 'annotation utility video_set_volume'
+ let style = {
+ top: y,
+ }
+ return (
+ <div
+ className={className}
+ style={style}
+ onClick={e => onClick(e, annotation)}
+ onDoubleClick={e => onDoubleClick(e, annotation)}
+ >
+ Set volume to {annotation.settings.volume}
+ </div>
+ )
+}
diff --git a/animism-align/frontend/app/views/editor/align/components/annotations/annotationTypes/index.js b/animism-align/frontend/app/views/editor/align/components/annotations/annotationTypes/index.js
new file mode 100644
index 0000000..1a89dbd
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/components/annotations/annotationTypes/index.js
@@ -0,0 +1,57 @@
+import React from 'react'
+
+import {
+ AnnotationSentence,
+ AnnotationHeadingText,
+ AnnotationSectionHeading,
+ AnnotationParagraphEnd,
+ AnnotationFootnote,
+ AnnotationTextPlate,
+ AnnotationSubtitle,
+} from './annotationTypes.text'
+
+import {
+ AnnotationVideo,
+ AnnotationVideoSetVolume,
+} from './annotationTypes.video'
+
+import {
+ AnnotationImage,
+} from './annotationTypes.image'
+
+import {
+ AnnotationGallery,
+ AnnotationGalleryAdvance,
+} from './annotationTypes.gallery'
+
+import {
+ AnnotationCurtain,
+ AnnotationIntro,
+ AnnotationSchedule,
+} from './annotationTypes.utility'
+
+export const AnnotationElementLookup = {
+ sentence: React.memo(AnnotationSentence),
+ pullquote_credit: React.memo(AnnotationSentence),
+ heading_text: React.memo(AnnotationHeadingText),
+ section_heading: React.memo(AnnotationSectionHeading),
+ paragraph_end: React.memo(AnnotationParagraphEnd),
+ footnote: React.memo(AnnotationFootnote),
+ text_plate: React.memo(AnnotationTextPlate),
+ subtitle: React.memo(AnnotationSubtitle),
+
+ video: React.memo(AnnotationVideo),
+ video_set_volume: React.memo(AnnotationVideoSetVolume),
+
+ image: React.memo(AnnotationImage),
+ gallery: React.memo(AnnotationGallery),
+ carousel: React.memo(AnnotationGallery),
+ grid: React.memo(AnnotationGallery),
+ vitrine: React.memo(AnnotationGallery),
+
+ gallery_advance: React.memo(AnnotationGalleryAdvance),
+
+ intro: React.memo(AnnotationIntro),
+ schedule: React.memo(AnnotationSchedule),
+ curtain: React.memo(AnnotationCurtain),
+}
diff --git a/animism-align/frontend/app/views/editor/align/components/player/playButton.component.js b/animism-align/frontend/app/views/editor/align/components/player/playButton.component.js
new file mode 100644
index 0000000..f411941
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/components/player/playButton.component.js
@@ -0,0 +1,22 @@
+import React from 'react'
+
+import actions from 'app/actions'
+
+const PlayButton = ({ playing, onClick }) => {
+ return (
+ <div
+ className={playing ? 'playButton playing' : 'playButton paused'}
+ onClick={() => {
+ if (onClick) {
+ onClick(playing)
+ } else if (playing) {
+ actions.audio.pause()
+ } else {
+ actions.audio.play()
+ }
+ }}
+ />
+ )
+}
+
+export default PlayButton
diff --git a/animism-align/frontend/app/views/editor/align/components/sidebar/script.component.js b/animism-align/frontend/app/views/editor/align/components/sidebar/script.component.js
new file mode 100644
index 0000000..6c20dfa
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/components/sidebar/script.component.js
@@ -0,0 +1,23 @@
+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'
+
+const Script = ({ text }) => {
+ if (text.loading) return null
+ return (
+ <textarea
+ className='script'
+ onChange={e => actions.site.updateText(e.target.value)}
+ value={text}
+ />
+ )
+}
+
+const mapStateToProps = state => ({
+ text: state.site.text,
+})
+
+export default connect(mapStateToProps)(Script)
diff --git a/animism-align/frontend/app/views/editor/align/components/sidebar/tableOfContents.component.js b/animism-align/frontend/app/views/editor/align/components/sidebar/tableOfContents.component.js
new file mode 100644
index 0000000..34aedb5
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/components/sidebar/tableOfContents.component.js
@@ -0,0 +1,30 @@
+import React, { Component } from 'react'
+// import { Link } from 'react-router-dom'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+
+import { ROMAN_NUMERALS } from 'app/constants'
+import actions from 'app/actions'
+
+class TableOfContents extends Component {
+ render() {
+ const { loading, order, lookup } = this.props.annotation
+ if (loading || !order) return null
+ const sectionIds = order.filter(id => lookup[id].type === "section_heading")
+ return (
+ <div className="toc">
+ {sectionIds.map((id, i) => (
+ <div key={id} onClick={() => actions.align.setScrollPosition(lookup[id].start_ts)}>
+ {ROMAN_NUMERALS[i]}{'. '}{lookup[id].text}
+ </div>
+ ))}
+ </div>
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ annotation: state.annotation.index,
+})
+
+export default connect(mapStateToProps)(TableOfContents)
diff --git a/animism-align/frontend/app/views/editor/align/components/timeline/cursor.component.js b/animism-align/frontend/app/views/editor/align/components/timeline/cursor.component.js
new file mode 100644
index 0000000..4a94100
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/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/editor/align/components/timeline/cursorRegion.component.js b/animism-align/frontend/app/views/editor/align/components/timeline/cursorRegion.component.js
new file mode 100644
index 0000000..a0c9bd7
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/components/timeline/cursorRegion.component.js
@@ -0,0 +1,28 @@
+import React from 'react'
+
+import { ZOOM_STEPS } from 'app/constants'
+import { timestamp } from 'app/utils'
+
+const CursorRegion = ({ timeline }) => {
+ const { start_ts, zoom, cursor_region } = timeline
+ if (!cursor_region) return null
+ const secondsPerPixel = ZOOM_STEPS[zoom] * 0.1
+ const duration = cursor_region.b_ts - cursor_region.a_ts
+ const y = (cursor_region.a_ts - start_ts) / secondsPerPixel
+ const height = (duration) / secondsPerPixel
+ return (
+ <div
+ className='cursor_region'
+ style={{
+ top: y,
+ height,
+ }}
+ >
+ <div className='tickLabel'>
+ {timestamp(duration, 1, true)}
+ </div>
+ </div>
+ )
+}
+
+export default CursorRegion \ No newline at end of file
diff --git a/animism-align/frontend/app/views/editor/align/components/timeline/playCursor.component.js b/animism-align/frontend/app/views/editor/align/components/timeline/playCursor.component.js
new file mode 100644
index 0000000..854c43b
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/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/editor/align/components/timeline/ticks.component.js b/animism-align/frontend/app/views/editor/align/components/timeline/ticks.component.js
new file mode 100644
index 0000000..4530863
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/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/editor/align/components/timeline/waveform.component.js b/animism-align/frontend/app/views/editor/align/components/timeline/waveform.component.js
new file mode 100644
index 0000000..59a2c13
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/components/timeline/waveform.component.js
@@ -0,0 +1,105 @@
+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}
+ onMouseDown={this.props.onMouseDown}
+ onMouseUp={this.props.onMouseUp}
+ />
+ )
+ }
+}
+
+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/editor/align/containers/annotations.container.js b/animism-align/frontend/app/views/editor/align/containers/annotations.container.js
new file mode 100644
index 0000000..cfcf7be
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/containers/annotations.container.js
@@ -0,0 +1,28 @@
+import React, { Component } from 'react'
+import { connect } from 'react-redux'
+
+import AnnotationForm from 'app/views/editor/align/components/annotations/annotation.form'
+import AnnotationIndex from 'app/views/editor/align/components/annotations/annotation.index'
+
+class Annotations extends Component {
+ constructor(props){
+ super(props)
+ }
+ render() {
+ return (
+ <div className='annotations'>
+ <AnnotationIndex />
+ {this.props.annotation.id &&
+ <AnnotationForm />
+ }
+ </div>
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ timeline: state.align.timeline,
+ annotation: state.align.annotation,
+})
+
+export default connect(mapStateToProps)(Annotations)
diff --git a/animism-align/frontend/app/views/editor/align/containers/sidebar.container.js b/animism-align/frontend/app/views/editor/align/containers/sidebar.container.js
new file mode 100644
index 0000000..11f5314
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/containers/sidebar.container.js
@@ -0,0 +1,35 @@
+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 Script from '../components/sidebar/script.component.js'
+import TableOfContents from '../components/sidebar/tableOfContents.component.js'
+
+export default class Sidebar extends Component {
+ state = {
+ mode: "toc",
+ }
+ render() {
+ if (this.state.mode === "toc") {
+ return (
+ <div className='sidebar'>
+ <button onClick={() => this.setState({ mode: "script" })}>
+ +
+ </button>
+ <TableOfContents />
+ </div>
+ )
+ }
+ return (
+ <div className='sidebar'>
+ <Script />
+ <button onClick={() => this.setState({ mode: "toc" })}>
+ x
+ </button>
+ </div>
+ )
+ }
+}
diff --git a/animism-align/frontend/app/views/editor/align/containers/timeline.container.js b/animism-align/frontend/app/views/editor/align/containers/timeline.container.js
new file mode 100644
index 0000000..feb4f6a
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/align/containers/timeline.container.js
@@ -0,0 +1,196 @@
+import React, { Component } from 'react'
+// import { Link } from 'react-router-dom'
+import { connect } from 'react-redux'
+
+import actions from 'app/actions'
+
+import Annotations from 'app/views/editor/align/containers/annotations.container'
+import Waveform from 'app/views/editor/align/components/timeline/waveform.component'
+import Ticks from 'app/views/editor/align/components/timeline/ticks.component'
+import Cursor from 'app/views/editor/align/components/timeline/cursor.component'
+import PlayCursor from 'app/views/editor/align/components/timeline/playCursor.component'
+import CursorRegion from 'app/views/editor/align/components/timeline/cursorRegion.component'
+
+import { WAVEFORM_SIZE, ZOOM_STEPS, INNER_HEIGHT } from 'app/constants'
+import { clamp } from 'app/utils'
+import { positionToTime } from 'app/utils/align.utils'
+
+class Timeline extends Component {
+ state = {
+ dragging: false,
+ a_ts: -1,
+ }
+ 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.handleTimelineMouseDown = this.handleTimelineMouseDown.bind(this)
+ this.handleTimelineMouseUp = this.handleTimelineMouseUp.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
+ case 68: // D
+ e.preventDefault()
+ actions.align.cloneSelectedAnnotation(selectedAnnotation)
+ }
+ 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)
+ }
+ }
+ handleContainerClick(e) {
+ actions.align.clearSelectedAnnotation()
+ actions.align.clearSelectedParagraph()
+ }
+ handleTimelineMouseDown(e) {
+ const cursor_ts = positionToTime(e.pageY, this.props.timeline)
+ actions.align.clearCursorRegion()
+ actions.align.setCursor(cursor_ts)
+ this.setState({
+ dragging: true,
+ a_ts: cursor_ts,
+ })
+ }
+ handleMouseMove(e) {
+ const cursor_ts = positionToTime(e.pageY, this.props.timeline)
+ if (this.state.dragging) {
+ actions.align.setCursorRegion(
+ Math.min(this.state.a_ts, cursor_ts),
+ Math.max(this.state.a_ts, cursor_ts),
+ )
+ } else {
+ actions.align.setCursor(cursor_ts)
+ }
+ }
+ handleTimelineMouseUp(e) {
+ this.setState({ dragging: false })
+ const play_ts = positionToTime(e.pageY, this.props.timeline)
+ if (e.metaKey) {
+ actions.align.spliceTime(play_ts)
+ } else 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
+ onMouseDown={this.handleTimelineMouseDown}
+ onMouseUp={this.handleTimelineMouseUp}
+ />
+ <Ticks timeline={this.props.timeline} />
+ <Cursor timeline={this.props.timeline} annotation={this.props.annotation} />
+ <CursorRegion timeline={this.props.timeline} />
+ </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,
+})
+
+export default connect(mapStateToProps)(Timeline)
diff --git a/animism-align/frontend/app/views/editor/annotation/annotation.reducer.js b/animism-align/frontend/app/views/editor/annotation/annotation.reducer.js
new file mode 100644
index 0000000..50232a9
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/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/editor/editor.container.js b/animism-align/frontend/app/views/editor/editor.container.js
new file mode 100644
index 0000000..563f50d
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/editor.container.js
@@ -0,0 +1,22 @@
+import React, { Component } from 'react'
+import { Route } from 'react-router'
+
+import EditorGate from 'app/views/editor/editor.gate'
+import AlignContainer from 'app/views/editor/align/align.container'
+import ParagraphContainer from 'app/views/editor/paragraph/paragraph.container'
+import MediaContainer from 'app/views/editor/media/media.container'
+import ViewerContainer from 'app/views/viewer/viewer.container'
+
+export default class Router extends Component {
+ render() {
+ return (
+ <EditorGate episode_id={this.props.match.params.episode_id}>
+ <Route path={"/editor/:episode_id/timeline/"} component={AlignContainer} />
+ <Route path={"/editor/:episode_id/transcript/"} component={ParagraphContainer} />
+ <Route path={"/editor/:episode_id/media/"} component={MediaContainer} />
+ <Route path={"/editor/:episode_id/viewer/"} component={ViewerContainer} />
+ <Route path={"/editor/:episode_id/"} exact component={MediaContainer} />
+ </EditorGate>
+ )
+ }
+}
diff --git a/animism-align/frontend/app/views/editor/editor.gate.js b/animism-align/frontend/app/views/editor/editor.gate.js
new file mode 100644
index 0000000..968680f
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/editor.gate.js
@@ -0,0 +1,32 @@
+import React, { Component } from 'react'
+
+import { Loader } from 'app/common'
+
+import actions from 'app/actions'
+
+export default class EditorGate extends Component {
+ constructor(props) {
+ super(props)
+ this.load()
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.episode_id !== prevProps.episode_id) {
+ this.load()
+ }
+ }
+
+ load() {
+ this.props.episode_id && actions.site.loadEpisode()
+ }
+
+ render() {
+ if (this.props.logged_in) {
+ return this.props.children
+ }
+ return (
+ <Loader />
+ )
+ }
+}
+
diff --git a/animism-align/frontend/app/views/editor/media/components/media.form.js b/animism-align/frontend/app/views/editor/media/components/media.form.js
new file mode 100644
index 0000000..2d21838
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/media/components/media.form.js
@@ -0,0 +1,287 @@
+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'
+import MediaFileForm from './media.formFile'
+import MediaGalleryForm from './media.formGallery'
+
+const newMedia = () => ({
+ type: 'gallery',
+ tag: 'media',
+ url: '',
+ title: '',
+ author: '',
+ pre_title: '',
+ post_title: '',
+ translated_title: '',
+ date: '',
+ source: '',
+ medium: '',
+ start_ts: 0,
+ settings: {},
+})
+
+const MEDIA_UPLOAD_TYPES = [
+ 'image', 'video', 'file', 'gallery',
+].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_UPLOAD_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}
+ />
+ }
+
+ {data.type === 'file' &&
+ <MediaFileForm
+ data={data}
+ onChange={this.handleSelect}
+ onSettingsChange={this.handleSettingsChange}
+ />
+ }
+
+ {data.type === 'gallery' &&
+ <MediaGalleryForm
+ 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 checklist"
+ name="hide_in_bibliography"
+ checked={data.settings.hide_in_bibliography}
+ onChange={this.handleSettingsChange}
+ />
+ <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/editor/media/components/media.formFile.js b/animism-align/frontend/app/views/editor/media/components/media.formFile.js
new file mode 100644
index 0000000..d3b1ae8
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/media/components/media.formFile.js
@@ -0,0 +1,67 @@
+import React, { Component } from 'react'
+import { Link } from 'react-router-dom'
+
+import { session } from 'app/session'
+import actions from 'app/actions'
+import { capitalize } from 'app/utils'
+
+import { TextInput, LabelDescription, Select, TextArea, Checkbox, SubmitButton, Loader, FileInputField } from 'app/common'
+
+export default class MediaFileForm 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)
+ }
+
+ 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) {
+ console.log('uploading file')
+ const uploadData = {
+ image: file,
+ tag: "file",
+ username: 'animism',
+ }
+ // uploadData['__image_filename'] = file.filename
+ return actions.upload.upload(uploadData).then(data => {
+ this.handleSettingsChange("file", data.res)
+ })
+ }
+
+ render() {
+ const { data } = this.props
+ console.log(data.settings)
+ return (
+ <div className='fileForm'>
+ <FileInputField
+ title="Upload file"
+ mime="*/*"
+ onChange={this.handleUpload}
+ />
+ {data.settings.file &&
+ <LabelDescription>
+ {data.settings.file.url}
+ </LabelDescription>
+ }
+ </div>
+ )
+ }
+}
diff --git a/animism-align/frontend/app/views/editor/media/components/media.formGallery.js b/animism-align/frontend/app/views/editor/media/components/media.formGallery.js
new file mode 100644
index 0000000..2cf894b
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/media/components/media.formGallery.js
@@ -0,0 +1,338 @@
+import React, { Component } from 'react'
+import { Link } from 'react-router-dom'
+import { ReactSortable } from "react-sortablejs"
+
+import actions from 'app/actions'
+import { capitalize, simpleArraysEqual } from 'app/utils'
+import { preloadImage } from 'app/utils/image.utils'
+import { DISPLAY_SIZE, DISPLAY_QUALITY, THUMBNAIL_SIZE, THUMBNAIL_QUALITY } from 'app/constants'
+
+import { TextInput, LabelDescription, FileInputField, Select, TextArea, Checkbox, SubmitButton, Loader } from 'app/common'
+import { renderThumbnail } from 'app/common/upload.helpers'
+
+import GalleryImageForm from './media.formGalleryImage'
+
+export default class MediaGalleryForm extends Component {
+ state = {
+ loading: false,
+ edit_image_id: 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.uploadSize = this.uploadSize.bind(this)
+ this.handleSaveItem = this.handleSaveItem.bind(this)
+ this.handleUploadGalleryThumbnail = this.handleUploadGalleryThumbnail.bind(this)
+ this.handleDestroyGalleryThumbnail = this.handleDestroyGalleryThumbnail.bind(this)
+ }
+
+ componentDidMount() {
+ const { data } = this.props
+ this.handleSettingsChange('multiple', {
+ image_order: data.settings.image_order || [],
+ image_lookup: data.settings.image_lookup || {},
+ caption_lookup: data.settings.caption_lookup || {},
+ thumbnail_lookup: data.settings.thumbnail_lookup || {},
+ })
+ }
+
+ 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(files) {
+ const { data } = this.props
+ this.setState({ loading: true })
+ this.uploadFullsize(files)
+ .then(() => {
+ this.setState({ loading: false })
+ })
+ }
+
+ handleUploadGalleryThumbnail(file) {
+ this.setState({ loading: true })
+ if (this.props.data.thumbnail) {
+ this.handleDestroyGalleryThumbnail()
+ }
+ this.uploadThumbnail(file, 'thumbnail', THUMBNAIL_SIZE, THUMBNAIL_QUALITY)
+ .then(thumbnail => {
+ this.uploadThumbnail(file, 'display', DISPLAY_SIZE, DISPLAY_QUALITY)
+ .then(display => {
+ console.log(thumbnail, display)
+ this.handleSettingsChange('multiple', {
+ thumbnail, display,
+ })
+ this.setState({ loading: false })
+ })
+ })
+ }
+
+ handleDestroyGalleryThumbnail(e) {
+ if (e) {
+ e.preventDefault()
+ e.stopPropagation()
+ }
+ console.log(this.props.data)
+ actions.upload.destroy({ id: this.props.data.settings.thumbnail.id })
+ }
+
+ uploadFullsize(files) {
+ const { data } = this.props
+ // first, upload all the fullsize files
+ let fullsizeUploadPromises = files.map(file => {
+ return this.uploadSize(file, 'fullsize')
+ })
+ // when these are done
+ return Promise.all(fullsizeUploadPromises).then(results => {
+ // get the added IDs in order
+ const added_image_order = results.map(result => result.id)
+ // append the new IDs to the image order
+ const new_image_order = (data.settings.image_order || []).concat(added_image_order)
+ // add the images to the lookup
+ const image_lookup = results.reduce((a,b) => {
+ a[b.id] = b
+ return a
+ }, (data.settings.image_lookup || {}))
+ // add these images to the settings object
+ this.handleSettingsChange('multiple', {
+ image_order: new_image_order,
+ image_lookup: image_lookup,
+ caption_lookup: data.settings.caption_lookup || {},
+ thumbnail_lookup: data.settings.thumbnail_lookup || {},
+ })
+ return this.uploadResizedFiles(files, added_image_order)
+ })
+ }
+
+ uploadResizedFiles(files, added_image_order) {
+ return (
+ this.uploadThumbnails(files, added_image_order, 'thumbnail', THUMBNAIL_SIZE, THUMBNAIL_QUALITY)
+ .then(() => {
+ return this.uploadThumbnails(files, added_image_order, 'display', DISPLAY_SIZE, DISPLAY_QUALITY)
+ })
+ )
+ }
+
+ uploadThumbnails(files, added_image_order, tag, maxSide, quality) {
+ const { data } = this.props
+ // construct thumbnails and upload these
+ const thumbnailUploadPromises = files.map(file => {
+ return this.uploadThumbnail(file, tag, maxSide, quality)
+ })
+ // once the thumbnails are done uploading...
+ return Promise.all(thumbnailUploadPromises).then(thumbnail_results => {
+ // decide which lookup we're adding to
+ const tag_lookup_name = tag + '_lookup'
+ const tag_lookup = data.settings[tag_lookup_name] || {}
+ // add them to the thumbnail lookup, keyed off the ID of the fullsize image
+ const thumbnail_lookup = thumbnail_results.reduce((a, b, i) => {
+ const id = added_image_order[i]
+ a[id] = b
+ return a
+ }, tag_lookup)
+ // update the settings object
+ this.handleSettingsChange('multiple', {
+ [tag_lookup_name]: thumbnail_lookup,
+ })
+ })
+ }
+
+ uploadThumbnail(file, tag, maxSide, quality) {
+ return new Promise((resolve, reject) => {
+ const type = (file.name.match('.png') !== -1) ? 'image/png' : 'image/jpg'
+ const fr = new FileReader()
+ fr.onload = fileReaderEvent => {
+ fr.onload = null
+ const image = new Image()
+ image.onload = () => {
+ image.onload = null
+ const thumbnailCanvas = renderThumbnail(image, { maxSide })
+ thumbnailCanvas.toBlob(thumbnail => {
+ this.uploadSize(thumbnail, tag, file.name)
+ .then(res => {
+ resolve(res)
+ })
+ .catch(err => {
+ reject(err)
+ })
+ }, type, quality)
+ }
+ image.src = fileReaderEvent.target.result
+ }
+ fr.readAsDataURL(file)
+ })
+ }
+
+ uploadSize(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
+ })
+ }
+
+ handleOrderChanged(new_image_order) {
+ // console.log(new_image_order)
+ const image_order = new_image_order.map(el => el.id)
+ if (!simpleArraysEqual(image_order, this.props.data.settings.image_order)) {
+ this.handleSettingsChange('image_order', image_order)
+ }
+ }
+
+ handleSaveItem(id, item, editNext) {
+ if (!id) {
+ this.setState({ edit_image_id: null })
+ }
+ const caption_lookup = this.props.data.settings.caption_lookup || {}
+ caption_lookup[id] = item
+ this.handleSettingsChange('caption_lookup', caption_lookup)
+ if (editNext) {
+ const image_order = this.props.data.settings.image_order
+ const index = image_order.indexOf(id) + 1
+ if (index < image_order.length) {
+ this.setState({ edit_image_id: image_order[index] })
+ } else {
+ this.setState({ edit_image_id: null })
+ }
+ } else {
+ this.setState({ edit_image_id: null })
+ }
+ }
+
+ handleDeleteItem(id) {
+ let { image_order, image_lookup, thumbnail_lookup, display_lookup, caption_lookup } = this.props.data.settings
+ const new_image_order = image_order.filter(n => n !== id)
+ caption_lookup = caption_lookup || {}
+ const image_lookup_upload_id = ((image_lookup && image_lookup[id]) || {}).id
+ if (image_lookup_upload_id) {
+ actions.upload.destroy({ id: image_lookup_upload_id })
+ }
+ const thumbnail_lookup_upload_id = ((thumbnail_lookup && thumbnail_lookup[id]) || {}).id
+ if (thumbnail_lookup_upload_id) {
+ actions.upload.destroy({ id: thumbnail_lookup_upload_id })
+ }
+ const display_lookup_upload_id = ((display_lookup && display_lookup[id]) || {}).id
+ if (display_lookup_upload_id) {
+ actions.upload.destroy({ id: display_lookup_upload_id })
+ }
+ image_lookup && delete image_lookup[id]
+ thumbnail_lookup && delete thumbnail_lookup[id]
+ display_lookup && delete display_lookup[id]
+ caption_lookup && delete caption_lookup[id]
+ this.handleSettingsChange('multiple', {
+ image_order: new_image_order,
+ image_lookup: { ...image_lookup },
+ thumbnail_lookup: { ...thumbnail_lookup },
+ display_lookup: { ...display_lookup },
+ caption_lookup: { ...caption_lookup },
+ })
+ }
+
+ render() {
+ const { data } = this.props
+ const { image_order, image_lookup, thumbnail_lookup, caption_lookup } = data.settings
+ const { loading, edit_image_id } = this.state
+ // console.log(data)
+ return (
+ <div className='galleryForm'>
+ <FileInputField
+ multiple
+ title="Upload images"
+ mime="*/*"
+ onChange={this.handleUpload}
+ />
+ {loading && <Loader />}
+ {image_order && image_order.length &&
+ <ReactSortable
+ className='galleryList'
+ list={image_order.map(id => ({ id }))}
+ setList={new_order => this.handleOrderChanged(new_order)}
+ >
+ {image_order.map(image_id => (
+ <GalleryListItem
+ id={image_id}
+ key={image_id}
+ image={image_lookup[image_id]}
+ thumbnail={thumbnail_lookup[image_id]}
+ onEdit={e => {
+ e.preventDefault()
+ e.stopPropagation()
+ this.setState({ edit_image_id: image_id })
+ }}
+ onDestroy={e => {
+ e.preventDefault()
+ e.stopPropagation()
+ this.handleDeleteItem(image_id)
+ }}
+ />
+ ))}
+ </ReactSortable>
+ }
+ {edit_image_id &&
+ <GalleryImageForm
+ id={edit_image_id}
+ initialData={initialCaptionData(caption_lookup, edit_image_id)}
+ thumbnail={thumbnail_lookup[edit_image_id]}
+ onSave={this.handleSaveItem}
+ />
+ }
+ <FileInputField
+ title="Upload thumbnail"
+ mime="*/*"
+ onChange={this.handleUploadGalleryThumbnail}
+ />
+ {data.settings.thumbnail &&
+ <div className='label'>
+ <span>Thumbnail</span>
+ <img src={data.settings.thumbnail.url} />
+ <button onClick={this.handleDestroyGalleryThumbnail}>x</button>
+ </div>
+ }
+ </div>
+ )
+ }
+}
+
+const initialCaptionData = (caption_lookup, image_id) => {
+ caption_lookup = caption_lookup || {}
+ return caption_lookup[image_id] || {}
+}
+
+const GalleryListItem = ({ id, key, image, thumbnail, onEdit, onDestroy }) => {
+ // console.log(image, thumbnail)
+ return (
+ <div className='galleryListItem'>
+ {thumbnail
+ ? (
+ <div>
+ <div><img src={thumbnail.url} /></div>
+ <button onClick={onEdit}>Edit</button>
+ <button onClick={onDestroy}>x</button>
+ </div>
+ ) : <Loader />
+ }
+ </div>
+ )
+}
diff --git a/animism-align/frontend/app/views/editor/media/components/media.formGalleryImage.js b/animism-align/frontend/app/views/editor/media/components/media.formGalleryImage.js
new file mode 100644
index 0000000..4d2b99c
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/media/components/media.formGalleryImage.js
@@ -0,0 +1,129 @@
+import React, { Component } from 'react'
+
+import { TextInput, LabelDescription, FileInputField, Select, TextArea, Checkbox, SubmitButton, Loader } from 'app/common'
+
+export default class GalleryImageForm extends Component {
+ state = {
+ loaded: false,
+ data: {},
+ }
+ constructor(props){
+ super(props)
+ this.handleChange = this.handleChange.bind(this)
+ this.handleSelect = this.handleSelect.bind(this)
+ this.handleSubmit = this.handleSubmit.bind(this)
+ this.handleSubmitAndEditNext = this.handleSubmitAndEditNext.bind(this)
+ this.handleCancel = this.handleCancel.bind(this)
+ }
+ componentDidMount() {
+ this.setState({
+ loaded: true,
+ data: { ...this.props.initialData },
+ })
+ }
+ componentDidUpdate(prevProps) {
+ if (this.props.id !== prevProps.id) {
+ this.setState({
+ data: { ...this.props.initialData },
+ })
+ }
+ }
+ handleChange(e) {
+ const { name, value } = e.target
+ this.handleSelect(name, value)
+ }
+ handleSelect(name, value) {
+ this.setState({
+ data: {
+ ...this.state.data,
+ [name]: value,
+ }
+ })
+ }
+ handleSubmit(e) {
+ e.preventDefault()
+ e.stopPropagation()
+ this.props.onSave(this.props.id, this.state.data)
+ }
+ handleSubmitAndEditNext(e) {
+ e.preventDefault()
+ e.stopPropagation()
+ this.props.onSave(this.props.id, this.state.data, true)
+ }
+ handleCancel(e) {
+ e.preventDefault()
+ e.stopPropagation()
+ this.props.onSave(null)
+ }
+ render() {
+ const { thumbnail } = this.props
+ const { loaded, data } = this.state
+ if (!loaded) return <div />
+ return (
+ <div className='modal visible'>
+ <div className='row'>
+ <div>
+ <img src={thumbnail.url} />
+ </div>
+ <div>
+ <TextInput
+ title="Author"
+ name="author"
+ data={data}
+ onChange={this.handleChange}
+ autoComplete="off"
+ />
+ <TextInput
+ title="Title"
+ name="title"
+ data={data}
+ onChange={this.handleChange}
+ autoComplete="off"
+ />
+ <TextInput
+ title="Date"
+ name="date"
+ data={data}
+ onChange={this.handleChange}
+ autoComplete="off"
+ />
+ <TextInput
+ title="Medium"
+ name="medium"
+ data={data}
+ onChange={this.handleChange}
+ autoComplete="off"
+ />
+ <Checkbox
+ label="Include in checklist"
+ name="in_checklist"
+ checked={data.in_checklist}
+ onChange={this.handleSelect}
+ />
+ <TextArea
+ title="Short caption"
+ name="caption"
+ placeholder="Used on inline galleries"
+ data={data}
+ onChange={this.handleChange}
+ />
+ <TextArea
+ title="Long caption"
+ name="long_caption"
+ placeholder="Used on detail views of the item, suitable for longer texts"
+ data={data}
+ onChange={this.handleChange}
+ />
+ <div className='label'>
+ <span></span>
+ <div className='buttons'>
+ <button onClick={this.handleSubmit}>Save and close</button>
+ <button onClick={this.handleSubmitAndEditNext}>Save and edit next</button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ )
+ }
+}
diff --git a/animism-align/frontend/app/views/editor/media/components/media.formImage.js b/animism-align/frontend/app/views/editor/media/components/media.formImage.js
new file mode 100644
index 0000000..dbbf69f
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/media/components/media.formImage.js
@@ -0,0 +1,169 @@
+import React, { Component } from 'react'
+import { Link } from 'react-router-dom'
+
+import { session } from 'app/session'
+import actions from 'app/actions'
+import { capitalize } from 'app/utils'
+import { preloadImage, cropImage } from 'app/utils/image.utils'
+import { DISPLAY_SIZE, DISPLAY_QUALITY, THUMBNAIL_SIZE, THUMBNAIL_QUALITY } from 'app/constants'
+
+import { TextInput, LabelDescription, UploadImage, Select, TextArea, Checkbox, SubmitButton, Loader } from 'app/common'
+import { renderThumbnail } from 'app/common/upload.helpers'
+
+import ImageSelection from './media.formImageSelection'
+
+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/editor/media/components/media.formImageSelection.js b/animism-align/frontend/app/views/editor/media/components/media.formImageSelection.js
new file mode 100644
index 0000000..966eb58
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/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/editor/media/components/media.formVideo.js b/animism-align/frontend/app/views/editor/media/components/media.formVideo.js
new file mode 100644
index 0000000..315925c
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/media/components/media.formVideo.js
@@ -0,0 +1,118 @@
+import React, { Component } from 'react'
+import { Link } from 'react-router-dom'
+import VimeoPlayer from '@u-wave/react-vimeo'
+
+import actions from 'app/actions'
+import { capitalize } from 'app/utils'
+import { posterURL } from 'app/utils/annotation.utils'
+import { TextInput, LabelDescription, Select, TextArea, Checkbox, SubmitButton, Loader, FileInputField } from 'app/common'
+
+import { getVimeoMetadata } from 'app/views/editor/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)
+ this.handleUpload = this.handleUpload.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)
+ }
+
+ handleUpload(file) {
+ console.log('uploading poster image')
+ const uploadData = {
+ image: file,
+ tag: "poster",
+ username: 'animism',
+ }
+ // uploadData['__image_filename'] = file.filename
+ return actions.upload.upload(uploadData).then(data => {
+ this.handleSettingsSelect("poster", data.res)
+ })
+ }
+
+ render() {
+ const { data } = this.props
+ const poster_url = posterURL(data)
+ 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>
+
+ {poster_url &&
+ <LabelDescription className='thumbnail'>
+ <a href={poster_url} target="_blank">
+ <img src={poster_url} />
+ </a>
+ </LabelDescription>
+ }
+
+ <FileInputField
+ title="Poster image"
+ mime="*/*"
+ onChange={this.handleUpload}
+ />
+
+ <TextArea
+ title="Subtitles"
+ name="subtitles"
+ required
+ data={data.settings}
+ onChange={this.handleSettingsChange}
+ autoComplete="off"
+ />
+ </div>
+ )}
+ </div>
+ )
+ }
+}
diff --git a/animism-align/frontend/app/views/editor/media/components/media.indexOptions.js b/animism-align/frontend/app/views/editor/media/components/media.indexOptions.js
new file mode 100644
index 0000000..d8187c9
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/media/components/media.indexOptions.js
@@ -0,0 +1,59 @@
+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}
+ />
+ </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/editor/media/components/media.menu.js b/animism-align/frontend/app/views/editor/media/components/media.menu.js
new file mode 100644
index 0000000..b782cdc
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/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/editor/media/containers/media.edit.js b/animism-align/frontend/app/views/editor/media/containers/media.edit.js
new file mode 100644
index 0000000..cf5f671
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/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/editor/media/containers/media.index.js b/animism-align/frontend/app/views/editor/media/containers/media.index.js
new file mode 100644
index 0000000..da94d3c
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/media/containers/media.index.js
@@ -0,0 +1,160 @@
+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/utils/annotation.utils'
+
+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>Galleries</h2>
+ {order.filter(id => lookup[id].type === 'gallery').map(id => <GalleryItem key={id} data={lookup[id]} />)}
+ <h2>Videos</h2>
+ {order.filter(id => lookup[id].type === 'video' && !lookup[id].medium.match(/book/i)).map(id => <MediaItem key={id} data={lookup[id]} />)}
+ <h2>Books</h2>
+ {order.filter(id => lookup[id].type === 'video' && lookup[id].medium.match(/book/i)).map(id => <MediaItem key={id} data={lookup[id]} />)}
+ <h2>Files</h2>
+ {order.filter(id => lookup[id].type === 'file').map(id => <FileItem 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 GalleryItem = ({ data }) => {
+ // console.log(data)
+ return (
+ <div className='cell'>
+ <div className='meta center'>
+ <div className='img'>
+ <Link to={"/media/" + data.id + "/edit/"}>
+ <img src={thumbnailURL(data)} alt={data.title} />
+ </Link>
+ </div>
+ <Link to={"/media/" + data.id + "/edit/"}>
+ <div>
+ <i>{data.title}</i><br />
+ {data.author}<br />
+ {data.date}
+ </div>
+ </Link>
+ </div>
+ </div>
+ )
+}
+
+const FileItem = ({ data }) => {
+ // console.log(data)
+ return (
+ <div className='cell'>
+ <div className='meta center'>
+ <Link to={"/media/" + data.id + "/edit/"}>
+ <div>
+ <i>{data.title}</i><br />
+ {data.settings.file.url}<br />
+ </div>
+ </Link>
+ </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/editor/media/containers/media.new.js b/animism-align/frontend/app/views/editor/media/containers/media.new.js
new file mode 100644
index 0000000..c193c2f
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/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/editor/media/media.actions.js b/animism-align/frontend/app/views/editor/media/media.actions.js
new file mode 100644
index 0000000..9919e46
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/media/media.actions.js
@@ -0,0 +1,9 @@
+import * as types from 'app/types'
+import { 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/editor/media/media.container.js b/animism-align/frontend/app/views/editor/media/media.container.js
new file mode 100644
index 0000000..b597a6c
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/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/editor/media/media.css b/animism-align/frontend/app/views/editor/media/media.css
new file mode 100644
index 0000000..ebf8c33
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/media/media.css
@@ -0,0 +1,110 @@
+.app > .media {
+ width: 100%;
+ height: calc(100% - 3.125rem);
+ overflow: scroll;
+}
+
+.results .cell {
+ margin-bottom: 1rem;
+ margin-right: 1rem;
+ max-width: 295px;
+}
+.results h2 {
+ display: block;
+ width: 100%;
+}
+.media .results .meta > div {
+ max-width: 100%;
+}
+.media .results .img img {
+ max-width: 295px;
+}
+
+/* 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;
+}
+.videoForm iframe {
+ pointer-events: auto;
+}
+
+/* image crop */
+
+.imageSelection {
+ width: 30rem;
+ position: relative;
+}
+.imageSelection img {
+ display: block;
+ max-width: 100%;
+}
+.imageSelection img.loading {
+ opacity: 0.5;
+}
+.imageSelection .box {
+ position: absolute;
+ background: rgba(255,32,64,0.05);
+ border: 1px solid #f24;
+}
+
+/* image galleries */
+
+.galleryListItem > div {
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+ margin-bottom: 0.25rem;
+}
+.galleryListItem:nth-child(odd) > div > div {
+ background: #333;
+}
+.galleryListItem:nth-child(even) > div > div {
+ background: #444;
+}
+.galleryListItem > div > div {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 5rem;
+ height: 5rem;
+ padding: 0.5rem;
+}
+.galleryListItem img {
+ max-height: 100%;
+ cursor: grab;
+ max-width: 100%;
+}
+.galleryListItem button {
+ margin-left: 1rem;
+ background: #000;
+ color: #88d;
+}
diff --git a/animism-align/frontend/app/views/editor/media/media.reducer.js b/animism-align/frontend/app/views/editor/media/media.reducer.js
new file mode 100644
index 0000000..f13f9de
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/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/editor/paragraph/components/paragraph.form.js b/animism-align/frontend/app/views/editor/paragraph/components/paragraph.form.js
new file mode 100644
index 0000000..55cb74e
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/paragraph/components/paragraph.form.js
@@ -0,0 +1,107 @@
+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 { timestamp, capitalize } from 'app/utils'
+import { Select, Checkbox } from 'app/common'
+
+const PARAGRAPH_TYPES = [
+ 'paragraph', 'intro_paragraph', 'blockquote', 'pullquote', 'big_text', '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.handleSettingsSelect = this.handleSettingsSelect.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,
+ })
+ }
+ handleSettingsSelect(name, value) {
+ const { onUpdate, paragraph } = this.props
+ onUpdate({
+ ...paragraph,
+ settings: {
+ ...paragraph.settings,
+ [name]: value,
+ }
+ })
+ }
+ handleSubmit() {
+ const { paragraph, onClose } = this.props
+ actions.paragraph.update(paragraph)
+ .then(response => {
+ console.log(response)
+ onClose()
+ })
+ }
+ render() {
+ const { paragraph, y } = this.props
+ console.log(paragraph)
+ return (
+ <div
+ className='paragraphForm'
+ style={{
+ top: y,
+ }}
+ >
+ {this.renderButtons()}
+ <div>
+ <Checkbox
+ label="Hide in transcript"
+ name="hide_in_transcript"
+ checked={paragraph.settings ? paragraph.settings.hide_in_transcript : false}
+ onChange={this.handleSettingsSelect}
+ />
+ </div>
+ </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/editor/paragraph/components/paragraph.list.js b/animism-align/frontend/app/views/editor/paragraph/components/paragraph.list.js
new file mode 100644
index 0000000..f7c9c58
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/paragraph/components/paragraph.list.js
@@ -0,0 +1,103 @@
+import React, { Component } from 'react'
+import { Route } from 'react-router-dom'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+
+import { floatLT, floatInRange, capitalize } from 'app/utils'
+import ParagraphForm from '../components/paragraph.form'
+
+class ParagraphList extends Component {
+ state = {
+ currentParagraph: -1,
+ currentAnnotation: -1,
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.audio.play_ts === prevProps.audio.play_ts) return
+ if (!this.props.paragraphs) return
+ this.setCurrentParagraph()
+ }
+
+ setCurrentParagraph() {
+ const { play_ts } = this.props.audio
+ const insideParagraph = this.props.paragraphs.some(paragraph => {
+ if (floatInRange(paragraph.start_ts, 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
+ const possibleAnnotations = annotations.filter(a => a.type === 'sentence')
+ // console.log(possibleAnnotations)
+ if (!possibleAnnotations.length) return
+ let currentAnnotation
+ let annotation
+ let i = 0, next_i
+ let len = possibleAnnotations.length
+ for (let i = 0; i < len - 1; i++) {
+ next_i = i + 1
+ if (next_i < len && floatLT(play_ts, possibleAnnotations[next_i].start_ts)) {
+ currentAnnotation = possibleAnnotations[i].id
+ break
+ }
+ }
+ if (!currentAnnotation) {
+ currentAnnotation = possibleAnnotations[len-1].id
+ }
+ this.setState({ currentParagraph, currentAnnotation })
+ }
+
+ render() {
+ const {
+ paragraphs, media,
+ paragraphElementLookup, selectedParagraph,
+ onAnnotationClick, onParagraphDoubleClick,
+ currentSection,
+ } = this.props
+ const { currentParagraph, currentAnnotation } = this.state
+ if (!paragraphs) return null
+ return paragraphs.map((paragraph, i) => {
+ if (selectedParagraph && selectedParagraph.id === paragraph.id) {
+ paragraph = selectedParagraph
+ }
+ if (paragraph.type in paragraphElementLookup) {
+ const ParagraphElement = paragraphElementLookup[paragraph.type]
+ return (
+ <ParagraphElement
+ key={paragraph.id + "_" + i}
+ paragraph={paragraph}
+ media={media}
+ currentSection={currentSection}
+ currentParagraph={paragraph.id === currentParagraph}
+ currentAnnotation={paragraph.id === currentParagraph && currentAnnotation}
+ onAnnotationClick={onAnnotationClick}
+ onDoubleClick={onParagraphDoubleClick}
+ />
+ )
+ // } else {
+ // return <div key={paragraph.id}>{'(' + capitalize(paragraph.type) + ')'}</div>
+ }
+ })
+ }
+}
+
+const mapStateToProps = state => ({
+ audio: state.audio,
+ media: state.media.index,
+ currentSection: state.viewer.currentSection,
+})
+
+const mapDispatchToProps = dispatch => ({
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(ParagraphList)
diff --git a/animism-align/frontend/app/views/editor/paragraph/components/paragraphTypes/index.js b/animism-align/frontend/app/views/editor/paragraph/components/paragraphTypes/index.js
new file mode 100644
index 0000000..5180d5e
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/paragraph/components/paragraphTypes/index.js
@@ -0,0 +1,26 @@
+import React from 'react'
+
+import {
+ Paragraph, ParagraphHeading
+} from './paragraphTypes.text'
+
+import {
+ MediaVideo
+} from './paragraphTypes.video'
+
+import {
+ MediaImage
+} from './paragraphTypes.image'
+
+export const paragraphElementLookup = {
+ paragraph: React.memo(Paragraph),
+ intro_paragraph: React.memo(Paragraph),
+ hidden: React.memo(Paragraph),
+ blockquote: React.memo(Paragraph),
+ pullquote: React.memo(Paragraph),
+ big_text: React.memo(Paragraph),
+ section_heading: React.memo(ParagraphHeading),
+ // heading_text: React.memo(ParagraphHeading),
+ video: React.memo(MediaVideo),
+ image: React.memo(MediaImage),
+}
diff --git a/animism-align/frontend/app/views/editor/paragraph/components/paragraphTypes/paragraphTypes.image.js b/animism-align/frontend/app/views/editor/paragraph/components/paragraphTypes/paragraphTypes.image.js
new file mode 100644
index 0000000..4eab2cc
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/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)}
+ >
+ </div>
+ )
+}
diff --git a/animism-align/frontend/app/views/editor/paragraph/components/paragraphTypes/paragraphTypes.text.js b/animism-align/frontend/app/views/editor/paragraph/components/paragraphTypes/paragraphTypes.text.js
new file mode 100644
index 0000000..f0529c8
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/paragraph/components/paragraphTypes/paragraphTypes.text.js
@@ -0,0 +1,55 @@
+import React, { Component } from 'react'
+
+import { ROMAN_NUMERALS } from 'app/constants'
+
+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 => {
+ if (annotation.type === 'footnote') {
+ return (
+ <span
+ key={annotation.id}
+ className='footnote'
+ onClick={e => onAnnotationClick(e, paragraph, annotation)}
+ dangerouslySetInnerHTML={{ __html: annotation.footnote_id }}
+ />
+ )
+ }
+ return (
+ <span
+ key={annotation.id}
+ className={
+ annotation.type === 'pullquote_credit'
+ ? 'pullquote_credit'
+ : annotation.id === currentAnnotation
+ ? 'current'
+ : ''
+ }
+ onClick={e => onAnnotationClick(e, paragraph, annotation)}
+ dangerouslySetInnerHTML={{ __html: annotation.text }}
+ />
+ )
+ })}
+ </div>
+ )
+}
+
+export const ParagraphHeading = ({ paragraph, currentParagraph, currentAnnotation, onAnnotationClick, onDoubleClick }) => {
+ let className = currentParagraph ? 'section_heading current' : 'section_heading'
+ const text = paragraph.annotations.map(annotation => annotation.text).join(' ')
+ return (
+ <div
+ className={className}
+ onDoubleClick={e => onDoubleClick(e, paragraph)}
+ >
+ <span>{ROMAN_NUMERALS[paragraph.sectionIndex]}{'. '}{text}</span>
+ </div>
+ )
+}
diff --git a/animism-align/frontend/app/views/editor/paragraph/components/paragraphTypes/paragraphTypes.video.js b/animism-align/frontend/app/views/editor/paragraph/components/paragraphTypes/paragraphTypes.video.js
new file mode 100644
index 0000000..ba7a996
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/paragraph/components/paragraphTypes/paragraphTypes.video.js
@@ -0,0 +1,16 @@
+import React, { Component } from 'react'
+
+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)}
+ >
+ </div>
+ )
+}
diff --git a/animism-align/frontend/app/views/editor/paragraph/containers/paragraphEditor.container.js b/animism-align/frontend/app/views/editor/paragraph/containers/paragraphEditor.container.js
new file mode 100644
index 0000000..09ba70c
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/paragraph/containers/paragraphEditor.container.js
@@ -0,0 +1,93 @@
+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)
+ }
+
+ componentDidMount() {
+ actions.transcript.buildAllParagraphs()
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.paragraph !== prevProps.paragraph) {
+ actions.transcript.buildAllParagraphs()
+ }
+ }
+
+ handleAnnotationClick(e, paragraph, annotation){
+ actions.audio.seek(annotation.start_ts)
+ }
+
+ handleParagraphDoubleClick(e, paragraph) {
+ 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 { paragraphs } = this.props
+ const { selectedParagraph, selectedParagraphOffset } = this.state
+ return (
+ <div className='paragraphs'>
+ <div className='content'>
+ <ParagraphList
+ paragraphs={paragraphs}
+ 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,
+ paragraphs: state.paragraph.paragraphs,
+})
+
+const mapDispatchToProps = dispatch => ({
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(ParagraphEditor)
diff --git a/animism-align/frontend/app/views/editor/paragraph/paragraph.container.js b/animism-align/frontend/app/views/editor/paragraph/paragraph.container.js
new file mode 100644
index 0000000..d0e9b86
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/paragraph/paragraph.container.js
@@ -0,0 +1,66 @@
+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 {
+ constructor(props) {
+ super(props)
+ this.handleKeyDown = this.handleKeyDown.bind(this)
+ }
+ componentDidMount() {
+ document.addEventListener('keydown', this.handleKeyDown)
+ }
+ componentWillUnmount() {
+ 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.loaded) {
+ return <div className='body loading'><Loader /></div>
+ }
+ return (
+ <div className='body'>
+ <ParagraphEditor />
+ </div>
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ loaded: !!state.annotation.index.lookup && !!state.paragraph.index.lookup && !!state.media.index.lookup
+})
+
+const mapDispatchToProps = dispatch => ({
+ // alignActions: bindActionCreators({ ...alignActions }, dispatch),
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(ParagraphContainer)
diff --git a/animism-align/frontend/app/views/editor/paragraph/paragraph.css b/animism-align/frontend/app/views/editor/paragraph/paragraph.css
new file mode 100644
index 0000000..5743dbb
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/paragraph/paragraph.css
@@ -0,0 +1,126 @@
+.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 .section_heading {
+ font-size: 32px;
+}
+
+.paragraphs .heading_text {
+ font-size: 32px;
+}
+
+.paragraphs .intro_paragraph {
+ font-size: 18px;
+ line-height: 1.5;
+}
+
+.paragraphs .paragraph {
+ font-size: 16px;
+ line-height: 1.5;
+}
+.paragraphs .footnote {
+ font-size: 13px;
+ position: relative;
+ top: -8px;
+ left: -4px;
+ cursor: pointer;
+}
+
+.paragraphs .blockquote {
+ padding-left: 3rem;
+}
+
+.paragraphs .hidden {
+ opacity: 0.5;
+}
+
+.paragraphs .pullquote {
+ border-left: 2px solid #ddd;
+ padding-left: 2rem;
+ font-size: 20px;
+}
+
+.paragraphs .pullquote_credit {
+ display: block;
+ margin-top: 1rem;
+}
+.paragraphs .pullquote_credit:before {
+ content: '—';
+}
+
+
+/* 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: #838;
+ color: #ddd;
+ box-shadow: 2px 2px 4px rgba(0,0,0,0.2);
+ font-family: 'Roboto', sans-serif;
+}
+.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/editor/paragraph/paragraph.reducer.js b/animism-align/frontend/app/views/editor/paragraph/paragraph.reducer.js
new file mode 100644
index 0000000..3b33128
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/paragraph/paragraph.reducer.js
@@ -0,0 +1,28 @@
+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', {
+ paragraphs: [],
+ footnotes: [],
+ options: {
+ }
+})
+
+const reducer = crudReducer('paragraph')
+
+export default function paragraphReducer(state = initialState, action) {
+ // console.log(action.type, action)
+ state = reducer(state, action)
+ switch (action.type) {
+ case types.paragraph.update_transcript:
+ return {
+ ...state,
+ paragraphs: action.paragraphs,
+ footnotes: action.footnotes,
+ }
+ default:
+ return state
+ }
+}
diff --git a/animism-align/frontend/app/views/editor/paragraph/transcript.actions.js b/animism-align/frontend/app/views/editor/paragraph/transcript.actions.js
new file mode 100644
index 0000000..336a79d
--- /dev/null
+++ b/animism-align/frontend/app/views/editor/paragraph/transcript.actions.js
@@ -0,0 +1,10 @@
+import * as types from 'app/types'
+import { store, history, dispatch } from 'app/store'
+import { buildParagraphs } from 'app/utils/transcript.utils'
+
+export const buildAllParagraphs = () => dispatch => {
+ const state = store.getState()
+ const annotationOrder = state.annotation.index.order
+ const { paragraphs, footnotes } = buildParagraphs(annotationOrder)
+ dispatch({ type: types.paragraph.update_transcript, paragraphs, footnotes })
+}