summaryrefslogtreecommitdiff
path: root/animism-align/frontend/app/views/editor/media/components
diff options
context:
space:
mode:
Diffstat (limited to 'animism-align/frontend/app/views/editor/media/components')
-rw-r--r--animism-align/frontend/app/views/editor/media/components/media.form.js287
-rw-r--r--animism-align/frontend/app/views/editor/media/components/media.formFile.js67
-rw-r--r--animism-align/frontend/app/views/editor/media/components/media.formGallery.js338
-rw-r--r--animism-align/frontend/app/views/editor/media/components/media.formGalleryImage.js129
-rw-r--r--animism-align/frontend/app/views/editor/media/components/media.formImage.js169
-rw-r--r--animism-align/frontend/app/views/editor/media/components/media.formImageSelection.js213
-rw-r--r--animism-align/frontend/app/views/editor/media/components/media.formVideo.js118
-rw-r--r--animism-align/frontend/app/views/editor/media/components/media.indexOptions.js59
-rw-r--r--animism-align/frontend/app/views/editor/media/components/media.menu.js58
9 files changed, 1438 insertions, 0 deletions
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/')
+ })
+ }
+ }} />,
+]))