diff options
Diffstat (limited to 'animism-align/frontend/app/views/editor/paragraph')
11 files changed, 647 insertions, 0 deletions
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 }) +} |
