diff options
Diffstat (limited to 'frontend')
49 files changed, 1874 insertions, 354 deletions
diff --git a/frontend/app/actions.js b/frontend/app/actions.js index 0fba6d1..6cfa470 100644 --- a/frontend/app/actions.js +++ b/frontend/app/actions.js @@ -1,5 +1,5 @@ import { bindActionCreators } from 'redux' -import { actions as crudActions } from './api' +import { actions as crudActions } from 'app/api' import * as siteActions from 'app/views/site/site.actions' @@ -15,4 +15,4 @@ export default .concat([ // ['socket', socketActions], ]) - .reduce((a,b) => (a[b[0]] = b[1])&&a,{})
\ No newline at end of file + .reduce((a,b) => (a[b[0]] = b[1])&&a,{}) diff --git a/frontend/app/api/crud.upload.js b/frontend/app/api/crud.upload.js index 8c1b265..2837dd4 100644 --- a/frontend/app/api/crud.upload.js +++ b/frontend/app/api/crud.upload.js @@ -1,4 +1,5 @@ import { as_type } from 'app/api/crud.types' +import { session } from 'app/session' export function crud_upload(type, data, dispatch) { return new Promise( (resolve, reject) => { @@ -6,9 +7,17 @@ export function crud_upload(type, data, dispatch) { const { id } = data const fd = new FormData() + if (!data.tag) { + data.tag = 'misc' + } Object.keys(data).forEach(key => { - if (key !== 'id') { + if (key.indexOf('__') !== -1) return + if (key === 'id') return + const fn_key = `__${key}_filename` + if (fn_key in data) { + fd.append(key, data[key], data[fn_key]) + } else { fd.append(key, data[key]) } }) @@ -23,12 +32,11 @@ export function crud_upload(type, data, dispatch) { xhr.addEventListener("error", uploadFailed, false) xhr.addEventListener("abort", uploadCancelled, false) xhr.open("POST", url) + xhr.setRequestHeader("Authorization", "Bearer " + session.get("access_token")) xhr.send(fd) dispatch && dispatch({ type: as_type(type, 'upload_loading')}) - let complete = false - function uploadProgress (e) { if (e.lengthComputable) { const percent = Math.round(e.loaded * 100 / e.total) || 0 diff --git a/frontend/app/common/app.css b/frontend/app/common/app.css index d9f9946..486e5fa 100644 --- a/frontend/app/common/app.css +++ b/frontend/app/common/app.css @@ -116,6 +116,20 @@ header > div > button:hover { border-color: #fff; color: #fff; } + +header .building { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + margin-left: 1rem; + color: #888; +} +header .building .loader { + transform: scale(0.75); + margin-right: 0.5rem; +} + header .vcat-btn { font-size: 0.875rem; padding-left: 0.5rem; @@ -147,6 +161,16 @@ header a:active { header a.navbar-brand { font-size: .8rem; } +header .arrow { + padding: 0.5rem 0.5rem 0.5rem 0.5rem; + margin-left: -0.5rem; + margin-right: 0.25rem; + transition: background 0.2s; + border-radius: 4px; +} +header .arrow:hover { + background: rgba(0,0,255,0.5); +} header .username { cursor: pointer; diff --git a/frontend/app/common/form.component.js b/frontend/app/common/form.component.js index cf3e466..de1020a 100644 --- a/frontend/app/common/form.component.js +++ b/frontend/app/common/form.component.js @@ -23,21 +23,60 @@ export const LabelDescription = props => ( </label> ) -export const NumberInput = props => ( - <label className={props.error ? 'error' : 'text'}> - <span>{props.title}</span> - <input - type="number" - required={props.required} - onChange={props.onChange} - name={props.name} - value={props.data[props.name]} - min={props.min} - max={props.max} - step={props.step || 1} - /> - </label> -) +export class NumberInput extends Component { + constructor(props) { + super(props) + this.handleKeyDown = this.handleKeyDown.bind(this) + } + handleKeyDown(e) { + const { min, max, step, data, name, onChange } = this.props + const value = data[name] + // console.log(e.keyCode) + switch (e.keyCode) { + case 38: // up + if (e.shiftKey) { + e.preventDefault() + onChange({ + target: { + name, + value: Math.min(max, parseFloat(value) + ((step || 1) * 10)) + } + }) + } + break + case 40: // down + if (e.shiftKey) { + e.preventDefault() + onChange({ + target: { + name, + value: Math.max(min, parseFloat(value) - ((step || 1) * 10)) + } + }) + } + break + } + } + render() { + const { props } = this + return ( + <label className={props.error ? 'error' : 'text'}> + <span>{props.title}</span> + <input + type="number" + required={props.required} + onKeyDown={this.handleKeyDown} + onChange={props.onChange} + name={props.name} + value={props.data[props.name]} + min={props.min} + max={props.max} + step={props.step || 1} + /> + </label> + ) + } +} export const ColorInput = props => ( <label className={props.error ? 'error color' : 'text color'}> @@ -71,12 +110,12 @@ export const TextArea = props => ( ) export const Checkbox = props => ( - <label className="checkbox"> + <label className={props.className ? props.className + " checkbox" : "checkbox"}> <input type="checkbox" name={props.name} value={1} - checked={props.checked} + checked={!!props.checked} onChange={(e) => props.onChange(props.name, e.target.checked)} /> <span>{props.label}</span> diff --git a/frontend/app/common/slider.component.js b/frontend/app/common/slider.component.js index 9d96b1e..d19ab9b 100644 --- a/frontend/app/common/slider.component.js +++ b/frontend/app/common/slider.component.js @@ -53,7 +53,7 @@ export default class Slider extends Component { } } handleKeyDown(e) { - console.log(e.keyCode) + // console.log(e.keyCode) } handleRange(e){ let { value: new_value } = e.target @@ -90,7 +90,7 @@ export default class Slider extends Component { step = 1 value = this.props.options.indexOf(value) } else { - step = (this.props.max - this.props.min) / 100 + step = this.props.step || (this.props.max - this.props.min) / 100 text_value = parseFloat(value).toFixed(2) } return ( diff --git a/frontend/app/site/viewer/viewer.container.js b/frontend/app/site/viewer/viewer.container.js deleted file mode 100644 index 42ce6c2..0000000 --- a/frontend/app/site/viewer/viewer.container.js +++ /dev/null @@ -1,96 +0,0 @@ -import React, { Component } from 'react' -import { Route } from 'react-router-dom' -import { bindActionCreators } from 'redux' -import { connect } from 'react-redux' - -import actions from '../actions' -import { Loader } from 'app/common/loader.component' -import TileHandle from 'app/views/page/components/tile.handle' - -import '../../views/page/page.css' - -class ViewerContainer extends Component { - state = { - page: {}, - } - - constructor(props) { - super(props) - this.pageRef = React.createRef() - this.handleMouseDown = this.handleMouseDown.bind(this) - } - - componentDidUpdate(prevProps) { - // console.log('didUpdate', this.props.graph !== prevProps.graph, this.props.location.pathname !== prevProps.location.pathname) - if (this.props.graph !== prevProps.graph || this.props.location.pathname !== prevProps.location.pathname) { - this.load() - } - } - - load() { - const { graph_name, page_name } = this.props.match.params - const page_path = ["", graph_name, page_name].join('/') - const { pages, home_page } = this.props.graph - const page = pages[page_path] - if (!page) { - // console.log('-> home page') - console.log(page_path) - const { home_page } = this.props.graph - this.setState({ page: pages[home_page] }) - } else { - // console.log(page) - console.log(page_path) - this.setState({ page }) - } - } - - handleMouseDown(e, tile) { - // console.log(tile) - } - - render() { - const { page } = this.state - if (this.props.graph.loading || !page.id) { - return ( - <div> - <div className='body'> - <div className='page loading'> - <Loader /> - </div> - </div> - </div> - ) - } - const { settings } = page - const pageStyle = { backgroundColor: settings ? settings.background_color : '#000000' } - // console.log(page) - return ( - <div className='body'> - <div className='page' ref={this.pageRef} style={pageStyle}> - {page.tiles.map(tile => { - return ( - <TileHandle - viewing - key={tile.id} - tile={tile} - bounds={this.state.bounds} - onMouseDown={e => this.handleMouseDown(e, tile)} - onDoubleClick={e => {}} - /> - ) - })} - </div> - </div> - ) - } -} - -const mapStateToProps = state => ({ - site: state.site, - graph: state.site.graph, -}) - -const mapDispatchToProps = dispatch => ({ -}) - -export default connect(mapStateToProps, mapDispatchToProps)(ViewerContainer) diff --git a/frontend/app/types.js b/frontend/app/types.js index 7120a91..f0f1e27 100644 --- a/frontend/app/types.js +++ b/frontend/app/types.js @@ -6,6 +6,7 @@ export const graph = crud_type('graph', [ 'show_add_page_form', 'hide_add_page_form', 'toggle_add_page_form', 'show_edit_page_form', 'hide_edit_page_form', 'toggle_edit_page_form', 'update_graph_page', + 'toggle_audio_list', ]) export const page = crud_type('page', [ @@ -14,6 +15,8 @@ export const page = crud_type('page', [ 'update_page_tile', 'set_tile_sort_order', 'update_tile_sort_order', 'show_tile_list', 'hide_tile_list', 'toggle_tile_list', + 'toggle_popups', 'load_popups', + 'toggle_sidebar_side', ]) export const tile = crud_type('tile', [ diff --git a/frontend/app/utils/index.js b/frontend/app/utils/index.js index bb5e01d..d67d89a 100644 --- a/frontend/app/utils/index.js +++ b/frontend/app/utils/index.js @@ -8,6 +8,7 @@ export const formatDateTime = dateStr => format(new Date(dateStr), 'd MMM yyyy H export const formatDate = dateStr => format(new Date(dateStr), 'd MMM yyyy') export const formatTime = dateStr => format(new Date(dateStr), 'H:mm') export const formatAge = dateStr => formatDistance(new Date(), new Date(dateStr)) + ' ago.' +export const unslugify = fn => fn.replace(/-/g, ' ').replace(/_/g, ' ').replace('.mp3', '') /* Mobile check */ @@ -49,7 +50,8 @@ export const pad = (n, m) => { } export const courtesyS = (n, s) => n + ' ' + (n === 1 ? s : s + 's') - +export const capitalize = s => s.split(' ').map(capitalizeWord).join(' ') +export const capitalizeWord = s => s.substr(0, 1).toUpperCase() + s.substr(1) export const padSeconds = n => n < 10 ? '0' + n : n export const timestamp = (n = 0, fps = 25) => { @@ -61,6 +63,16 @@ export const timestamp = (n = 0, fps = 25) => { } return (n % 60) + ':' + s } +export const timestampToSeconds = time_str => { + const time_str_parts = (time_str || "").trim().split(":").map(s => parseFloat(s)) + if (time_str_parts.length === 3) { + return (time_str_parts[0] * 60 + time_str_parts[1]) * 60 + time_str_parts[2] + } + if (time_str_parts.length === 2) { + return time_str_parts[0] * 60 + time_str_parts[1] + } + return time_str_parts[0] +} export const percent = n => (n * 100).toFixed(1) + '%' @@ -120,6 +132,35 @@ export const preloadImage = url => ( }) ) +export const preloadVideo = url => ( + new Promise((resolve, reject) => { + const video = document.createElement('video') + let loaded = false + const bind = () => { + video.addEventListener('loadedmetadata', onload) + video.addEventListener('error', onerror) + } + const unbind = () => { + video.removeEventListener('loadedmetadata', onload) + video.removeEventListener('error', onerror) + } + const onload = () => { + if (loaded) return + loaded = true + unbind() + resolve(video) + } + const onerror = (error) => { + if (loaded) return + loaded = true + unbind() + reject(error) + } + bind() + video.src = url + }) +) + export const cropImage = (url, crop) => { return new Promise((resolve, reject) => { let { x, y, w, h } = crop diff --git a/frontend/app/views/audio/components/audio.select.js b/frontend/app/views/audio/components/audio.select.js new file mode 100644 index 0000000..384bb7a --- /dev/null +++ b/frontend/app/views/audio/components/audio.select.js @@ -0,0 +1,58 @@ +import React, { Component } from 'react' +import { connect } from 'react-redux' + +import { Select } from 'app/common' +import { unslugify } from 'app/utils' + +const NO_AUDIO = 0 +const AUDIO_TOP_OPTIONS = [ + { name: NO_AUDIO, label: 'No Sound' }, + { name: -2, label: '──────────', disabled: true }, +] + +class AudioSelect extends Component { + state = { + audioList: [] + } + + constructor(props) { + super(props) + this.handleChange = this.handleChange.bind(this) + } + + componentDidMount(){ + const { uploads } = this.props.graph.show.res + const audioUploads = uploads + .filter(upload => upload.tag === 'audio') + .map(upload => ({ name: upload.id, label: unslugify(upload.fn) })) + let audioList = [ + ...AUDIO_TOP_OPTIONS, + ...audioUploads, + ] + this.setState({ + audioList, + }) + } + + handleChange(name, value) { + this.props.onChange(name, parseInt(value)) + } + + render() { + return ( + <Select + title={this.props.title || "Audio"} + name={this.props.name} + selected={this.props.selected || NO_AUDIO} + options={this.state.audioList} + onChange={this.handleChange} + /> + ) + } +} + +const mapStateToProps = state => ({ + graph: state.graph, +}) + +export default connect(mapStateToProps)(AudioSelect) diff --git a/frontend/app/views/graph/components/audio.list.js b/frontend/app/views/graph/components/audio.list.js new file mode 100644 index 0000000..011ab08 --- /dev/null +++ b/frontend/app/views/graph/components/audio.list.js @@ -0,0 +1,148 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' +import { connect } from 'react-redux' + +import { history } from 'app/store' +import { unslugify } from 'app/utils' +import actions from 'app/actions' + +class AudioList extends Component { + state = { + playing: false, + play_id: -1, + } + + constructor(props) { + super(props) + this.toggleAudio = this.toggleAudio.bind(this) + this.upload = this.upload.bind(this) + this.audioDidEnd = this.audioDidEnd.bind(this) + } + + componentDidMount() { + this.audioElement = document.createElement('audio') + this.audioElement.addEventListener('ended', this.audioDidEnd) + } + + componentWillUnmount() { + this.audioElement.removeEventListener('ended', this.audioDidEnd) + this.audioElement.pause() + this.audioElement = null + } + + audioDidEnd() { + this.setState({ playing: false }) + } + + upload(e) { + e.preventDefault() + document.body.className = '' + const files = e.dataTransfer ? e.dataTransfer.files : e.target.files + let i + if (!files.length) return + Array.from(files).forEach(file => this.uploadTaggedFile(file, 'audio', file.filename)) + } + + uploadTaggedFile(file, tag, fn) { + return new Promise((resolve, reject) => { + this.setState({ status: "Uploading " + tag + "..." }) + const uploadData = { + tag, + file, + __file_filename: fn, + graph_id: this.props.graph.id, + username: 'swimmer', + } + // console.log(uploadData) + return actions.upload.upload(uploadData).then(data => { + // console.log(data) + resolve({ + ...data.res, + }) + }) + }) + } + + destroyFile(upload) { + return new Promise((resolve, reject) => { + actions.upload.destroy(upload) + .then(() => { + console.log('Destroy successful') + resolve() + }) + .catch(() => { + console.log('Error deleting the file') + reject() + }) + }) + } + + toggleAudio(upload) { + console.log(upload) + let playing = false + if (this.state.play_id === upload.id && this.state.playing) { + this.audioElement.pause() + } else { + this.audioElement.src = upload.url + this.audioElement.currentTime = 0 + this.audioElement.play() + playing = true + } + this.setState({ + playing, + play_id: upload.id, + }) + } + + render() { + const { playing, play_id } = this.state + const { graph } = this.props + // console.log(graph.uploads) + return ( + <div className='box audioList'> + <div className="uploadButton"> + <button> + <span> + {"Upload an audio file"} + </span> + </button> + <input + type="file" + accept="audio/mp3" + onChange={this.upload} + required={this.props.required} + /> + </div> + {graph.uploads.map(upload => ( + <div className='audioItem' key={upload.id} onClick={() => this.toggleAudio(upload)} > + <img + className='playButton' + src={ + (playing && play_id === upload.id) + ? "/static/img/icons_pause_white.svg" + : "/static/img/icons_play_white.svg" + } + /> + <div className='title'> + <div>{unslugify(upload.fn)}</div> + </div> + </div> + ))} + </div> + ) + } +} + +const mapStateToProps = state => ({ + graph: state.graph.show.res, +}) + +const mapDispatchToProps = dispatch => ({ +}) + +export default connect(mapStateToProps, mapDispatchToProps)(AudioList) + + +/* + - upload new audio file + */
\ No newline at end of file diff --git a/frontend/app/views/graph/components/graph.header.js b/frontend/app/views/graph/components/graph.header.js index 46ad962..0766580 100644 --- a/frontend/app/views/graph/components/graph.header.js +++ b/frontend/app/views/graph/components/graph.header.js @@ -9,10 +9,12 @@ function GraphHeader(props) { return ( <header> <div> - <Link to="/" className="logo"><b>{props.site.siteTitle}</b></Link> + <Link to="/" className="logo arrow">{"◁ "}</Link> + <b>{props.site.siteTitle}</b> </div> <div> <button onClick={() => props.graphActions.toggleAddPageForm()}>+ Add page</button> + <button onClick={() => props.graphActions.toggleAudioList()}>+ Audio</button> </div> </header> ) diff --git a/frontend/app/views/graph/components/page.edit.js b/frontend/app/views/graph/components/page.edit.js index 4025726..16a7eef 100644 --- a/frontend/app/views/graph/components/page.edit.js +++ b/frontend/app/views/graph/components/page.edit.js @@ -45,6 +45,7 @@ class PageEdit extends Component { return ( <PageForm data={show.res} + actions={{ graph: this.props.graphActions }} graph={this.props.graph.show.res} onSubmit={this.handleSubmit.bind(this)} /> diff --git a/frontend/app/views/graph/components/page.form.js b/frontend/app/views/graph/components/page.form.js index 8fc00b0..a060698 100644 --- a/frontend/app/views/graph/components/page.form.js +++ b/frontend/app/views/graph/components/page.form.js @@ -2,8 +2,11 @@ import React, { Component } from 'react' import { Link } from 'react-router-dom' import { session } from 'app/session' +import actions from 'app/actions' +import { history } from 'app/store' -import { TextInput, ColorInput, LabelDescription, TextArea, Checkbox, SubmitButton, Loader } from 'app/common' +import { TextInput, ColorInput, Checkbox, LabelDescription, TextArea, SubmitButton, Loader } from 'app/common' +import AudioSelect from 'app/views/audio/components/audio.select' const newPage = (data) => ({ path: '', @@ -14,6 +17,8 @@ const newPage = (data) => ({ x: 0.05, y: 0.05, background_color: '#000000', + background_audio_id: 0, + restart_audio: false, }, ...data, }) @@ -26,6 +31,16 @@ export default class PageForm extends Component { errorFields: new Set([]), } + constructor(props){ + super(props) + this.handleChange = this.handleChange.bind(this) + this.handleSelect = this.handleSelect.bind(this) + this.handleSettingsChange = this.handleSettingsChange.bind(this) + this.handleSettingsSelect = this.handleSettingsSelect.bind(this) + this.handleSubmit = this.handleSubmit.bind(this) + this.handleDelete = this.handleDelete.bind(this) + } + componentDidMount() { const { graph, data, isNew } = this.props const title = isNew ? 'new page' : 'editing ' + data.title @@ -76,6 +91,10 @@ export default class PageForm extends Component { handleSettingsChange(e) { const { name, value } = e.target + this.handleSettingsSelect(name, value) + } + + handleSettingsSelect(name, value) { this.setState({ data: { ...this.state.data, @@ -110,11 +129,17 @@ export default class PageForm extends Component { } } - handleDelete() { + handleDelete(e) { + e && e.preventDefault() + e && e.stopPropagation() const { data } = this.state console.log(data) if (confirm('Really delete this page?')) { - actions.page.delete(page_id) + actions.page.destroy(data) + .then(() => { + this.props.actions.graph.hideEditPageForm() + history.goBack() + }) } } @@ -124,14 +149,14 @@ export default class PageForm extends Component { return ( <div className='box'> <h1>{title}</h1> - <form onSubmit={this.handleSubmit.bind(this)}> + <form onSubmit={this.handleSubmit}> <TextInput title="Path" name="path" required data={data} error={errorFields.has('path')} - onChange={this.handleChange.bind(this)} + onChange={this.handleChange} autoComplete="off" /> <LabelDescription> @@ -143,32 +168,48 @@ export default class PageForm extends Component { required data={data} error={errorFields.has('title')} - onChange={this.handleChange.bind(this)} + onChange={this.handleChange} autoComplete="off" /> <ColorInput - title='BG' + title='BG Color' name='background_color' data={data.settings} - onChange={this.handleSettingsChange.bind(this)} + onChange={this.handleSettingsChange} autoComplete="off" /> <TextArea title="Description" name="description" data={data} - onChange={this.handleChange.bind(this)} + onChange={this.handleChange} + /> + + <AudioSelect + title="Background Audio" + name="background_audio_id" + selected={data.settings.background_audio_id} + onChange={this.handleSettingsSelect} /> + + <Checkbox + label="Restart audio on load" + name="restart_audio" + checked={data.settings.restart_audio} + onChange={this.handleSettingsSelect} + autoComplete="off" + /> + <div className='row buttons'> <SubmitButton title={submitTitle} - onClick={this.handleSubmit.bind(this)} + onClick={this.handleSubmit} /> {!isNew && <SubmitButton title={'Delete'} className='destroy' - onClick={this.handleDelete.bind(this)} + onClick={this.handleDelete} /> } </div> diff --git a/frontend/app/views/graph/graph.actions.js b/frontend/app/views/graph/graph.actions.js index a24ccc2..4185386 100644 --- a/frontend/app/views/graph/graph.actions.js +++ b/frontend/app/views/graph/graph.actions.js @@ -1,4 +1,5 @@ import * as types from 'app/types' +import { api } from 'app/utils' import actions from 'app/actions' export const showAddPageForm = () => dispatch => { @@ -25,6 +26,10 @@ export const toggleEditPageForm = () => dispatch => { dispatch({ type: types.graph.toggle_edit_page_form }) } +export const toggleAudioList = () => dispatch => { + dispatch({ type: types.graph.toggle_audio_list }) +} + export const updateGraphPage = page => dispatch => { dispatch({ type: types.graph.update_graph_page, page }) } @@ -34,4 +39,12 @@ export const setHomePageId = (graph, page) => dispatch => { delete updated_graph.pages updated_graph.home_page_id = page.id actions.graph.update(updated_graph) -}
\ No newline at end of file +} + +export const viewPage = (graph, page) => dispatch => { + api(dispatch, types.api, 'export', `/api/v1/graph/export/${graph.path}`) + .then(result => { + console.log(result) + window.open(`${process.env.EXPORT_HOST}/${graph.path}/${page.path}`) + }) +} diff --git a/frontend/app/views/graph/graph.container.js b/frontend/app/views/graph/graph.container.js index 9e354fc..34c3d9d 100644 --- a/frontend/app/views/graph/graph.container.js +++ b/frontend/app/views/graph/graph.container.js @@ -15,6 +15,7 @@ import PageEdit from './components/page.edit' import GraphHeader from './components/graph.header' import GraphEditor from './components/graph.editor' +import AudioList from './components/audio.list' class GraphContainer extends Component { componentDidMount() { @@ -63,6 +64,7 @@ class GraphContainer extends Component { <div className='sidebar'> {this.props.graph.editor.addingPage && <PageNew />} {this.props.graph.editor.editingPage && <PageEdit />} + {this.props.graph.editor.showingAudio && <AudioList />} </div> </div> </div> diff --git a/frontend/app/views/graph/graph.css b/frontend/app/views/graph/graph.css index 389a55d..171bb38 100644 --- a/frontend/app/views/graph/graph.css +++ b/frontend/app/views/graph/graph.css @@ -29,6 +29,10 @@ max-height: 100%; z-index: 20; } +.sidebar.left { + right: auto; + left: 0; +} .box { width: 15rem; padding: 0.5rem; @@ -65,6 +69,9 @@ justify-content: flex-start; align-items: center; } +.box form label.checkbox.short span { + padding: 0.125rem 0; +} .box form input[type="checkbox"] { margin-left: 0rem; } @@ -100,6 +107,9 @@ padding: 0.25rem; margin-right: 0; } +.box .single .select { + width: 9.25rem; +} .box .selects label { flex-direction: row; width: 6.5rem; @@ -110,6 +120,18 @@ padding: 0.25rem; } +.box form .single label span { + min-width: auto; + width: 4.25rem; + padding: 0.25rem 0; +} +.box .single label { + flex-direction: row; + width: 100%; + margin-right: 0.5px; + min-width: auto; +} + .box form .pair label span { min-width: 3rem; padding: 0.25rem 0; @@ -123,11 +145,45 @@ .box .pair input[type=text] { width: 3rem; } + +.box form .single_text label span { + min-width: auto; + width: 6rem; + padding: 0.25rem 0; +} +.box .single_text input[type=text] { + width: 7rem; +} +.box .single_text label { + flex-direction: row; + justify-content: space-between; + width: 100%; + margin-right: 0.5px; + min-width: auto; +} + .box .position { font-size: smaller; margin-bottom: 0.25rem; } +button.box_corner { + position: absolute; + top: 1.25rem; right: 1.25rem; + padding: 0.5rem; + background: transparent; + border: 0; + border-radius: 4px; + transform: scaleX(-1); +} +button.box_corner:hover { + color: #fff; + background: rgba(64,64,128,0.5); +} +.sidebar.left button.box_corner { + transform: scaleX(1); +} + .box .slider { display: flex; flex-direction: row; @@ -146,6 +202,59 @@ width: 5.5rem; } +/* Upload area */ + +.box .uploadButton { + position: relative; + display: flex; + justify-content: center; + align-items: center; + margin-top: 0.5rem; + margin-bottom: 0.5rem; +} +.uploadButton input[type=file] { + position: absolute; + top: 0; left: 0; + width: 100%; height: 100%; +} +.audioList .audioItem { + display: flex; + justify-content: flex-start; + align-items: center; + cursor: pointer; + padding: 0.125rem 0; +} +.audioList .playButton { + background: transparent; + border: 0; + width: 1.5rem; + height: 1.5rem; + margin-right: 0.5rem; + opacity: 0.8; +} +.audioList .title { + display: flex; + justify-content: flex-start; + align-items: center; + overflow: hidden; + flex: 1; +} +.audioList .title div { + overflow: hidden; + text-overflow: ellipsis; + white-space: pre; + width: 100%; +} +.audioList .audioItem:hover { + background: rgba(255,255,255,0.2); +} +.audioList .audioItem:hover .title { + color: #fff; +} +.audioList .audioItem:hover .playButton { + opacity: 1.0; +} + /* Graph handles */ .handle { @@ -156,7 +265,7 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - max-width: 8rem; + max-width: 12rem; user-select: none; cursor: arrow; } diff --git a/frontend/app/views/graph/graph.reducer.js b/frontend/app/views/graph/graph.reducer.js index 6be5089..725c256 100644 --- a/frontend/app/views/graph/graph.reducer.js +++ b/frontend/app/views/graph/graph.reducer.js @@ -7,6 +7,8 @@ const initialState = crudState('graph', { editor: { addingPage: false, editingPage: false, + showingAudio: false, + building: false, }, options: { } @@ -36,6 +38,19 @@ export default function graphReducer(state = initialState, action) { } } + case types.upload.upload_complete: + console.log(action) + return { + ...state, + show: { + ...state.show, + res: { + ...state.show.res, + uploads: state.show.res.uploads.concat(action.data.res) + } + } + } + case types.graph.show_add_page_form: return { ...state, @@ -43,6 +58,7 @@ export default function graphReducer(state = initialState, action) { ...state.editor, addingPage: true, editingPage: false, + showingAudio: false, } } @@ -52,6 +68,7 @@ export default function graphReducer(state = initialState, action) { editor: { ...state.editor, addingPage: false, + showingAudio: false, } } @@ -62,6 +79,7 @@ export default function graphReducer(state = initialState, action) { ...state.editor, addingPage: !state.editor.addingPage, editingPage: false, + showingAudio: false, } } @@ -72,6 +90,7 @@ export default function graphReducer(state = initialState, action) { ...state.editor, addingPage: false, editingPage: true, + showingAudio: false, } } @@ -81,6 +100,7 @@ export default function graphReducer(state = initialState, action) { editor: { ...state.editor, editingPage: false, + showingAudio: false, } } @@ -91,9 +111,34 @@ export default function graphReducer(state = initialState, action) { ...state.editor, addingPage: false, editingPage: !state.editor.editingPage, + showingAudio: false, } } + case types.graph.toggle_audio_list: + return { + ...state, + editor: { + ...state.editor, + addingPage: false, + editingPage: false, + showingAudio: !state.editor.showingAudio, + } + } + + case types.api.loading: + if (action.tag !== 'view' && action.tag !== 'export') { + return state + } + return { ...state, editor: { ...state.editor, building: action.tag } } + + case types.api.loaded: + case types.api.error: + if (action.tag !== 'view' && action.tag !== 'export') { + return state + } + return { ...state, editor: { ...state.editor, building: null } } + default: return state } diff --git a/frontend/app/views/index/containers/graph.index.js b/frontend/app/views/index/containers/graph.index.js index 91098a7..bf3d75e 100644 --- a/frontend/app/views/index/containers/graph.index.js +++ b/frontend/app/views/index/containers/graph.index.js @@ -11,6 +11,7 @@ class GraphIndex extends Component { componentDidMount() { actions.graph.index() } + render() { const { index } = this.props // console.log(this.props) diff --git a/frontend/app/views/page/components/page.editor.js b/frontend/app/views/page/components/page.editor.js index d324874..ec6ddd3 100644 --- a/frontend/app/views/page/components/page.editor.js +++ b/frontend/app/views/page/components/page.editor.js @@ -5,17 +5,18 @@ import { connect } from 'react-redux' import { session } from 'app/session' import actions from 'app/actions' -import * as pageActions from '../page.actions' -import * as tileActions from '../../tile/tile.actions' +import * as pageActions from 'app/views/page/page.actions' +import * as tileActions from 'app/views/tile/tile.actions' import { Loader } from 'app/common' import { clamp, dist } from 'app/utils' -import TileHandle from './tile.handle' +import TileHandle from 'app/views/tile/components/tile.handle' const defaultState = { dragging: false, bounds: null, + videoBounds: null, mouseX: 0, mouseY: 0, box: { @@ -37,6 +38,7 @@ class PageEditor extends Component { this.handleMouseMove = this.handleMouseMove.bind(this) this.handleMouseUp = this.handleMouseUp.bind(this) this.handleWindowResize = this.handleWindowResize.bind(this) + this.handlePlaybackEnded = this.handlePlaybackEnded.bind(this) this.pageRef = React.createRef() } @@ -59,7 +61,8 @@ class PageEditor extends Component { document.body.addEventListener('mousemove', this.handleMouseMove) document.body.addEventListener('mouseup', this.handleMouseUp) window.addEventListener('resize', this.handleWindowResize) - this.setState({ bounds: this.getBoundingClientRect() }) + const bounds = this.getBoundingClientRect() + this.setState({ bounds }) } componentDidUpdate(prevProps) { @@ -72,7 +75,12 @@ class PageEditor extends Component { this.setState({ bounds: this.getBoundingClientRect() }) } + handlePlaybackEnded() { + // + } + handleMouseDown(e, tile) { + if (e.metaKey || e.ctrlKey || e.altKey || e.button !== 0) return const bounds = this.getBoundingClientRect() const mouseX = e.pageX const mouseY = e.pageY @@ -130,7 +138,7 @@ class PageEditor extends Component { const { dx, dy } = box let url = window.location.pathname this.setState({ - page: null, + tile: null, box: null, initialBox: null, dragging: false, @@ -142,6 +150,7 @@ class PageEditor extends Component { } const updatedTile = { ...tile, + target_page_id: tile.target_page_id || 0, settings: { ...tile.settings, x: tile.settings.x + dx, @@ -158,7 +167,7 @@ class PageEditor extends Component { } } - render(){ + render() { if (!this.state.bounds || (!this.props.page.show.res && !this.props.page.show.res.tiles)) { return ( <div className='page' ref={this.pageRef} /> @@ -170,9 +179,14 @@ class PageEditor extends Component { const { res } = this.props.page.show const { settings } = res const pageStyle = { backgroundColor: settings ? settings.background_color : '#000000' } + const videoBounds = (res.tiles && res.tiles.length && res.tiles[0].type === 'video') ? { + width: res.tiles[0].settings.width, + height: res.tiles[0].settings.height, + } : this.state.bounds return ( <div className='page' ref={this.pageRef} style={pageStyle}> - {res.tiles.map(tile => { + {res.tiles && res.tiles.map(tile => { + if (!this.props.page.editor.showingPopups && tile.settings.is_popup) return if (temporaryTile && temporaryTile.id === tile.id) { tile = temporaryTile } @@ -181,9 +195,11 @@ class PageEditor extends Component { key={tile.id} tile={tile} bounds={this.state.bounds} + videoBounds={videoBounds} box={currentTile && tile.id === currentTile.id && currentBox} onMouseDown={e => this.handleMouseDown(e, tile)} onDoubleClick={e => this.props.pageActions.showEditTileForm(tile.id)} + onPlaybackEnded={this.handlePlaybackEnded} /> ) })} @@ -192,8 +208,10 @@ class PageEditor extends Component { key={temporaryTile.id} tile={temporaryTile} bounds={this.state.bounds} + videoBounds={videoBounds} box={currentTile && temporaryTile.id === currentTile.id && currentBox} onMouseDown={e => this.handleMouseDown(e, temporaryTile)} + onPlaybackEnded={this.handlePlaybackEnded} /> )} </div> diff --git a/frontend/app/views/page/components/page.header.js b/frontend/app/views/page/components/page.header.js index eb1c3b9..d40f6e0 100644 --- a/frontend/app/views/page/components/page.header.js +++ b/frontend/app/views/page/components/page.header.js @@ -3,6 +3,9 @@ import { bindActionCreators } from 'redux' import { connect } from 'react-redux' import { Link } from 'react-router-dom' +import { Loader } from 'app/common' +import { capitalize } from 'app/utils' + import * as graphActions from '../../graph/graph.actions' import * as pageActions from '../page.actions' @@ -10,12 +13,23 @@ function PageHeader(props) { return ( <header> <div> - <Link to={props.graph.show.res ? "/" + props.graph.show.res.path : "/"} className="logo"><b>{props.site.siteTitle}</b></Link> + <Link to={props.graph ? "/" + props.graph.path : "/"} className="logo arrow">{"◁"}</Link> + <b>{props.site.siteTitle}</b> + {props.building && ( + <div className='building'> + <div className='loader'> + <Loader /> + </div> + {capitalize(props.building)}ing... + </div> + )} </div> <div> <button onClick={() => props.pageActions.toggleAddTileForm()}>+ Add tile</button> <button onClick={() => props.pageActions.toggleTileList()}>Sort tiles</button> + <button onClick={() => props.pageActions.togglePopups()}>Toggle popups</button> <button onClick={() => props.graphActions.toggleEditPageForm()}>Edit page</button> + <button onClick={() => props.graphActions.viewPage(props.graph, props.page)}>View page</button> </div> </header> ) @@ -24,7 +38,9 @@ function PageHeader(props) { const mapStateToProps = (state) => ({ // auth: state.auth, site: state.site, - graph: state.graph, + graph: state.graph.show.res, + page: state.page.show.res, + building: state.graph.editor.building, // isAuthenticated: state.auth.isAuthenticated, }) diff --git a/frontend/app/views/page/components/tile.handle.js b/frontend/app/views/page/components/tile.handle.js deleted file mode 100644 index 624b175..0000000 --- a/frontend/app/views/page/components/tile.handle.js +++ /dev/null @@ -1,146 +0,0 @@ -import React, { Component } from 'react' -import { Link } from 'react-router-dom' - -const TileHandle = ({ tile, bounds, box, viewing, onMouseDown, onDoubleClick }) => { - // console.log(tile) - const { width, height } = tile.settings - const style = { - transform: generateTransform(tile, box), - opacity: tile.settings.opacity, - } - // console.log(generateTransform(tile)) - let content; - let className = ['tile', tile.type].join(' ') - if (tile.target_page_id || (viewing && tile.href)) { - className += ' ' + (tile.settings.cursor || 'hand_up') - } - // console.log(tile.settings) - switch (tile.type) { - case 'image': - if (!tile.settings.url) { - return null - } - if (tile.settings.is_tiled) { - style.backgroundImage = 'url(' + tile.settings.url + ')' - style.backgroundPosition = tile.settings.align.replace('_', ' ') - switch (tile.settings.tile_style) { - default: - case 'tile': - break - case 'cover': - style.backgroundSize = 'cover' - break - case 'contain': - style.backgroundSize = 'contain' - break - case 'contain no-repeat': - style.backgroundSize = 'contain' - style.backgroundRepeat = 'no-repeat' - break - } - className += ' is_tiled' - } else { - className += ' ' + tile.settings.align - content = <img src={tile.settings.url} /> - } - break - case 'text': - if (!tile.settings.content) { - return null - } - content = <span dangerouslySetInnerHTML={{ __html: tile.settings.content }} /> - className += ' ' + tile.settings.align - style.width = tile.settings.width ? tile.settings.width + 'px' : 'auto' - style.height = tile.settings.height ? tile.settings.height + 'px' : 'auto' - style.fontFamily = tile.settings.font_family - style.fontSize = tile.settings.font_size + 'px' - style.lineHeight = 1.5 - style.fontWeight = (tile.settings.font_style || "").indexOf('bold') !== -1 ? 'bold' : 'normal' - style.fontStyle = (tile.settings.font_style || "").indexOf('italic') !== -1 ? 'italic' : 'normal' - style.backgroundColor = tile.settings.background_color || 'transparent' - style.color = tile.settings.font_color || '#dddddd!important' - break - case 'link': - content = "" - className += ' ' + tile.settings.align - style.width = tile.settings.width ? tile.settings.width + 'px' : 'auto' - style.height = tile.settings.height ? tile.settings.height + 'px' : 'auto' - break - case 'script': - content = "" - if (viewing) { - eval(tile.settings.content) - } else { - content = "SCRIPT" - } - } - if (viewing && tile.href) { - if (tile.href.indexOf('http') === 0) { - return ( - <a href={tile.href} rel='noopener'> - <div - className={className} - style={style} - > - {content} - </div> - </a> - ) - } else { - return ( - <Link to={tile.href}> - <div - className={className} - onMouseDown={onMouseDown} - style={style} - > - {content} - </div> - </Link> - ) - } - } else { - return ( - <div - className={className} - onMouseDown={onMouseDown} - onDoubleClick={onDoubleClick} - style={style} - > - {content} - </div> - ) - } -} - -const generateTransform = (tile, box) => { - let { x, y, align, rotation, scale, is_tiled } = tile.settings - if (is_tiled) { - return 'translateZ(0)' - } - if (box) { - x += box.dx - y += box.dy - } - const [yalign, xalign] = align.split('_') - let transform = ['translateZ(0)'] - if (yalign === 'center') { - transform.push('translateY(-50%)') - } - if (xalign === 'center') { - transform.push('translateX(-50%)') - } - // if (x % 2 == 1) x += 0.5 - // if (y % 2 == 1) y += 0.5 - transform.push('translateX(' + x + 'px)') - transform.push('translateY(' + y + 'px)') - if (scale !== 1) { - transform.push('scale(' + scale + ')') - } - if (rotation !== 0) { - transform.push('rotateZ(' + rotation + 'deg)') - } - return transform.join(' ') -} - -export default TileHandle diff --git a/frontend/app/views/page/cursors.css b/frontend/app/views/page/cursors.css index 56fb088..6cc37a9 100644 --- a/frontend/app/views/page/cursors.css +++ b/frontend/app/views/page/cursors.css @@ -13,6 +13,13 @@ .tile.hand_left { cursor: url(/static/img/hand_left.png) 10 60, pointer; } +.tile.unclickable { + cursor: default; + pointer-events: none; +} +.tile.none { + cursor: default; +} .tile.link { cursor: pointer; diff --git a/frontend/app/views/page/page.actions.js b/frontend/app/views/page/page.actions.js index d2bbbe2..e42d539 100644 --- a/frontend/app/views/page/page.actions.js +++ b/frontend/app/views/page/page.actions.js @@ -50,6 +50,30 @@ export const toggleTileList = () => dispatch => { dispatch({ type: types.page.toggle_tile_list }) } +// Popups + +export const loadPopups = (page, popups) => dispatch => { + const state = store.getState() + page = page || state.page.show.res + popups = popups || state.page.editor.popups + popups = page.tiles.reduce((acc, tile) => { + const { is_popup, popup_group } = tile.settings + if (is_popup) { + acc[popup_group] = acc[popup_group] || false + } + return acc + }, { ...popups }) + // console.log(popups) + dispatch({ type: types.page.load_popups, popups }) +} +export const togglePopups = () => dispatch => { + dispatch({ type: types.page.toggle_popups }) +} + +export const toggleSidebarSide = () => dispatch => { + dispatch({ type: types.page.toggle_sidebar_side }) +} + // Update local page tile state when we change it export const updatePageTile = tile => dispatch => { diff --git a/frontend/app/views/page/page.container.js b/frontend/app/views/page/page.container.js index dc85f5e..0ad9806 100644 --- a/frontend/app/views/page/page.container.js +++ b/frontend/app/views/page/page.container.js @@ -9,13 +9,13 @@ import './cursors.css' import actions from 'app/actions' import { Loader } from 'app/common' -import * as graphActions from '../graph/graph.actions' +import * as graphActions from 'app/views/graph/graph.actions' import * as pageActions from './page.actions' -import PageEdit from '../graph/components/page.edit' -import TileNew from './components/tile.new' -import TileEdit from './components/tile.edit' -import TileList from './components/tile.list' +import PageEdit from 'app/views/graph/components/page.edit' +import TileNew from 'app/views/tile/components/tile.new' +import TileEdit from 'app/views/tile/components/tile.edit' +import TileList from 'app/views/tile/components/tile.list' import PageHeader from './components/page.header' import PageEditor from './components/page.editor' @@ -44,6 +44,7 @@ class PageContainer extends Component { this.props.pageActions.showGraphAndPageIfUnloaded(this.props.match.params) .then(data => { actions.site.setSiteTitle(data.res.title) + this.props.pageActions.loadPopups(data.res, {}) if (!data.res.tiles.length) { this.props.pageActions.showAddTileForm() } else { @@ -70,7 +71,7 @@ class PageContainer extends Component { <PageHeader /> <div className='body'> <PageEditor /> - <div className='sidebar'> + <div className={this.props.page.editor.sidebarOnRight ? 'sidebar' : 'sidebar left'}> {this.props.graph.editor.editingPage && <PageEdit />} {this.props.page.editor.addingTile && <TileNew />} {this.props.page.editor.editingTile && <TileEdit />} diff --git a/frontend/app/views/page/page.css b/frontend/app/views/page/page.css index 4559543..2e0efb9 100644 --- a/frontend/app/views/page/page.css +++ b/frontend/app/views/page/page.css @@ -15,6 +15,9 @@ .tile.image { display: block; } +.tile.video { + display: block; +} .tile.image.is_tiled { width: 100%; height: 100%; @@ -114,6 +117,12 @@ .box .row.pair { justify-content: space-between; } +.box .row.pair.with_checkbox { + align-items: flex-end; +} +.box .row.single { + justify-content: space-between; +} .box .pair label:last-child { margin-right: 0; } diff --git a/frontend/app/views/page/page.reducer.js b/frontend/app/views/page/page.reducer.js index c2d231a..a1f281a 100644 --- a/frontend/app/views/page/page.reducer.js +++ b/frontend/app/views/page/page.reducer.js @@ -9,6 +9,9 @@ const initialState = crudState('page', { editingTile: false, currentEditTileId: 0, tileList: false, + showingPopups: true, + sidebarOnRight: true, + popups: {}, }, options: { } @@ -195,6 +198,32 @@ export default function pageReducer(state = initialState, action) { } } + case types.page.toggle_popups: + return { + ...state, + editor: { + ...state.editor, + showingPopups: !state.editor.showingPopups, + } + } + + case types.page.load_popups: + return { + ...state, + editor: { + ...state.editor, + popups: action.popups, + } + } + + case types.page.toggle_sidebar_side: + return { + ...state, + editor: { + ...state.editor, + sidebarOnRight: !state.editor.sidebarOnRight, + } + } default: return state diff --git a/frontend/app/views/page/components/tile.edit.js b/frontend/app/views/tile/components/tile.edit.js index 2ea09d1..cae9f73 100644 --- a/frontend/app/views/page/components/tile.edit.js +++ b/frontend/app/views/tile/components/tile.edit.js @@ -29,7 +29,9 @@ class TileEdit extends Component { load() { const { currentEditTileId } = this.props.page.editor - const tile = this.props.page.show.res.tiles.filter(tile => tile.id === currentEditTileId)[0] + const { tiles } = this.props.page.show.res + if (!tiles) return + const tile = tiles.filter(tile => tile.id === currentEditTileId)[0] console.log('edit', currentEditTileId) this.setState({ tile }) } diff --git a/frontend/app/views/page/components/tile.form.js b/frontend/app/views/tile/components/tile.form.js index 3f43dd0..8a6a08e 100644 --- a/frontend/app/views/page/components/tile.form.js +++ b/frontend/app/views/tile/components/tile.form.js @@ -10,12 +10,14 @@ import { TextInput, NumberInput, ColorInput, Slider, Select, LabelDescription, TextArea, Checkbox, SubmitButton, Loader } from 'app/common' -import { preloadImage } from 'app/utils' +import AudioSelect from 'app/views/audio/components/audio.select' +import { preloadImage, preloadVideo } from 'app/utils' -import * as tileActions from '../../tile/tile.actions' +import * as pageActions from 'app/views/page/page.actions' +import * as tileActions from 'app/views/tile/tile.actions' const SELECT_TYPES = [ - "image", "text", "link", "script", + "image", "text", "video", "link", "script", ].map(s => ({ name: s, label: s })) const ALIGNMENTS = [ @@ -31,6 +33,7 @@ const ALIGNMENTS = [ const REQUIRED_KEYS = { image: ['url'], + video: ['url'], text: ['content'], link: [], script: [], @@ -40,6 +43,10 @@ const IMAGE_TILE_STYLES = [ 'tile', 'cover', 'contain', 'contain no-repeat' ].map(style => ({ name: style, label: style })) +const VIDEO_STYLES = [ + 'normal', 'cover', 'contain', +].map(style => ({ name: style, label: style })) + const TEXT_FONT_FAMILIES = [ 'sans-serif', 'serif', 'fantasy', 'monospace', 'cursive', ].map(style => ({ name: style, label: style })) @@ -49,18 +56,38 @@ const TEXT_FONT_STYLES = [ ].map(style => ({ name: style, label: style })) const CURSORS = [ + { name: 'none', label: 'None', }, { name: 'hand_up', label: 'Up', }, { name: 'hand_down', label: 'Down', }, { name: 'hand_left', label: 'Left', }, { name: 'hand_right', label: 'Right', }, + { name: 'unclickable', label: 'Unclickable', }, +] + +const UNITS = [ + { name: 'px', label: 'pixels' }, + { name: '%', label: 'percent' }, + { name: 'video', label: 'video' }, + { name: 'vmin', label: 'screen min' }, + { name: 'vmax', label: 'screen max' }, ] const NO_LINK = 0 const EXTERNAL_LINK = -1 +const OPEN_POPUP_LINK = -2 +const CLOSE_POPUP_LINK = -3 const PAGE_LIST_TOP_OPTIONS = [ { name: NO_LINK, label: 'No link' }, { name: EXTERNAL_LINK, label: 'External link' }, - { name: -2, label: '──────────', disabled: true }, + { name: OPEN_POPUP_LINK, label: 'Open popup' }, + { name: CLOSE_POPUP_LINK, label: 'Close popup' }, + { name: -99, label: '──────────', disabled: true }, +] + +const NO_POPUP = 0 +const POPUP_LIST_TOP_OPTIONS = [ + { name: NO_POPUP, label: 'Select a popup group' }, + { name: -99, label: '──────────', disabled: true }, ] // target_page_id = Column(Integer, ForeignKey('page.id'), nullable=True) @@ -76,7 +103,26 @@ const newImage = (data) => ({ cursor: 'hand_up', }, type: 'image', - target_page_id: null, + target_page_id: 0, + ...data, +}) + +const newVideo = (data) => ({ + settings: { + ...newPosition(), + video_style: 'cover', + url: "", + external_link_url: "", + cursor: 'none', + muted: false, + loop_style: false, + autoadvance: false, + loop_section: false, + loop_start: 0, + loop_end: 0, + }, + type: 'video', + target_page_id: 0, ...data, }) @@ -91,11 +137,12 @@ const newText = (data) => ({ background_color: 'transparent', width: 0, height: 0, + units: 'px', external_link_url: "", cursor: 'hand_up', }, type: 'text', - target_page_id: null, + target_page_id: 0, ...data, }) @@ -104,9 +151,10 @@ const newLink = (data) => ({ ...newPosition({ width: 100, height: 100, }), external_link_url: "", cursor: 'hand_up', + units: 'px', }, type: 'link', - target_page_id: null, + target_page_id: 0, ...data, }) @@ -123,12 +171,18 @@ const newPosition = (data) => ({ width: 0, height: 0, rotation: 0, scale: 1, opacity: 1, + units: false, align: "center_center", + has_audio: false, + audio_on_click_id: 0, + audio_on_hover_id: 0, + navigate_when_audio_finishes: false, ...data, }) const TYPE_CONSTRUCTORS = { image: newImage, + video: newVideo, text: newText, link: newLink, script: newScript, @@ -141,6 +195,20 @@ class TileForm extends Component { errorFields: new Set([]), modified: false, pageList: [], + popupList: [], + } + + constructor(props){ + super(props) + this.handleChange = this.handleChange.bind(this) + this.handleSelect = this.handleSelect.bind(this) + this.handleSettingsChange = this.handleSettingsChange.bind(this) + this.handleSettingsSelect = this.handleSettingsSelect.bind(this) + this.handleAlignment = this.handleAlignment.bind(this) + this.handleImageChange = this.handleImageChange.bind(this) + this.handleVideoChange = this.handleVideoChange.bind(this) + this.handleSubmit = this.handleSubmit.bind(this) + this.handleDelete = this.handleDelete.bind(this) } componentDidMount() { @@ -158,7 +226,11 @@ class TileForm extends Component { ...PAGE_LIST_TOP_OPTIONS, ...linkPages.map(page => ({ name: page.id, label: page.path })) ] - this.setState({ pageList }) + let popupList = [ + ...POPUP_LIST_TOP_OPTIONS, + ...Object.keys(page.editor.popups).map(popup_group => ({ name: popup_group, label: popup_group })) + ] + this.setState({ pageList, popupList }) if (isNew) { const newTile = newImage({ id: "new", @@ -277,6 +349,24 @@ class TileForm extends Component { }) } + handleVideoChange(e) { + const { name, value } = e.target + this.handleSettingsSelect(name, value) + preloadVideo(value).then(video => { + // console.log(img) + this.props.tileActions.updateTemporaryTile({ + ...this.props.temporaryTile, + settings: { + ...this.props.temporaryTile.settings, + [name]: value, + width: video.videoWidth, + height: video.videoHeight, + x: 0, y: 0, + } + }) + }) + } + clearErrorField(name) { const { errorFields } = this.state if (errorFields.has(name)) { @@ -313,6 +403,7 @@ class TileForm extends Component { } else { validData.id = temporaryTile.id } + validData.target_page_id = validData.target_page_id || 0 this.setState({ modified: false }) console.log('submit', validData) onSubmit(validData) @@ -340,21 +431,24 @@ class TileForm extends Component { return ( <div className='box'> <h1>{title}</h1> - <form onSubmit={this.handleSubmit.bind(this)}> + <button className='box_corner' onClick={this.props.pageActions.toggleSidebarSide}> + {'◁'} + </button> + <form onSubmit={this.handleSubmit}> <div className="row selects"> <Select name='type' selected={temporaryTile.type} options={SELECT_TYPES} title='' - onChange={this.handleSelect.bind(this)} + onChange={this.handleSelect} /> <Select name='align' selected={temporaryTile.settings.align} options={ALIGNMENTS} title='' - onChange={this.handleAlignment.bind(this)} + onChange={this.handleAlignment} /> </div> @@ -362,6 +456,8 @@ class TileForm extends Component { {temporaryTile.type === 'image' ? this.renderImageForm() + : temporaryTile.type === 'video' + ? this.renderVideoForm() : temporaryTile.type === 'text' ? this.renderTextForm() : temporaryTile.type === 'link' @@ -372,17 +468,18 @@ class TileForm extends Component { {this.renderHyperlinkForm()} {this.renderMiscForm()} + {this.renderAudioForm()} <div className='row buttons'> <SubmitButton title={submitTitle} - onClick={this.handleSubmit.bind(this)} + onClick={this.handleSubmit} /> {!isNew && <SubmitButton title={'Delete'} className='destroy' - onClick={this.handleDelete.bind(this)} + onClick={this.handleDelete} /> } </div> @@ -427,7 +524,7 @@ class TileForm extends Component { required data={temporaryTile.settings} error={errorFields.has('url')} - onChange={this.handleImageChange.bind(this)} + onChange={this.handleImageChange} autoComplete="off" /> </div> @@ -436,7 +533,7 @@ class TileForm extends Component { label="Tiled" name="is_tiled" checked={temporaryTile.settings.is_tiled} - onChange={this.handleSettingsSelect.bind(this)} + onChange={this.handleSettingsSelect} autoComplete="off" /> {temporaryTile.settings.is_tiled && @@ -445,7 +542,7 @@ class TileForm extends Component { selected={temporaryTile.settings.tile_style || 'tile'} options={IMAGE_TILE_STYLES} title='' - onChange={this.handleSettingsSelect.bind(this)} + onChange={this.handleSettingsSelect} /> } </div> @@ -453,6 +550,103 @@ class TileForm extends Component { ) } + renderVideoForm() { + // const { isNew } = this.props + const { temporaryTile } = this.props + const { errorFields } = this.state + // console.log(temporaryTile.settings) + return ( + <div> + <div className='row imageUrl'> + <TextInput + title="" + placeholder='http://' + name="url" + required + data={temporaryTile.settings} + error={errorFields.has('url')} + onChange={this.handleVideoChange} + autoComplete="off" + /> + </div> + <div className='row pair with_checkbox'> + <Select + name='video_style' + selected={temporaryTile.settings.video_style || 'none'} + options={VIDEO_STYLES} + title='' + onChange={this.handleSettingsSelect} + /> + <Checkbox + label="Loop" + name="loop" + checked={temporaryTile.settings.loop} + onChange={this.handleSettingsSelect} + autoComplete="off" + /> + </div> + <div className='row pair'> + <Checkbox + label="Muted" + name="muted" + className='short' + checked={temporaryTile.settings.muted} + onChange={this.handleSettingsSelect} + /> + <Checkbox + label="Autoadvance" + name="autoadvance" + className='short' + checked={temporaryTile.settings.autoadvance} + onChange={this.handleSettingsSelect} + /> + </div> + {!temporaryTile.settings.muted && ( + <Slider + title='Volume' + name='volume' + value={('volume' in temporaryTile.settings) ? temporaryTile.settings.volume : 1.0} + onChange={this.handleSettingsSelect} + min={0.0} + max={1.0} + step={0.01} + /> + )} + {temporaryTile.settings.loop && ( + <div className='row'> + <Checkbox + label="Loop section?" + className='short' + name="loop_section" + checked={temporaryTile.settings.loop_section} + onChange={this.handleSettingsSelect} + /> + </div> + )} + {temporaryTile.settings.loop && temporaryTile.settings.loop_section && ( + <div className='row pair'> + <TextInput + title="From" + placeholder='0:00' + name="loop_start" + data={temporaryTile.settings} + onChange={this.handleSettingsChange} + autoComplete="off" + /> + <TextInput + title="To" + placeholder='0:00' + name="loop_end" + data={temporaryTile.settings} + onChange={this.handleSettingsChange} + autoComplete="off" + /> + </div> + )} + </div> + ) + } + renderTextForm() { const { temporaryTile } = this.props const { errorFields } = this.state @@ -464,7 +658,7 @@ class TileForm extends Component { required data={temporaryTile.settings} error={errorFields.has('content')} - onChange={this.handleSettingsChange.bind(this)} + onChange={this.handleSettingsChange} autoComplete="off" /> <div className='row font'> @@ -474,7 +668,7 @@ class TileForm extends Component { selected={temporaryTile.settings.font_family || 'sans-serif'} options={TEXT_FONT_FAMILIES} title='' - onChange={this.handleSettingsSelect.bind(this)} + onChange={this.handleSettingsSelect} /> <NumberInput title='' @@ -483,7 +677,7 @@ class TileForm extends Component { min={1} max={1200} error={errorFields.has('font_size')} - onChange={this.handleSettingsChange.bind(this)} + onChange={this.handleSettingsChange} autoComplete="off" /> <Select @@ -491,7 +685,7 @@ class TileForm extends Component { selected={temporaryTile.settings.font_style || 'normal'} options={TEXT_FONT_STYLES} title='' - onChange={this.handleSettingsSelect.bind(this)} + onChange={this.handleSettingsSelect} /> </div> <ColorInput @@ -499,7 +693,7 @@ class TileForm extends Component { name='font_color' data={temporaryTile.settings} error={errorFields.has('font_color')} - onChange={this.handleSettingsChange.bind(this)} + onChange={this.handleSettingsChange} autoComplete="off" /> <ColorInput @@ -507,7 +701,7 @@ class TileForm extends Component { name='background_color' data={temporaryTile.settings} error={errorFields.has('background_color')} - onChange={this.handleSettingsChange.bind(this)} + onChange={this.handleSettingsChange} autoComplete="off" /> <div className='row pair'> @@ -518,7 +712,7 @@ class TileForm extends Component { min={0} max={1200} error={errorFields.has('width')} - onChange={this.handleSettingsChange.bind(this)} + onChange={this.handleSettingsChange} autoComplete="off" /> <NumberInput @@ -528,7 +722,7 @@ class TileForm extends Component { min={0} max={1200} error={errorFields.has('height')} - onChange={this.handleSettingsChange.bind(this)} + onChange={this.handleSettingsChange} autoComplete="off" /> </div> @@ -549,7 +743,7 @@ class TileForm extends Component { min={0} max={1200} error={errorFields.has('width')} - onChange={this.handleSettingsChange.bind(this)} + onChange={this.handleSettingsChange} autoComplete="off" /> <NumberInput @@ -559,7 +753,7 @@ class TileForm extends Component { min={0} max={1200} error={errorFields.has('height')} - onChange={this.handleSettingsChange.bind(this)} + onChange={this.handleSettingsChange} autoComplete="off" /> </div> @@ -578,7 +772,7 @@ class TileForm extends Component { required data={temporaryTile.settings} error={errorFields.has('content')} - onChange={this.handleSettingsChange.bind(this)} + onChange={this.handleSettingsChange} autoComplete="off" /> <div> @@ -590,8 +784,12 @@ class TileForm extends Component { renderHyperlinkForm() { const { temporaryTile } = this.props - const { pageList } = this.state + const { pageList, popupList } = this.state const isExternalLink = temporaryTile.target_page_id === EXTERNAL_LINK + const isPopupLink = ( + temporaryTile.target_page_id === OPEN_POPUP_LINK || + temporaryTile.target_page_id === CLOSE_POPUP_LINK + ) return ( <div> <div className={'row selects'}> @@ -600,7 +798,7 @@ class TileForm extends Component { name='target_page_id' selected={temporaryTile.target_page_id || NO_LINK} options={pageList} - onChange={this.handleSelect.bind(this)} + onChange={this.handleSelect} /> <Select title='' @@ -608,21 +806,79 @@ class TileForm extends Component { selected={temporaryTile.settings.cursor} options={CURSORS} defaultOption="Cursor" - onChange={this.handleSettingsSelect.bind(this)} + onChange={this.handleSettingsSelect} /> </div> - <div> - {isExternalLink && + {isExternalLink && ( + <div> <TextInput title="" placeholder='http://' name="external_link_url" data={temporaryTile.settings} - onChange={this.handleSettingsChange.bind(this)} + onChange={this.handleSettingsChange} autoComplete="off" /> - } - </div> + </div> + )} + {(temporaryTile.target_page_id === OPEN_POPUP_LINK || temporaryTile.target_page_id === CLOSE_POPUP_LINK) && ( + <div className='row single'> + <Select + title="Popup" + name='target_popup' + selected={temporaryTile.settings.target_popup || NO_POPUP} + options={popupList} + onChange={this.handleSettingsSelect} + /> + </div> + )} + </div> + ) + } + + renderAudioForm() { + const { temporaryTile } = this.props + return ( + <div> + <Checkbox + label="Sound effects" + name="has_audio" + className='short' + checked={temporaryTile.settings.has_audio} + onChange={this.handleSettingsSelect} + /> + {temporaryTile.settings.has_audio && ( + <div> + <div className='row single'> + <AudioSelect + title="On click" + name="audio_on_click_id" + selected={temporaryTile.settings.audio_on_click_id} + onChange={this.handleSettingsSelect} + /> + </div> + + {!!temporaryTile.settings.audio_on_click_id && ( + <Checkbox + label="Navigate when audio finishes" + name="navigate_when_audio_finishes" + className='short' + checked={temporaryTile.settings.navigate_when_audio_finishes} + onChange={this.handleSettingsSelect} + autoComplete="off" + /> + )} + + <div className='row single'> + <AudioSelect + title="On hover" + name="audio_on_hover_id" + selected={temporaryTile.settings.audio_on_hover_id} + onChange={this.handleSettingsSelect} + /> + </div> + </div> + )} </div> ) } @@ -631,11 +887,20 @@ class TileForm extends Component { const { temporaryTile } = this.props return ( <div> + <div className='row single'> + <Select + name='units' + selected={temporaryTile.settings.units || 'px'} + options={UNITS} + title='Units' + onChange={this.handleSettingsSelect} + /> + </div> <Slider title='Opacity' name='opacity' value={temporaryTile.settings.opacity} - onChange={this.handleSettingsSelect.bind(this)} + onChange={this.handleSettingsSelect} min={0.0} max={1.0} step={0.01} @@ -644,7 +909,7 @@ class TileForm extends Component { title='Scale' name='scale' value={temporaryTile.settings.scale} - onChange={this.handleSettingsSelect.bind(this)} + onChange={this.handleSettingsSelect} min={0.01} max={10.0} step={0.01} @@ -653,12 +918,58 @@ class TileForm extends Component { title='Rotation' name='rotation' value={temporaryTile.settings.rotation} - onChange={this.handleSettingsSelect.bind(this)} + onChange={this.handleSettingsSelect} min={-180.0} max={180.0} step={1} type='int' /> + <Checkbox + label="Element is a Popup" + name="is_popup" + className='short' + checked={temporaryTile.settings.is_popup} + onChange={this.handleSettingsSelect} + autoComplete="off" + /> + {temporaryTile.settings.is_popup && ( + <div className='row single_text'> + <TextInput + title="Popup group" + name="popup_group" + data={temporaryTile.settings} + onChange={this.handleSettingsChange} + autoComplete="off" + /> + </div> + )} + <Checkbox + label="Wait to appear" + name="wait_to_appear" + className='short' + checked={temporaryTile.settings.wait_to_appear} + onChange={this.handleSettingsSelect} + autoComplete="off" + /> + {temporaryTile.settings.wait_to_appear && ( + <div className='row single_text'> + <TextInput + title="Appear after" + name="appear_after" + data={temporaryTile.settings} + onChange={this.handleSettingsChange} + autoComplete="off" + /> + </div> + )} + <Checkbox + label="Hide on click" + name="hide_on_click" + className='short' + checked={temporaryTile.settings.hide_on_click} + onChange={this.handleSettingsSelect} + autoComplete="off" + /> </div> ) } @@ -672,6 +983,7 @@ const mapStateToProps = state => ({ }) const mapDispatchToProps = dispatch => ({ + pageActions: bindActionCreators({ ...pageActions }, dispatch), tileActions: bindActionCreators({ ...tileActions }, dispatch), }) diff --git a/frontend/app/views/tile/components/tile.handle.js b/frontend/app/views/tile/components/tile.handle.js new file mode 100644 index 0000000..03b9f88 --- /dev/null +++ b/frontend/app/views/tile/components/tile.handle.js @@ -0,0 +1,8 @@ +import React from 'react' + +import handles from 'app/views/tile/handles' + +export default function TileHandle (props) { + const Tile = handles[props.tile.type] + return <Tile {...props} /> +} diff --git a/frontend/app/views/page/components/tile.list.js b/frontend/app/views/tile/components/tile.list.js index c455489..127ca52 100644 --- a/frontend/app/views/page/components/tile.list.js +++ b/frontend/app/views/tile/components/tile.list.js @@ -114,6 +114,10 @@ const TileListLink = ({ tile, pageTitles }) => ( {'Link: '} {tile.target_page_id === -1 ? 'External' + : tile.target_page_id === -2 + ? 'Open popup' + : tile.target_page_id === -3 + ? 'Close popup' : !tile.target_page_id ? 'No link specified!' : tile.target_page_id in pageTitles @@ -123,6 +127,14 @@ const TileListLink = ({ tile, pageTitles }) => ( </div> ) +const TileListVideo = ({ tile }) => { + return ( + <div className='row' data-id={tile.id}> + <span className='snippet'>{"Vido: "}{tile.settings.url}</span> + </div> + ) +} + const TileListMisc = ({ tile }) => ( <div className='row' data-id={tile.id}> <span className='snippet'>{"Tile: "}{tile.type}</span> diff --git a/frontend/app/views/page/components/tile.new.js b/frontend/app/views/tile/components/tile.new.js index b491fdd..e0f61a6 100644 --- a/frontend/app/views/page/components/tile.new.js +++ b/frontend/app/views/tile/components/tile.new.js @@ -21,6 +21,7 @@ class TileNew extends Component { // history.push('/' + graph.path + '/' + res.res.path) // } this.props.pageActions.hideAddTileForm() + this.props.pageActions.loadPopups() this.props.tileActions.clearTemporaryTile() }) .catch(err => { diff --git a/frontend/app/views/tile/handles/index.js b/frontend/app/views/tile/handles/index.js new file mode 100644 index 0000000..8aaeb06 --- /dev/null +++ b/frontend/app/views/tile/handles/index.js @@ -0,0 +1,14 @@ + +import TileImage from './tile.image' +import TileVideo from './tile.video' +import TileLink from './tile.link' +import TileText from './tile.text' +import TileScript from './tile.script' + +export default { + image: TileImage, + video: TileVideo, + link: TileLink, + text: TileText, + script: TileScript, +} diff --git a/frontend/app/views/tile/handles/tile.image.js b/frontend/app/views/tile/handles/tile.image.js new file mode 100644 index 0000000..9ab5616 --- /dev/null +++ b/frontend/app/views/tile/handles/tile.image.js @@ -0,0 +1,56 @@ +import React from 'react' +import { generateTransform } from 'app/views/tile/tile.utils' + +export default function TileImage({ tile, box, bounds, videoBounds, viewing, onMouseDown, onDoubleClick }) { + // console.log(tile) + const style = { + transform: generateTransform(tile, box, bounds, videoBounds), + opacity: tile.settings.opacity, + } + // console.log(generateTransform(tile)) + let content + let className = ['tile', tile.type].join(' ') + if (tile.target_page_id || (viewing && tile.href)) { + if (viewing || tile.settings.cursor !== 'unclickable') { + className += ' ' + (tile.settings.cursor || 'hand_up') + } + } + + if (!tile.settings.url) { + return null + } + if (tile.settings.is_tiled) { + style.backgroundImage = 'url(' + tile.settings.url + ')' + style.backgroundPosition = tile.settings.align.replace('_', ' ') + switch (tile.settings.tile_style) { + default: + case 'tile': + break + case 'cover': + style.backgroundSize = 'cover' + break + case 'contain': + style.backgroundSize = 'contain' + break + case 'contain no-repeat': + style.backgroundSize = 'contain' + style.backgroundRepeat = 'no-repeat' + break + } + className += ' is_tiled' + } else { + className += ' ' + tile.settings.align + content = <img src={tile.settings.url} /> + } + + return ( + <div + className={className} + onMouseDown={onMouseDown} + onDoubleClick={onDoubleClick} + style={style} + > + {content} + </div> + ) +} diff --git a/frontend/app/views/tile/handles/tile.link.js b/frontend/app/views/tile/handles/tile.link.js new file mode 100644 index 0000000..4dd4fd4 --- /dev/null +++ b/frontend/app/views/tile/handles/tile.link.js @@ -0,0 +1,33 @@ +import React from 'react' +import { generateTransform, unitsDimension } from 'app/views/tile/tile.utils' + +export default function TileScript({ tile, box, bounds, videoBounds, viewing, onMouseDown, onDoubleClick }) { + // console.log(tile) + const style = { + transform: generateTransform(tile, box, bounds, videoBounds), + opacity: tile.settings.opacity, + } + // console.log(generateTransform(tile)) + let className = ['tile', tile.type].join(' ') + if (tile.target_page_id || (viewing && tile.href)) { + if (viewing || tile.settings.cursor !== 'unclickable') { + className += ' ' + (tile.settings.cursor || 'hand_up') + } + } + + let content = "" + className += ' ' + tile.settings.align + style.width = unitsDimension(tile, 'width', bounds, videoBounds) + style.height = unitsDimension(tile, 'height', bounds, videoBounds) + + return ( + <div + className={className} + onMouseDown={onMouseDown} + onDoubleClick={onDoubleClick} + style={style} + > + {content} + </div> + ) +} diff --git a/frontend/app/views/tile/handles/tile.script.js b/frontend/app/views/tile/handles/tile.script.js new file mode 100644 index 0000000..e844adf --- /dev/null +++ b/frontend/app/views/tile/handles/tile.script.js @@ -0,0 +1,36 @@ +import React, { Component } from 'react' +import { generateTransform } from 'app/views/tile/tile.utils' + +export default class TileScript extends Component { + componentDidMount(){ + const { viewing, tile } = this.props + if (viewing) { + eval(tile.settings.content) + } + } + render() { + if (viewing) { + return <div style={{ display: 'none' }} /> + } + + const { tile, box, bounds, videoBounds, viewing, onMouseDown, onDoubleClick } = this.props + // console.log(tile) + const style = { + transform: generateTransform(tile, box, bounds, videoBounds), + opacity: tile.settings.opacity, + } + // console.log(generateTransform(tile)) + let className = ['tile', tile.type, 'hand_up'].join(' ') + + return ( + <div + className={className} + onMouseDown={onMouseDown} + onDoubleClick={onDoubleClick} + style={style} + > + {"SCRIPT"} + </div> + ) + } +} diff --git a/frontend/app/views/tile/handles/tile.text.js b/frontend/app/views/tile/handles/tile.text.js new file mode 100644 index 0000000..2fd63b2 --- /dev/null +++ b/frontend/app/views/tile/handles/tile.text.js @@ -0,0 +1,44 @@ +import React from 'react' +import { generateTransform, unitsDimension } from 'app/views/tile/tile.utils' + +export default function TileScript({ tile, box, bounds, videoBounds, viewing, onMouseDown, onDoubleClick }) { + // console.log(tile) + const style = { + transform: generateTransform(tile, box, bounds, videoBounds), + opacity: tile.settings.opacity, + } + // console.log(generateTransform(tile)) + let className = ['tile', tile.type].join(' ') + if (tile.target_page_id || (viewing && tile.href)) { + if (viewing || tile.settings.cursor !== 'unclickable') { + className += ' ' + (tile.settings.cursor || 'hand_up') + } + } + + if (!tile.settings.content) { + return null + } + + let content = <span dangerouslySetInnerHTML={{ __html: tile.settings.content }} /> + className += ' ' + tile.settings.align + style.width = unitsDimension(tile, 'width', bounds, videoBounds) + style.height = unitsDimension(tile, 'height', bounds, videoBounds) + style.fontFamily = tile.settings.font_family + style.fontSize = tile.settings.font_size + 'px' + style.lineHeight = 1.5 + style.fontWeight = (tile.settings.font_style || "").indexOf('bold') !== -1 ? 'bold' : 'normal' + style.fontStyle = (tile.settings.font_style || "").indexOf('italic') !== -1 ? 'italic' : 'normal' + style.backgroundColor = tile.settings.background_color || 'transparent' + style.color = tile.settings.font_color || '#dddddd!important' + + return ( + <div + className={className} + onMouseDown={onMouseDown} + onDoubleClick={onDoubleClick} + style={style} + > + {content} + </div> + ) +} diff --git a/frontend/app/views/tile/handles/tile.video.js b/frontend/app/views/tile/handles/tile.video.js new file mode 100644 index 0000000..3166848 --- /dev/null +++ b/frontend/app/views/tile/handles/tile.video.js @@ -0,0 +1,108 @@ +import React, { Component } from 'react' + +import { generateTransform, generateVideoStyle } from 'app/views/tile/tile.utils' +import { timestampToSeconds } from 'app/utils' + +export default class TileVideo extends Component { + constructor(props) { + super(props) + this.videoRef = React.createRef() + this.handleTimeUpdate = this.handleTimeUpdate.bind(this) + this.handleEnded = this.handleEnded.bind(this) + } + + componentDidMount() { + this.bind() + } + + componentDidUpdate() { + this.unbind() + this.bind() + } + + componentWillUnmount() { + this.unbind() + } + + bind() { + if (!this.videoRef.current) return + this.el = this.videoRef.current + this.el.addEventListener('ended', this.handleEnded) + this.el.addEventListener('timeupdate', this.handleTimeUpdate) + const muted = this.props.viewing + ? this.props.tile.settings.muted + : true + const volume = muted + ? 0.0 + : ('volume' in this.props.tile.settings) + ? this.props.tile.settings.volume + : 1.0 + this.el.volume = volume + } + + unbind() { + if (!this.el) return + this.el.removeEventListener('timeupdate', this.handleTimeUpdate) + this.el.removeEventListener('ended', this.handleEnded) + } + + handleTimeUpdate() { + if (this.props.tile.settings.loop && this.props.tile.settings.loop_section) { + const loop_start = timestampToSeconds(this.props.tile.settings.loop_start) || 0 + const loop_end = timestampToSeconds(this.props.tile.settings.loop_end) || this.videoRef.current.duration + if (this.videoRef.current.currentTime > loop_end) { + this.videoRef.current.currentTime = loop_start + } + } + } + + handleEnded() { + this.props.onPlaybackEnded(this.props.tile) + if (this.props.tile.settings.loop && this.props.tile.settings.loop_section) { + const loop_start = timestampToSeconds(this.props.tile.settings.loop_start) || 0 + this.videoRef.current.currentTime = loop_start + } + } + + render() { + let { tile, box, bounds, videoBounds, viewing, onMouseDown, onDoubleClick } = this.props + // console.log(tile) + const style = { + transform: generateTransform(tile, box, bounds, videoBounds), + opacity: tile.settings.opacity, + } + let className = ['tile', tile.type].join(' ') + if (tile.target_page_id || (viewing && tile.href)) { + if (viewing || tile.settings.cursor !== 'unclickable') { + className += ' ' + (tile.settings.cursor || 'hand_up') + } + } + // console.log(tile.settings) + if (!tile.settings.url) { + return null + } + className += ' ' + tile.settings.align + const muted = viewing + ? tile.settings.muted + : true + return ( + <div + className={className} + onMouseDown={onMouseDown} + onDoubleClick={onDoubleClick} + style={style} + > + <video + ref={this.videoRef} + src={tile.settings.url} + autoPlay={true} + controls={false} + disablePictureInPicture={true} + loop={tile.settings.loop} + muted={muted} + style={generateVideoStyle(tile, bounds)} + /> + </div> + ) + } +} diff --git a/frontend/app/views/tile/tile.utils.js b/frontend/app/views/tile/tile.utils.js new file mode 100644 index 0000000..46d7764 --- /dev/null +++ b/frontend/app/views/tile/tile.utils.js @@ -0,0 +1,80 @@ +export const generateTransform = (tile, box, bounds, videoBounds) => { + let { x, y, align, rotation, scale, units, is_tiled } = tile.settings + if (is_tiled) { + return 'translateZ(0)' + } + if (box) { + x += box.dx + y += box.dy + } + units = units || 'px' + const [yalign, xalign] = align.split('_') + let transform = ['translateZ(0)'] + if (yalign === 'center') { + transform.push('translateY(-50%)') + } + if (xalign === 'center') { + transform.push('translateX(-50%)') + } + // if (x % 2 == 1) x += 0.5 + // if (y % 2 == 1) y += 0.5 + const xUnits = units === 'video' ? videoUnits(x, bounds, videoBounds) : x + units + const yUnits = units === 'video' ? videoUnits(y, bounds, videoBounds) : y + units + + transform.push('translateX(' + xUnits + ')') + transform.push('translateY(' + yUnits + ')') + if (scale !== 1) { + transform.push('scale(' + scale + ')') + } + if (rotation !== 0) { + transform.push('rotateZ(' + rotation + 'deg)') + } + return transform.join(' ') +} + +export const generateVideoStyle = (tile, bounds) => { + const style = { + pointerEvents: "none", + } + switch (tile.settings.video_style) { + case 'normal': + style.width = "auto" + style.height = "auto" + break + case 'cover': + if (tile.settings.width && (tile.settings.width / tile.settings.height) > (bounds.width / bounds.height)) { + style.width = Math.round((tile.settings.width / tile.settings.height) * bounds.height) + style.height = bounds.height + } else { + style.width = bounds.width + style.height = Math.round((tile.settings.height / tile.settings.width) * bounds.width) + } + break + case 'contain': + if (tile.settings.width && (tile.settings.width / tile.settings.height) > (bounds.width / bounds.height)) { + style.width = bounds.width + style.height = Math.round((tile.settings.height / tile.settings.width) * bounds.width) + } else { + style.width = Math.round((tile.settings.width / tile.settings.height) * bounds.height) + style.height = bounds.height + } + break + } + return style +} + +export const unitsDimension = (tile, dimension, bounds, videoBounds) => { + const value = tile.settings[dimension] + if (!value) return "auto" + if (tile.settings.units) { + if (tile.settings.units === 'video') { + return videoUnits(value, bounds, videoBounds) + } + return value + tile.settings.units + } + return value + "px" +} + +export const videoUnits = (value, bounds, videoBounds) => ( + Math.round(value / videoBounds.width * bounds.width) + 'px' +)
\ No newline at end of file diff --git a/frontend/app/site/actions.js b/frontend/site/actions.js index e672028..dea882c 100644 --- a/frontend/app/site/actions.js +++ b/frontend/site/actions.js @@ -1,9 +1,9 @@ import { bindActionCreators } from 'redux' // import { actions as crudActions } from './api' -import * as siteActions from './site/site.actions' +import * as siteActions from 'site/site/site.actions' -import { store } from './store' +import { store } from 'site/store' export default // Object.keys(crudActions) diff --git a/frontend/app/site/app.js b/frontend/site/app.js index 389e5b5..098bd44 100644 --- a/frontend/app/site/app.js +++ b/frontend/site/app.js @@ -2,19 +2,19 @@ import React, { Component } from 'react' import { ConnectedRouter } from 'connected-react-router' import { Route } from 'react-router' -import ViewerContainer from './viewer/viewer.container' -import actions from './actions' +import ViewerContainer from 'site/viewer/viewer.container' +import actions from 'site/actions' export default class App extends Component { componentDidMount() { const path_partz = window.location.pathname.split('/') const graph_name = path_partz[1] - let path_name = null - if (path_partz.length > 2) { - path_name = path_partz[2] - } + // let path_name = null + // if (path_partz.length > 2) { + // path_name = path_partz[2] + // } // console.log('loading', graph_name, path_name) - actions.site.loadSite(graph_name, path_name) + actions.site.loadSite(graph_name) } render() { diff --git a/frontend/site/audio/audio.player.js b/frontend/site/audio/audio.player.js new file mode 100644 index 0000000..17edeee --- /dev/null +++ b/frontend/site/audio/audio.player.js @@ -0,0 +1,141 @@ +import { history } from 'site/store' + +export default class AudioPlayer { + files = {} + players = {} + current_background_id = 0 + + constructor() { + this.done = this.done.bind(this) + } + + load(graph) { + this.files = graph.uploads + .filter(upload => upload.tag === 'audio') + .reduce((accumulator, item) => { + accumulator[item.id] = item + return accumulator + }, {}) + } + + has(id) { + return ( + (id > 0) && + (id in this.files) + ) + } + + done(id) { + // console.log('remove', id) + delete this.players[id] + } + + playPage(page) { + const { background_audio_id, restart_audio } = page.settings + // console.log('playPage', page) + if ( + this.current_background_id + && this.current_background_id !== background_audio_id + && this.current_background_id in this.players + ) { + this.players[this.current_background_id].stop() + } + if (this.has(background_audio_id)) { + this.current_background_id = background_audio_id + this.playFile({ + id: background_audio_id, + type: 'background', + restart: !!restart_audio, + }) + } + } + + playTile({ tile, type }) { + let id = type === 'click' + ? tile.settings.audio_on_click_id + : type === 'hover' + ? tile.settings.audio_on_hover_id + : null + if (this.has(id)) { + this.playFile({ id, tile, type }) + } + } + + playFile({ id, tile, type, restart, loop }) { + const item = this.files[id] + if (id in this.players) { + if (restart) { + this.players[id].restart() + } + if (tile && !this.players[id].tile) { + this.players[id].tile = tile + this.players[id].type = type + } + return this.players[id] + } else { + this.players[id] = new Player({ + item, + tile, + type, + done: this.done + }) + this.players[id].play() + return this.players[id] + } + } +} + +class Player { + constructor({ item, tile, type, done }) { + this.item = item + this.tile = tile + this.type = type + this.done = done + this.audio = document.createElement('audio') + this.handleEnded = this.handleEnded.bind(this) + this.handleError = this.handleError.bind(this) + this.release = this.release.bind(this) + this.audio.addEventListener('ended', this.handleEnded) + this.audio.addEventListener('error', this.handleError) + this.audio.src = item.url + } + + release() { + if (this.type === 'click' && this.tile && this.tile.settings.navigate_when_audio_finishes) { + history.push(this.tile.href) + } + this.audio.removeEventListener('ended', this.handleEnded) + this.audio.removeEventListener('error', this.handleError) + this.done(this.item.id) + this.item = null + this.done = null + this.audio = null + } + + handleError(error) { + console.error(error) + this.release() + } + + handleEnded() { + if (this.type === 'background') { + this.restart() + } else { + this.release() + } + } + + play() { + this.audio.play() + } + + restart() { + this.audio.currentTime = 0 + this.audio.play() + } + + stop() { + this.audio.pause() + this.release() + } +} diff --git a/frontend/site/audio/audio.reducer.js b/frontend/site/audio/audio.reducer.js new file mode 100644 index 0000000..f0bf0e9 --- /dev/null +++ b/frontend/site/audio/audio.reducer.js @@ -0,0 +1,18 @@ +import AudioPlayer from 'site/audio/audio.player' +import * as types from 'site/types' + +const initialState = { + player: new AudioPlayer(), +} + +export default function audioReducer(state = initialState, action) { + // console.log(action.type, action) + switch (action.type) { + case types.site.loaded: + state.player.load(action.data.graph) + return state + + default: + return state + } +} diff --git a/frontend/app/site/index.js b/frontend/site/index.js index 6f1a0a5..337d362 100644 --- a/frontend/app/site/index.js +++ b/frontend/site/index.js @@ -2,9 +2,11 @@ import React from 'react' import ReactDOM from 'react-dom' import { Provider } from 'react-redux' -import App from './app' +import './site.css' -import { store, history } from './store' +import App from 'site/app' + +import { store, history } from 'site/store' const container = document.createElement('div') container.classList.add('container') diff --git a/frontend/site/site.css b/frontend/site/site.css new file mode 100644 index 0000000..0597514 --- /dev/null +++ b/frontend/site/site.css @@ -0,0 +1,20 @@ +.roadblock { + position: fixed; + top: 0; left: 0; + display: flex; + width: 100vw; + height: 100vh; + justify-content: center; + align-items: center; + cursor: pointer; +} +.roadblock div { + display: inline-block; + text-align: center; +} +.roadblock h2 { + font-style: italic; +} +.roadblock button { + padding: 0.5rem; +} diff --git a/frontend/app/site/site/site.actions.js b/frontend/site/site/site.actions.js index 79e4573..aab68e8 100644 --- a/frontend/app/site/site/site.actions.js +++ b/frontend/site/site/site.actions.js @@ -1,4 +1,4 @@ -import * as types from '../types' +import * as types from 'site/types' import { api } from 'app/utils' export const setSiteTitle = title => dispatch => { @@ -6,6 +6,10 @@ export const setSiteTitle = title => dispatch => { dispatch({ type: types.site.set_site_title, payload: title }) } -export const loadSite = (graph_name, path_name) => dispatch => ( - api(dispatch, types.site, 'site', '/' + graph_name + '/index.json') +export const loadSite = graph_name => dispatch => ( + api(dispatch, types.site, 'site', '/' + graph_name + '/index.json?t=' + (Date.now() / 3600000)) ) + +export const interact = () => dispatch => { + dispatch({ type: types.site.interact }) +}
\ No newline at end of file diff --git a/frontend/app/site/site/site.reducer.js b/frontend/site/site/site.reducer.js index 85c3486..9763e48 100644 --- a/frontend/app/site/site/site.reducer.js +++ b/frontend/site/site/site.reducer.js @@ -1,14 +1,15 @@ -import * as types from '../types' +import * as types from 'site/types' const initialState = { siteTitle: 'swimmer', + interactive: false, graph: { loading: true, } } export default function siteReducer(state = initialState, action) { - console.log(action.type, action) + // console.log(action.type, action) switch (action.type) { case types.site.set_site_title: return { @@ -22,6 +23,12 @@ export default function siteReducer(state = initialState, action) { graph: action.data.graph, } + case types.site.interact: + return { + ...state, + interactive: true, + } + case '@@router/LOCATION_CHANGE': return { ...state, diff --git a/frontend/app/site/store.js b/frontend/site/store.js index a228e2b..60c3116 100644 --- a/frontend/app/site/store.js +++ b/frontend/site/store.js @@ -3,12 +3,14 @@ import { connectRouter, routerMiddleware } from 'connected-react-router' import { createBrowserHistory } from 'history' import thunk from 'redux-thunk' -import siteReducer from './site/site.reducer' +import siteReducer from 'site/site/site.reducer' +import audioReducer from 'site/audio/audio.reducer' const createRootReducer = history => ( combineReducers({ auth: (state = {}) => state, router: connectRouter(history), + audio: audioReducer, site: siteReducer, }) ) diff --git a/frontend/app/site/types.js b/frontend/site/types.js index 23bed98..4ab897f 100644 --- a/frontend/app/site/types.js +++ b/frontend/site/types.js @@ -1,7 +1,7 @@ -import { with_type, crud_type } from 'app/api/crud.types' +import { with_type } from 'app/api/crud.types' export const site = with_type('site', [ - 'set_site_title', 'loading', 'loaded', 'error', + 'set_site_title', 'loading', 'loaded', 'error', 'interact' ]) export const system = with_type('system', [ diff --git a/frontend/site/viewer/viewer.container.js b/frontend/site/viewer/viewer.container.js new file mode 100644 index 0000000..9bf4442 --- /dev/null +++ b/frontend/site/viewer/viewer.container.js @@ -0,0 +1,223 @@ +import React, { Component } from 'react' +import { Route } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import { history } from 'site/store' +import actions from 'site/actions' +import { Loader } from 'app/common/loader.component' +import TileHandle from 'app/views/tile/components/tile.handle' + +import 'app/views/page/page.css' + +class ViewerContainer extends Component { + state = { + page: {}, + bounds: { width: window.innerWidth, height: window.innerHeight }, + roadblock: false, + popups: {}, + hidden: {}, + time: 0, + maxDeferTime: 0, + } + + constructor(props) { + super(props) + this.pageRef = React.createRef() + this.handleMouseDown = this.handleMouseDown.bind(this) + this.handleResize = this.handleResize.bind(this) + this.removeRoadblock = this.removeRoadblock.bind(this) + this.updateTimer = this.updateTimer.bind(this) + window.addEventListener('resize', this.handleResize) + } + + componentDidUpdate(prevProps) { + // console.log('didUpdate', this.props.graph !== prevProps.graph, this.props.location.pathname !== prevProps.location.pathname) + if (this.props.graph !== prevProps.graph || this.props.location.pathname !== prevProps.location.pathname) { + this.load() + } + } + + componentWillUnmount() { + window.removeEventListener('resize', this.handleResize) + actions.site.interact() + } + + load() { + const { graph_name, page_name } = this.props.match.params + const page_path = ["", graph_name, page_name].join('/') + const { pages, home_page } = this.props.graph + const page = pages[page_path] || pages[home_page] + if (!this.props.interactive && hasAutoplay(page)) { + this.setState({ page, popups: {}, hidden: {}, roadblock: true }) + } else { + this.setState({ page, popups: {}, hidden: {}, roadblock: false }) + actions.site.interact() + this.props.audio.player.playPage(page) + this.resetTimer(page) + } + } + + resetTimer(page) { + clearTimeout(this.timeout) + const maxDeferTime = page.tiles.reduce((max_time, tile) => Math.max(tile.settings.appear_after || 0, max_time), 0) + if (maxDeferTime) { + this.setState({ time: 0, maxDeferTime }) + this.timeout = setTimeout(this.updateTimer, 500) + } + } + + updateTimer() { + clearTimeout(this.timeout) + this.setState({ time: this.state.time + 0.500 }) + if (this.state.time < this.state.maxDeferTime) { + this.timeout = setTimeout(this.updateTimer, 500) + } + } + + handleResize() { + this.setState({ + bounds: { + width: window.innerWidth, + height: window.innerHeight, + } + }) + } + + handleMouseDown(e, tile) { + if (tile.href) { + if (tile.href.indexOf('http') === 0) { + window.location.href = tile.href + return + } + else if (tile.href === '__open_popup') { + this.setState({ + popups: { + ...this.state.popups, + [tile.settings.target_popup]: true, + }, + }) + } + else if (tile.href === '__close_popup') { + this.setState({ + popups: { + ...this.state.popups, + [tile.settings.target_popup]: false, + }, + }) + } + else if (!tile.settings.navigate_when_audio_finishes) { + history.push(tile.href) + } + } + if (tile.settings.audio_on_click_id > 0) { + this.props.audio.player.playTile({ + type: "click", + tile, + }) + } + if (tile.settings.hide_on_click) { + this.setState({ + hidden: { + ...this.state.hidden, + [tile.id]: true, + } + }) + } + } + + handlePlaybackEnded(tile) { + if (tile.href && tile.settings.autoadvance) { + history.push(tile.href) + } + } + + render() { + const { page, audio, popups, hidden, time } = this.state + if (this.state.roadblock) { + return this.renderRoadblock() + } + if (this.props.graph.loading || !page.id) { + return ( + <div> + <div className='body'> + <div className='page loading'> + <Loader /> + </div> + </div> + </div> + ) + } + const { settings } = page + const pageStyle = { backgroundColor: settings ? settings.background_color : '#000000' } + const videoBounds = (page.tiles.length && page.tiles[0].type === 'video') ? { + width: page.tiles[0].settings.width, + height: page.tiles[0].settings.height, + } : this.state.bounds + // console.log(page) + return ( + <div className='body'> + <div className='page' ref={this.pageRef} style={pageStyle}> + {page.tiles.map(tile => { + if (tile.settings.is_popup && !popups[tile.settings.popup_group]) return + if (tile.settings.appear_after && time < tile.settings.appear_after) return + if (tile.settings.hide_on_click && hidden[tile.id]) return + return ( + <TileHandle + viewing + key={tile.id} + tile={tile} + audio={audio} + bounds={this.state.bounds} + videoBounds={videoBounds} + onMouseDown={e => this.handleMouseDown(e, tile)} + onPlaybackEnded={e => this.handlePlaybackEnded(e, tile)} + onDoubleClick={e => {}} + /> + ) + })} + </div> + </div> + ) + } + + removeRoadblock() { + console.log("remove roadblock") + actions.site.interact() + this.setState({ roadblock: false }) + this.props.audio.player.playPage(this.state.page) + this.resetTimer(this.state.page) + } + + renderRoadblock() { + const { title } = this.props.graph + return ( + <div className='roadblock' onClick={this.removeRoadblock}> + <div> + <h2>{title}</h2> + <button>Enter</button> + </div> + </div> + ) + } +} + +const hasAutoplay = page => { + const hasAutoplayVideo = page.tiles.some(tile => { + return tile.type === 'video' && !tile.settings.muted + }) + const hasAutoplayAudio = page.settings.background_audio_id > 0 + return hasAutoplayAudio || hasAutoplayVideo +} + +const mapStateToProps = state => ({ + site: state.site, + audio: state.audio, + graph: state.site.graph, + interactive: state.site.interactive, +}) + +const mapDispatchToProps = dispatch => ({ +}) + +export default connect(mapStateToProps, mapDispatchToProps)(ViewerContainer) |
