diff options
Diffstat (limited to 'animism-align/frontend/app/views/media/components')
6 files changed, 889 insertions, 0 deletions
diff --git a/animism-align/frontend/app/views/media/components/media.form.js b/animism-align/frontend/app/views/media/components/media.form.js new file mode 100644 index 0000000..af53014 --- /dev/null +++ b/animism-align/frontend/app/views/media/components/media.form.js @@ -0,0 +1,270 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' + +import { capitalize } from 'app/utils' + +import { TextInput, LabelDescription, Select, TextArea, Checkbox, SubmitButton, Loader } from 'app/common' + +import MediaImageForm from './media.formImage' +import MediaVideoForm from './media.formVideo' + +const newMedia = () => ({ + type: 'image', + tag: 'media', + url: '', + title: '', + author: '', + pre_title: '', + post_title: '', + translated_title: '', + date: '', + source: '', + medium: '', + start_ts: 0, + settings: {}, +}) + +const MEDIA_TYPES = [ + 'image', 'video' +].map(name => ({ name, label: capitalize(name) })) + +export default class MediaForm extends Component { + state = { + title: "", + submitTitle: "", + data: { ...newMedia() }, + errorFields: new Set([]), + } + + constructor(props) { + super(props) + this.handleKeyDown = this.handleKeyDown.bind(this) + this.handleSelect = this.handleSelect.bind(this) + this.handleChange = this.handleChange.bind(this) + this.handleSettingsChange = this.handleSettingsChange.bind(this) + this.handleSettingsChangeEvent = this.handleSettingsChangeEvent.bind(this) + this.handleSubmit = this.handleSubmit.bind(this) + } + + componentDidMount() { + const { data, isNew } = this.props + const title = isNew ? 'New media' : 'Editing ' + data.title + const submitTitle = isNew ? "Add Media" : "Save Changes" + this.setState({ + title, + submitTitle, + errorFields: new Set([]), + data: { + ...newMedia(), + ...data + }, + }) + window.addEventListener('keydown', this.handleKeyDown) + } + + componentWillUnmount() { + window.removeEventListener('keydown', this.handleKeyDown) + } + + handleKeyDown(e) { + // console.log(e, e.keyCode) + if ((e.ctrlKey || e.metaKey) && e.keyCode === 83) { + if (e) { + e.preventDefault() + } + this.handleSubmit() + } + } + + handleChange(e) { + const { name, value } = e.target + this.handleSelect(name, value) + } + + handleSelect(name, value) { + const { errorFields } = this.state + if (errorFields.has(name)) { + errorFields.delete(name) + } + this.setState({ + errorFields, + data: { + ...this.state.data, + [name]: value, + } + }) + } + + handleSettingsChangeEvent(e) { + const { name, value } = e.target + this.handleSettingsChange(name, value) + } + + handleSettingsChange(name, value) { + console.log(name, value) + if (name !== 'multiple') { + value = { [name]: value } + } + this.setState({ + data: { + ...this.state.data, + settings: { + ...this.state.data.settings, + ...value, + } + } + }) + } + + handleSubmit(e) { + if (e) { + e.preventDefault() + } + const { isNew, onSubmit } = this.props + const { data } = this.state + const requiredKeys = "author title date".split(" ") + const validKeys = "type tag url title author pre_title post_title translated_title date source medium start_ts settings".split(" ") + const validData = validKeys.reduce((a,b) => { a[b] = data[b]; return a }, {}) + const errorFields = requiredKeys.filter(key => !validData[key]) + if (errorFields.length) { + console.log('error', errorFields, validData) + this.setState({ errorFields: new Set(errorFields) }) + } else { + if (isNew) { + // + } else { + validData.id = data.id + } + console.log('submit', validData) + onSubmit(validData) + } + } + + render() { + const { isNew } = this.props + const { title, submitTitle, errorFields, data } = this.state + // console.log(data) + return ( + <div className='form'> + <h1>{title}</h1> + <form onSubmit={this.handleSubmit}> + <Select + title='Media Type' + name='type' + selected={data.type} + options={MEDIA_TYPES} + onChange={this.handleSelect} + /> + + {data.type === 'image' && + <MediaImageForm + data={data} + onChange={this.handleSelect} + onSettingsChange={this.handleSettingsChange} + /> + } + + {data.type === 'video' && + <MediaVideoForm + data={data} + onChange={this.handleSelect} + onSettingsChange={this.handleSettingsChange} + /> + } + + <TextInput + title="Author" + name="author" + required + data={data} + onChange={this.handleChange} + autoComplete="off" + /> + <TextInput + title="Title" + name="title" + required + data={data} + onChange={this.handleChange} + autoComplete="off" + /> + <TextInput + title="Title Prefix" + name="pre_title" + data={data} + onChange={this.handleChange} + autoComplete="off" + /> + <TextInput + title="Title Suffix" + name="post_title" + data={data} + onChange={this.handleChange} + autoComplete="off" + /> + <TextInput + title="Translated Title" + name="translated_title" + data={data} + onChange={this.handleChange} + autoComplete="off" + /> + <TextInput + title="Date" + name="date" + required + data={data} + onChange={this.handleChange} + autoComplete="off" + /> + <TextInput + title="Medium" + name="medium" + required + data={data} + onChange={this.handleChange} + /> + <TextInput + title="Source" + name="source" + placeholder="Courtesy of / Copyright" + required + data={data} + onChange={this.handleChange} + autoComplete="off" + /> + <TextArea + title="Citation" + name="bibliography" + placeholder="Use if special HTML formatting needed" + data={data.settings} + onChange={this.handleSettingsChangeEvent} + /> + <Checkbox + label="Hide in list of works" + name="hide_in_bibliography" + checked={data.settings.hide_in_bibliography} + onChange={this.handleSettingsChange} + autoComplete="off" + /> + <TextArea + title="Description" + name="description" + data={data} + onChange={this.handleChange} + /> + <SubmitButton + title={submitTitle} + onClick={this.handleSubmit} + /> + {!!errorFields.size && + <label> + <span></span> + <span>Please complete the required fields</span> + </label> + } + </form> + </div> + ) + } +} diff --git a/animism-align/frontend/app/views/media/components/media.formImage.js b/animism-align/frontend/app/views/media/components/media.formImage.js new file mode 100644 index 0000000..b6e38be --- /dev/null +++ b/animism-align/frontend/app/views/media/components/media.formImage.js @@ -0,0 +1,172 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' + +import { session } from 'app/session' +import actions from 'app/actions' +import { capitalize, preloadImage, cropImage } from 'app/utils' + +import { TextInput, LabelDescription, UploadImage, Select, TextArea, Checkbox, SubmitButton, Loader } from 'app/common' +import { renderThumbnail } from 'app/common/upload.helpers' + +import ImageSelection from './media.formImageSelection' + +const DISPLAY_SIZE = 1024 +const DISPLAY_QUALITY= 80 +const THUMBNAIL_SIZE = 320 +const THUMBNAIL_QUALITY = 80 + +export default class MediaImageForm extends Component { + state = { + img: null, + } + + constructor(props) { + super(props) + this.handleSelect = this.handleSelect.bind(this) + this.handleChange = this.handleChange.bind(this) + this.handleSettingsChange = this.handleSettingsChange.bind(this) + this.handleUpload = this.handleUpload.bind(this) + this.handleCrop = this.handleCrop.bind(this) + this.replaceTaggedSize = this.replaceTaggedSize.bind(this) + this.uploadTaggedSize = this.uploadTaggedSize.bind(this) + } + + componentDidMount() { + // this.setState({ }) + if (this.props.data.settings.fullsize) { + preloadImage(this.props.data.settings.fullsize.url) + .then(img => this.setState({ img })) + } + } + + handleChange(e) { + const { name, value } = e.target + this.handleSelect(name, value) + } + + handleSelect(name, value) { + this.props.onSelect(name, value) + } + + handleSettingsChange(name, value) { + this.props.onSettingsChange(name, value) + } + + handleUpload({ file, img, canvas, blob }) { + // sizes: fullsize, display, thumbnail + this.replaceTaggedSize(file, 'fullsize') + .then(data => { + this.setState({ img }) + this.props.onSettingsChange('multiple', { + fullsize: data, + crop: {}, + }) + return this.replaceTaggedSize(blob, 'display', file.name) + }).then(data => { + this.props.onSettingsChange('multiple', { + display: data, + }) + this.uploadThumbnail(img) + }) + } + + uploadThumbnail(img) { + const { fn } = this.props.data.settings.fullsize + const thumbnailCanvas = renderThumbnail(img, { maxSide: THUMBNAIL_SIZE }) + thumbnailCanvas.toBlob(thumbnail => { + this.replaceTaggedSize(thumbnail, 'thumbnail', fn).then(data => { + this.props.onSettingsChange('multiple', { + thumbnail: data, + }) + }) + }, 'image/jpeg', THUMBNAIL_QUALITY) + } + + replaceTaggedSize(image, tag, fn) { + // when we upload an image, if the image already exists in this "position" + // on the record, we should also delete it + if (this.props.data.settings[tag] && this.props.data.settings[tag].id) { + return new Promise((resolve, reject) => { + actions.upload.destroy(this.props.data.settings[tag]) + .then(() => { + console.log('destroy successful') + this.uploadTaggedSize(image, tag, fn).then(data => resolve(data)) + }) + .catch(() => { + console.log('error deleting the image') + this.uploadTaggedSize(image, tag, fn).then(data => resolve(data)) + }) + }) + } + return this.uploadTaggedSize(image, tag, fn) + } + + uploadTaggedSize(image, tag, fn) { + console.log('uploading size', tag) + const uploadData = { + image, + tag, + username: 'animism', + } + if (fn) { + uploadData['__image_filename'] = fn + } + // console.log(uploadData) + return actions.upload.upload(uploadData).then(data => { + // console.log(data) + return data.res + }) + } + + handleCrop(crop) { + // when cropping an image, re-upload the display image and thumbnail + // console.log(crop) + cropImage(this.state.img, crop, DISPLAY_SIZE) + .then(canvas => { + canvas.toBlob(blob => { + // console.log(canvas, canvas.width, canvas.height, blob) + this.replaceTaggedSize(blob, 'display', this.props.data.settings.fullsize.fn) + .then(data => { + this.props.onSettingsChange('multiple', { + crop, + display: data, + }) + this.uploadThumbnail(canvas) + }) + }, 'image/jpeg', DISPLAY_QUALITY) + }) + } + + render() { + const { data } = this.props + // console.log(data) + return ( + <div className='imageForm'> + {!data.url && + <label className={'text fileInput'}> + <span>{"Upload image"}</span> + <div className="row"> + <button> + {"Choose image"} + </button> + <UploadImage + onUpload={this.handleUpload} + maxSide={DISPLAY_SIZE} + quality={DISPLAY_QUALITY} + /> + </div> + </label> + } + {data.settings.fullsize && + <div> + <ImageSelection + url={data.settings.fullsize.url} + crop={data.settings.crop} + onCrop={this.handleCrop} + /> + </div> + } + </div> + ) + } +} diff --git a/animism-align/frontend/app/views/media/components/media.formImageSelection.js b/animism-align/frontend/app/views/media/components/media.formImageSelection.js new file mode 100644 index 0000000..966eb58 --- /dev/null +++ b/animism-align/frontend/app/views/media/components/media.formImageSelection.js @@ -0,0 +1,213 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' +import toBlob from 'data-uri-to-blob' + +import { clamp } from 'app/utils' +import { Loader } from 'app/common' + +const defaultState = { + dragging: false, + draggingBox: false, + bounds: null, + mouseX: 0, + mouseY: 0, + box: { + x: 0, + y: 0, + w: 0, + h: 0, + } +} + +export default class ImageSelection extends Component { + state = { + ...defaultState + } + + constructor() { + super() + // bind these events in the constructor, so we can remove event listeners later + this.handleMouseDown = this.handleMouseDown.bind(this) + this.handleMouseDownOnBox = this.handleMouseDownOnBox.bind(this) + this.handleMouseMove = this.handleMouseMove.bind(this) + this.handleMouseUp = this.handleMouseUp.bind(this) + this.handleWindowResize = this.handleWindowResize.bind(this) + } + + componentDidMount() { + document.body.addEventListener('mousemove', this.handleMouseMove) + document.body.addEventListener('mouseup', this.handleMouseUp) + window.addEventListener('resize', this.handleWindowResize) + } + + componentDidUpdate(prevProps) { + if (this.state.bounds && this.props.url !== prevProps.url) { + this.setState({ + ...defaultState, + bounds: this.getBoundingClientRect(), + box: this.props.crop || defaultState.box, + }) + } + } + + componentWillUnmount() { + document.body.removeEventListener('mousemove', this.handleMouseMove) + document.body.removeEventListener('mouseup', this.handleMouseUp) + window.removeEventListener('resize', this.handleWindowResize) + } + + getBoundingClientRect() { + if (!this.imgRef) return null + const rect = this.imgRef.getBoundingClientRect() + const scrollTop = document.body.scrollTop || document.body.parentNode.scrollTop + const scrollLeft = document.body.scrollLeft || document.body.parentNode.scrollLeft + const bounds = { + top: rect.top + scrollTop, + left: rect.left + scrollLeft, + width: rect.width, + height: rect.height, + } + return bounds + } + + handleLoad() { + const bounds = this.getBoundingClientRect() + const box = this.props.crop || defaultState.box + this.setState({ bounds, box }) + } + + handleWindowResize() { + if (!this.imgRef) return + const bounds = this.getBoundingClientRect() + this.setState({ bounds }) + } + + handleMouseDown(e) { + e.preventDefault() + const bounds = this.getBoundingClientRect() + const mouseX = e.pageX + const mouseY = e.pageY + const x = (mouseX - bounds.left) / bounds.width + const y = (mouseY - bounds.top) / bounds.height + const w = 1 / bounds.width + const h = 1 / bounds.height + this.setState({ + dragging: true, + bounds, + mouseX, + mouseY, + box: { + x, y, w, h, + } + }) + } + + handleMouseDownOnBox(e) { + const bounds = this.getBoundingClientRect() + const mouseX = e.pageX + const mouseY = e.pageY + this.setState({ + draggingBox: true, + bounds, + mouseX, + mouseY, + initialBox: { + ...this.state.box + }, + box: { + ...this.state.box + } + }) + } + + handleMouseMove(e) { + const { + dragging, draggingBox, + bounds, mouseX, mouseY, initialBox, box + } = this.state + if (dragging) { + e.preventDefault() + let { x, y } = box + let dx = (e.pageX - mouseX) / bounds.width + let dy = (e.pageY - mouseY) / bounds.height + let w = clamp(dx, 0.0, 1.0 - x) + let h = clamp(dy, 0.0, 1.0 - y) + this.setState({ + box: { + x, y, w, h, + } + }) + } else if (draggingBox) { + e.preventDefault() + let { x, y, w, h } = initialBox + let dx = (e.pageX - mouseX) / bounds.width + let dy = (e.pageY - mouseY) / bounds.height + this.setState({ + box: { + x: clamp(x + dx, 0, 1.0 - w), + y: clamp(y + dy, 0, 1.0 - h), + w, + h, + } + }) + } + } + + handleMouseUp(e) { + const { onCrop } = this.props + const { dragging, draggingBox, bounds, box } = this.state + if (!dragging && !draggingBox) return + e.preventDefault() + const { x, y, w, h } = box + let url = window.location.pathname + this.setState({ + dragging: false, + draggingBox: false, + }) + if (w < 10 / bounds.width || h < 10 / bounds.height) { + this.setState({ box: { ...defaultState.box }}) + onCrop({}) + } else { + // pass the box dimensions up - do the search again + onCrop(box) + } + } + + render() { + const { url } = this.props + const { bounds, box } = this.state + const { x, y, w, h } = box + return ( + <div className="imageSelection"> + <img + src={url} + ref={ref => this.imgRef = ref} + onMouseDown={this.handleMouseDown} + onLoad={this.handleLoad.bind(this)} + crossOrigin='anonymous' + /> + {!!w && + <div + className="box" + style={{ + left: x * bounds.width, + top: y * bounds.height, + width: w * bounds.width, + height: h * bounds.height, + }} + onMouseDown={this.handleMouseDownOnBox} + /> + } + </div> + ) + } +} + +const boxToFixed = ({ x, y, w, h }) => ({ + x: x.toFixed(3), + y: y.toFixed(3), + w: w.toFixed(3), + h: h.toFixed(3), +}) diff --git a/animism-align/frontend/app/views/media/components/media.formVideo.js b/animism-align/frontend/app/views/media/components/media.formVideo.js new file mode 100644 index 0000000..c6b1cf9 --- /dev/null +++ b/animism-align/frontend/app/views/media/components/media.formVideo.js @@ -0,0 +1,111 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' +import VimeoPlayer from '@u-wave/react-vimeo' + +import { capitalize } from 'app/utils' +import { TextInput, LabelDescription, Select, TextArea, Checkbox, SubmitButton, Loader } from 'app/common' + +import { getVimeoMetadata } from 'app/views/media/media.actions' + +export default class MediaVideoForm extends Component { + state = { + } + + constructor(props) { + super(props) + this.handleSelect = this.handleSelect.bind(this) + this.handleChange = this.handleChange.bind(this) + this.handleSettingsChange = this.handleSettingsChange.bind(this) + } + + handleChange(e) { + let { name, value } = e.target + return this.handleSelect(name, value) + } + + handleSelect(name, value) { + value = value.trim() + if (name === 'url') { + getVimeoMetadata(value) + .then(data => { + console.log('video metadata', data) + this.props.onChange(name, value) + setTimeout(() => { + this.props.onSettingsChange('video', { + thumbnail_url: data.thumbnail_url, + duration: data.duration, + video_id: data.video_id, + }) + }, 20) + }) + } else { + this.props.onChange(name, value) + } + } + + handleSettingsChange(e) { + let { name, value } = e.target + this.props.onSettingsChange(name, value) + } + + handleSettingsSelect(name, value) { + this.props.onSettingsChange(name, value) + } + + render() { + const { data } = this.props + return ( + <div className='videoForm'> + <TextInput + title="Video URL" + name="url" + required + data={data} + onChange={this.handleChange} + autoComplete="off" + /> + + {data.url && + <div> + <LabelDescription className='video'> + <VimeoPlayer video={data.url} /> + </LabelDescription> + + {data.settings.video && data.settings.video.thumbnail && + <LabelDescription className='thumbnail'> + <img src={data.settings.video.thumbnail} /> + </LabelDescription> + } + + <TextInput + title="Start time" + name="video_start_time" + data={data.settings} + placeholder="0:00" + onChange={this.handleSettingsChange} + autoComplete="off" + /> + + <TextInput + title="End time" + name="video_end_time" + data={data.settings} + placeholder="0:00" + onChange={this.handleSettingsChange} + autoComplete="off" + /> + + <TextInput + title="Original duration" + name="original_duration" + data={data.settings} + placeholder="0:00" + onChange={this.handleSettingsChange} + autoComplete="off" + /> + </div> + } + </div> + ) + } +} diff --git a/animism-align/frontend/app/views/media/components/media.indexOptions.js b/animism-align/frontend/app/views/media/components/media.indexOptions.js new file mode 100644 index 0000000..09a0f74 --- /dev/null +++ b/animism-align/frontend/app/views/media/components/media.indexOptions.js @@ -0,0 +1,65 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import actions from 'app/actions' + +import { Select, Checkbox } from 'app/common' + +const thumbnailOptions = [ + { name: 'th', label: 'Thumbnails', }, + { name: 'sm', label: 'Small', }, + { name: 'md', label: 'Medium', }, + { name: 'lg', label: 'Large', }, + { name: 'orig', label: 'Original', }, +] + +const sortOptions = [ + { name: 'id-asc', label: 'Most recent' }, + { name: 'id-desc', label: 'Oldest first' }, + { name: 'username-asc', label: 'Username (A-Z)' }, + { name: 'username-desc', label: 'Username (Z-A)' }, + { name: 'author-asc', label: 'Author (A-Z)' }, + { name: 'author-desc', label: 'Author (Z-A)' }, + { name: 'title-asc', label: 'Title (A-Z)' }, + { name: 'title-desc', label: 'Title (Z-A)' }, + // { name: '-asc', label: '' }, + // { name: '-desc', label: '' }, + // { name: '-asc', label: '' }, + // { name: '-desc', label: '' }, + // { name: '-asc', label: '' }, + // { name: '-desc', label: '' }, +] + +class IndexOptions extends Component { + render() { + const { options } = this.props + return ( + <div className='row menubar'> + <div /> + <Select + name={'sort'} + options={sortOptions} + selected={options.sort} + onChange={actions.upload.updateOption} + /> + <Select + name={'thumbnailSize'} + options={thumbnailOptions} + selected={options.thumbnailSize} + onChange={actions.upload.updateOption} + /> + </div> + ) + } +} + +const mapStateToProps = state => ({ + options: state.upload.options, +}) + +const mapDispatchToProps = dispatch => ({ +}) + +export default connect(mapStateToProps, mapDispatchToProps)(IndexOptions) diff --git a/animism-align/frontend/app/views/media/components/media.menu.js b/animism-align/frontend/app/views/media/components/media.menu.js new file mode 100644 index 0000000..b782cdc --- /dev/null +++ b/animism-align/frontend/app/views/media/components/media.menu.js @@ -0,0 +1,58 @@ +import React, { Component } from 'react' +import { Route, Link } from 'react-router-dom' +import { connect } from 'react-redux' + +import { history } from 'app/store' +import actions from 'app/actions' +import { MenuButton, FileInput } from 'app/common' + +const mapStateToProps = state => ({ + media: state.media, +}) + +export default class MediaMenu extends Component { + render() { + return ( + <div className='menuButtons'> + <Route exact path='/media/:id/show/' component={MediaShowMenu} /> + <Route exact path='/media/:id/edit/' component={MediaEditMenu} /> + <Route exact path='/media/new/' component={MediaNewMenu} /> + <Route exact path='/media/' component={MediaIndexMenu} /> + </div> + ) + } +} + +const MediaIndexMenu = () => ([ + <MenuButton key='new' name="new" href="/media/new/" />, +]) + +const MediaShowMenu = connect(mapStateToProps)((props) => ([ + <MenuButton key='back' name="back" href="/media/" />, + <MenuButton key='edit' name="edit" href={"/media/" + props.match.params.id + "/edit/"} />, + <MenuButton key='delete' name="delete" onClick={() => { + const { res: media } = props.media.show + if (confirm("Really delete this media?")) { + actions.media.destroy(media).then(() => { + history.push('/media/') + }) + } + }} />, +])) + +const MediaNewMenu = (props) => ([ + <MenuButton key='back' name="back" href="/media/" />, +]) + +const MediaEditMenu = connect(mapStateToProps)((props) => ([ + <MenuButton key='back' name="back" href="/media/" />, + <MenuButton key='copy' name="copy" href={"/media/" + props.match.params.id + '/copy/'} label="Make a copy" />, + <MenuButton key='delete' name="delete" onClick={() => { + const { res: media } = props.media.show + if (confirm("Really delete this media?")) { + actions.media.destroy(media).then(() => { + history.push('/media/') + }) + } + }} />, +])) |
