diff options
Diffstat (limited to 'animism-align/frontend/app/views/paragraph')
10 files changed, 612 insertions, 0 deletions
diff --git a/animism-align/frontend/app/views/paragraph/components/paragraph.form.js b/animism-align/frontend/app/views/paragraph/components/paragraph.form.js new file mode 100644 index 0000000..751ec7f --- /dev/null +++ b/animism-align/frontend/app/views/paragraph/components/paragraph.form.js @@ -0,0 +1,87 @@ +import React, { Component } from 'react' +// import { Link } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import actions from 'app/actions' + +import { clamp, timestamp, capitalize } from 'app/utils' +import { Select } from 'app/common' + +const PARAGRAPH_TYPES = [ + 'paragraph', 'blockquote', 'hidden', +].map(name => ({ name, label: capitalize(name.replace('_', ' ')) })) + +class ParagraphForm extends Component { + constructor(props){ + super(props) + this.handleChange = this.handleChange.bind(this) + this.handleSelect = this.handleSelect.bind(this) + this.handleSubmit = this.handleSubmit.bind(this) + } + componentDidMount() { + if (this.textareaRef && this.textareaRef.current) { + this.textareaRef.current.focus() + } + } + handleChange(e) { + const { name, value } = e.target + this.handleSelect(name, value) + } + handleSelect(name, value) { + const { onUpdate, paragraph } = this.props + onUpdate({ + ...paragraph, + [name]: value, + }) + } + handleSubmit() { + const { paragraph, onClose } = this.props + actions.paragraph.update(paragraph) + .then(response => { + console.log(response) + onClose() + }) + } + render() { + const { paragraph, y } = this.props + return ( + <div + className='paragraphForm' + style={{ + top: y, + }} + > + {this.renderButtons()} + </div> + ) + } + renderButtons() { + const { paragraph } = this.props + return ( + <div className='row buttons'> + <div className='row'> + <Select + name='type' + selected={paragraph.type} + options={PARAGRAPH_TYPES} + defaultOption='text' + onChange={this.handleSelect} + /> + <div className='ts'>{timestamp(paragraph.start_ts, 1, true)}</div> + </div> + <div> + <button onClick={this.handleSubmit}>Save</button> + </div> + </div> + ) + } +} + +const mapStateToProps = state => ({ +}) + +const mapDispatchToProps = dispatch => ({ +}) + +export default connect(mapStateToProps, mapDispatchToProps)(ParagraphForm) diff --git a/animism-align/frontend/app/views/paragraph/components/paragraph.list.js b/animism-align/frontend/app/views/paragraph/components/paragraph.list.js new file mode 100644 index 0000000..1b8a0ac --- /dev/null +++ b/animism-align/frontend/app/views/paragraph/components/paragraph.list.js @@ -0,0 +1,165 @@ +import React, { Component } from 'react' +import { Route } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import { floatLT, floatLTE } from 'app/utils' +import actions from 'app/actions' +import ParagraphForm from '../components/paragraph.form' + +const MEDIA_TYPES = new Set([ + 'image', 'gallery', 'vitrine', + 'video', +]) + +class ParagraphList extends Component { + state = { + paragraphs: [], + currentParagraph: -1, + currentAnnotation: -1, + } + + componentDidMount() { + this.build() + } + + componentDidUpdate(prevProps) { + if (this.props.paragraph !== prevProps.paragraph) { + this.build() + } + if (this.props.audio.play_ts === prevProps.audio.play_ts) return + this.setCurrentParagraph() + } + + setCurrentParagraph() { + const { play_ts } = this.props.audio + const insideParagraph = this.state.paragraphs.some(paragraph => { + if (floatLTE(paragraph.start_ts, play_ts) && floatLT(play_ts, paragraph.end_ts)) { + this.setCurrentAnnotation(paragraph, play_ts) + return true + } + return false + }) + if (!insideParagraph) { + this.setState({ + currentParagraph: -1, + currentAnnotation: -1, + }) + } + } + + setCurrentAnnotation(paragraph, play_ts) { + const { id: currentParagraph, annotations } = paragraph + let currentAnnotation + let annotation + let i = 0 + let len = annotations.length + for (let i = 0; i < len - 1; i++) { + if (floatLT(play_ts, annotations[i+1].start_ts)) { + currentAnnotation = annotations[i].id + break + } + } + if (!currentAnnotation) { + currentAnnotation = annotations[len-1].id + } + this.setState({ currentParagraph, currentAnnotation }) + } + + build() { + const { order: annotationOrder, lookup: annotationLookup } = this.props.annotation + const { lookup: paragraphLookup } = this.props.paragraph + let currentParagraph = {} + const paragraphs = [] + // loop over the annotations in time order + annotationOrder.forEach((annotation_id, i) => { + const annotation = annotationLookup[annotation_id] + const paragraph = paragraphLookup[annotation.paragraph_id] + // if this annotation is media, insert it after the current paragraph + if (MEDIA_TYPES.has(annotation.type)) { + paragraphs.push({ + id: ('index_' + i), + type: annotation.type, + start_ts: annotation.start_ts, + end_ts: 0, + annotations: [annotation], + }) + return + } + // if this annotation is from a different paragraph, make a new paragraph + if (annotation.paragraph_id !== currentParagraph.id) { + const paragraph_type = getParagraphType(annotation, paragraph) + currentParagraph = { + id: annotation.paragraph_id || ('index_' + i), + type: paragraph_type, + start_ts: annotation.start_ts, + end_ts: 0, + annotations: [], + } + paragraphs.push(currentParagraph) + } + // if this annotation is a paragraph_end, set the end timestamp + if (annotation.type === 'paragraph_end') { + currentParagraph.end_ts = annotation.start_ts + } + // otherwise, just append this annotation to the paragraph + else { + currentParagraph.annotations.push(annotation) + } + }) + for (let i = 0; i < (paragraphs.length - 1); i++) { + if (!paragraphs[i].end_ts) { + paragraphs[i].end_ts = paragraphs[i+1].start_ts - 0.1 + } + } + this.setState({ paragraphs }) + } + + render() { + const { + media, paragraphElementLookup, selectedParagraph, + onAnnotationClick, onParagraphDoubleClick + } = this.props + const { paragraphs, currentParagraph, currentAnnotation } = this.state + return paragraphs.map(paragraph => { + if (selectedParagraph && selectedParagraph.id === paragraph.id) { + paragraph = selectedParagraph + } + if (paragraph.type in paragraphElementLookup) { + const ParagraphElement = paragraphElementLookup[paragraph.type] + return ( + <ParagraphElement + key={paragraph.id} + paragraph={paragraph} + media={media} + currentParagraph={paragraph.id === currentParagraph} + currentAnnotation={paragraph.id === currentParagraph && currentAnnotation} + onAnnotationClick={onAnnotationClick} + onDoubleClick={onParagraphDoubleClick} + /> + ) + } else { + return <div key={paragraph.id}>{'(waiting to implement' + paragraph.type + ')'}</div> + } + }) + } +} + +const getParagraphType = (annotation, paragraph) => { + if (!paragraph) { + return annotation.type + } + return paragraph.type +} + +const mapStateToProps = state => ({ + paragraph: state.paragraph.index, + annotation: state.annotation.index, + audio: state.audio, + media: state.media.index, +}) + +const mapDispatchToProps = dispatch => ({ +}) + +export default connect(mapStateToProps, mapDispatchToProps)(ParagraphList) diff --git a/animism-align/frontend/app/views/paragraph/components/paragraphTypes/index.js b/animism-align/frontend/app/views/paragraph/components/paragraphTypes/index.js new file mode 100644 index 0000000..62b4a49 --- /dev/null +++ b/animism-align/frontend/app/views/paragraph/components/paragraphTypes/index.js @@ -0,0 +1,22 @@ +import React from 'react' + +import { + Paragraph, ParagraphHeader +} from './paragraphTypes.text' + +import { + MediaVideo +} from './paragraphTypes.video' + +import { + MediaImage +} from './paragraphTypes.image' + +export const paragraphElementLookup = { + paragraph: React.memo(Paragraph), + hidden: React.memo(Paragraph), + blockquote: React.memo(Paragraph), + header: React.memo(ParagraphHeader), + video: React.memo(MediaVideo), + image: React.memo(MediaImage), +} diff --git a/animism-align/frontend/app/views/paragraph/components/paragraphTypes/paragraphTypes.image.js b/animism-align/frontend/app/views/paragraph/components/paragraphTypes/paragraphTypes.image.js new file mode 100644 index 0000000..36c72e9 --- /dev/null +++ b/animism-align/frontend/app/views/paragraph/components/paragraphTypes/paragraphTypes.image.js @@ -0,0 +1,17 @@ +import React, { Component } from 'react' + +export const MediaImage = ({ paragraph, media, currentParagraph, currentAnnotation, onAnnotationClick, onDoubleClick }) => { + if (!media.lookup) return <div /> + const className = currentParagraph ? 'media image current' : 'media image' + const annotation = paragraph.annotations[0] + const item = media.lookup[annotation.settings.media_id] + if (!item) return <div>Media not found: {annotation.settings.media_id}</div> + return ( + <div + className={className} + onDoubleClick={e => onDoubleClick(e, paragraph)} + > + <img src={item.settings.display.url} /> + </div> + ) +} diff --git a/animism-align/frontend/app/views/paragraph/components/paragraphTypes/paragraphTypes.text.js b/animism-align/frontend/app/views/paragraph/components/paragraphTypes/paragraphTypes.text.js new file mode 100644 index 0000000..c2ebcd7 --- /dev/null +++ b/animism-align/frontend/app/views/paragraph/components/paragraphTypes/paragraphTypes.text.js @@ -0,0 +1,35 @@ +import React, { Component } from 'react' + +export const Paragraph = ({ paragraph, currentParagraph, currentAnnotation, onAnnotationClick, onDoubleClick }) => { + let className = paragraph.type + if (className !== 'paragraph') className += ' paragraph' + if (currentParagraph) className += ' current' + return ( + <div + className={className} + onDoubleClick={e => onDoubleClick(e, paragraph)} + > + {paragraph.annotations.map(annotation => ( + <span + key={annotation.id} + className={annotation.id === currentAnnotation ? 'current' : ''} + onClick={e => onAnnotationClick(e, paragraph, annotation)} + dangerouslySetInnerHTML={{ __html: ' ' + annotation.text + ' ' }} + /> + ))} + </div> + ) +} + +export const ParagraphHeader = ({ paragraph, currentParagraph, currentAnnotation, onAnnotationClick, onDoubleClick }) => { + let className = currentParagraph ? 'header current' : 'header' + const text = paragraph.annotations.map(annotation => annotation.text).join(' ') + return ( + <div + className={className} + onDoubleClick={e => onDoubleClick(e, paragraph)} + > + {text} + </div> + ) +} diff --git a/animism-align/frontend/app/views/paragraph/components/paragraphTypes/paragraphTypes.video.js b/animism-align/frontend/app/views/paragraph/components/paragraphTypes/paragraphTypes.video.js new file mode 100644 index 0000000..423864b --- /dev/null +++ b/animism-align/frontend/app/views/paragraph/components/paragraphTypes/paragraphTypes.video.js @@ -0,0 +1,19 @@ +import React, { Component } from 'react' + +import VimeoPlayer from '@u-wave/react-vimeo' + +export const MediaVideo = ({ paragraph, media, currentParagraph, currentAnnotation, onAnnotationClick, onDoubleClick }) => { + if (!media.lookup) return <div /> + const className = currentParagraph ? 'media current' : 'media' + const annotation = paragraph.annotations[0] + const item = media.lookup[annotation.settings.media_id] + if (!item) return <div>Media not found: {annotation.settings.media_id}</div> + return ( + <div + className={className} + onDoubleClick={e => onDoubleClick(e, paragraph)} + > + <VimeoPlayer video={item.url} muted width="650" /> + </div> + ) +} diff --git a/animism-align/frontend/app/views/paragraph/containers/paragraphEditor.container.js b/animism-align/frontend/app/views/paragraph/containers/paragraphEditor.container.js new file mode 100644 index 0000000..12ab618 --- /dev/null +++ b/animism-align/frontend/app/views/paragraph/containers/paragraphEditor.container.js @@ -0,0 +1,85 @@ +import React, { Component } from 'react' +import { Route } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import actions from 'app/actions' +import ParagraphForm from '../components/paragraph.form' +import ParagraphList from '../components/paragraph.list' +import { paragraphElementLookup } from '../components/paragraphTypes' + +class ParagraphEditor extends Component { + state = { + selectedParagraph: null, + selectedParagraphOffset: 0, + } + + constructor(props) { + super(props) + this.handleAnnotationClick = this.handleAnnotationClick.bind(this) + this.handleParagraphDoubleClick = this.handleParagraphDoubleClick.bind(this) + this.handleCloseParagraphForm = this.handleCloseParagraphForm.bind(this) + this.updateSelectedParagraph = this.updateSelectedParagraph.bind(this) + } + + handleAnnotationClick(e, paragraph, annotation){ + actions.audio.seek(annotation.start_ts) + } + + handleParagraphDoubleClick(e, paragraph) { + console.log(e.target.parentNode) + let paragraphNode = e.target + if (!paragraphNode.classList.contains('paragraph')) { + paragraphNode = paragraphNode.parentNode + } + this.setState({ + selectedParagraph: { ...paragraph }, + selectedParagraphOffset: paragraphNode.offsetTop + }) + } + + updateSelectedParagraph(selectedParagraph) { + this.setState({ selectedParagraph }) + } + + handleCloseParagraphForm() { + this.setState({ selectedParagraph: null }) + } + + render() { + // const { media } = this.props + const { paragraphs, selectedParagraph, currentParagraph, currentAnnotation } = this.state + return ( + <div className='paragraphs'> + <div className='content'> + <ParagraphList + paragraphElementLookup={paragraphElementLookup} + selectedParagraph={selectedParagraph} + onAnnotationClick={this.handleAnnotationClick} + onParagraphDoubleClick={this.handleParagraphDoubleClick} + /> + {selectedParagraph && + <ParagraphForm + paragraph={selectedParagraph} + onUpdate={this.updateSelectedParagraph} + onClose={this.handleCloseParagraphForm} + y={selectedParagraphOffset} + /> + } + </div> + </div> + ) + } +} + +const mapStateToProps = state => ({ + // paragraph: state.paragraph.index, + // annotation: state.annotation.index, + // audio: state.audio, + // media: state.media.index, +}) + +const mapDispatchToProps = dispatch => ({ +}) + +export default connect(mapStateToProps, mapDispatchToProps)(ParagraphEditor) diff --git a/animism-align/frontend/app/views/paragraph/paragraph.container.js b/animism-align/frontend/app/views/paragraph/paragraph.container.js new file mode 100644 index 0000000..ea1c797 --- /dev/null +++ b/animism-align/frontend/app/views/paragraph/paragraph.container.js @@ -0,0 +1,69 @@ +import React, { Component } from 'react' +import { Route } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import './paragraph.css' + +import actions from 'app/actions' +import { Loader } from 'app/common' + +import ParagraphEditor from './containers/paragraphEditor.container' + +class ParagraphContainer extends Component { + componentDidMount() { + this.bind() + } + componentWillUnmount() { + this.unbind() + } + bind() { + document.addEventListener('keydown', this.handleKeydown) + } + unbind() { + document.removeEventListener('keydown', this.handleKeydown) + } + handleKeydown(e) { + if (document.activeElement !== document.body) { + return + } + // console.log(e.keyCode) + switch (e.keyCode) { + case 32: // spacebar + e.preventDefault() + actions.audio.toggle() + break + case 37: // left + case 38: // up + e.preventDefault() + actions.audio.jump(-5.0) + break + case 39: // right + case 40: // down + e.preventDefault() + actions.audio.jump(5.0) + break + } + } + render() { + if (!this.props.annotation.lookup || !this.props.paragraph.lookup) { + return <div className='body loading'><Loader /></div> + } + return ( + <div className='body'> + <ParagraphEditor /> + </div> + ) + } +} + +const mapStateToProps = state => ({ + paragraph: state.paragraph.index, + annotation: state.annotation.index, +}) + +const mapDispatchToProps = dispatch => ({ + // alignActions: bindActionCreators({ ...alignActions }, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(ParagraphContainer) diff --git a/animism-align/frontend/app/views/paragraph/paragraph.css b/animism-align/frontend/app/views/paragraph/paragraph.css new file mode 100644 index 0000000..8cd502c --- /dev/null +++ b/animism-align/frontend/app/views/paragraph/paragraph.css @@ -0,0 +1,93 @@ +.paragraphs { + width: 100%; + height: calc(100% - 3.125rem); + overflow: scroll; + background: white; + color: black; + padding: 1rem; +} + +/* general paragraph styles */ + +.paragraphs .content { + font-family: 'Georgia', serif; + width: 650px; + margin: 0 auto; + padding-bottom: 6rem; + position: relative; +} + +.paragraphs .content > div { + margin-bottom: 16px; +} + +/* paragraph subtypes */ + +.paragraphs .header { + font-size: 32px; +} + +.paragraphs .paragraph { + font-size: 16px; + line-height: 24px; +} + +.paragraphs .blockquote { + padding-left: 3rem; +} + +.paragraphs .hidden { + opacity: 0.5; +} + +/* media image */ + +.paragraphs .media.image img { + width: 100%; +} + +/* current paragraph */ + +.paragraphs .paragraph.current { + background: rgba(0,0,0,0.0); +} + +/* sentences */ + +.paragraphs span { + margin-right: 4px; + cursor: pointer; +} + +.paragraphs .paragraph .current { + box-shadow: -2px -3px 0 #fff, + 2px -3px 0 #fff, + -2px 3px 0 #fff, + 2px 3px 0 #fff; + box-decoration-break: clone; + background: black; + color: white; +} + +/* paragraph form */ + +.paragraphForm { + position: absolute; + right: -305px; + width: 300px; + padding: 0.5rem; + background: #ddd; + box-shadow: 2px 2px 4px rgba(0,0,0,0.2); +} +.paragraphForm .select div { + color: #ddd; + font-family: 'Roboto', sans-serif; +} +.paragraphForm .row { + justify-content: space-between; + align-items: center; +} +.paragraphForm .row > div { + display: flex; + align-items: center; +} diff --git a/animism-align/frontend/app/views/paragraph/paragraph.reducer.js b/animism-align/frontend/app/views/paragraph/paragraph.reducer.js new file mode 100644 index 0000000..c3babc8 --- /dev/null +++ b/animism-align/frontend/app/views/paragraph/paragraph.reducer.js @@ -0,0 +1,20 @@ +import * as types from 'app/types' +import { session, getDefault, getDefaultInt } from 'app/session' + +import { crudState, crudReducer } from 'app/api/crud.reducer' + +const initialState = crudState('paragraph', { + options: { + } +}) + +const reducer = crudReducer('paragraph') + +export default function paragraphReducer(state = initialState, action) { + // console.log(action.type, action) + state = reducer(state, action) + switch (action.type) { + default: + return state + } +} |
