diff options
| author | Jules Laplace <julescarbon@gmail.com> | 2021-03-08 22:11:55 +0100 |
|---|---|---|
| committer | Jules Laplace <julescarbon@gmail.com> | 2021-03-08 22:11:55 +0100 |
| commit | d2cb17038b8537a609be06be2ed7013dbe27117e (patch) | |
| tree | 028ceac9edddafc03ce80c49d5a05981bec3fcbe /animism-align/frontend/app/views/editor | |
| parent | b5ceb782f40fc1e402d1e58bc1ced2e4038fd787 (diff) | |
beginning the BIG refactor. moving editor stuff into per-episode hierarchy
Diffstat (limited to 'animism-align/frontend/app/views/editor')
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 }) +} |
