diff options
Diffstat (limited to 'frontend/site')
| -rw-r--r-- | frontend/site/actions.js | 19 | ||||
| -rw-r--r-- | frontend/site/app.js | 35 | ||||
| -rw-r--r-- | frontend/site/audio/audio.player.js | 141 | ||||
| -rw-r--r-- | frontend/site/audio/audio.reducer.js | 18 | ||||
| -rw-r--r-- | frontend/site/index.js | 19 | ||||
| -rw-r--r-- | frontend/site/site.css | 20 | ||||
| -rw-r--r-- | frontend/site/site/site.actions.js | 15 | ||||
| -rw-r--r-- | frontend/site/site/site.reducer.js | 43 | ||||
| -rw-r--r-- | frontend/site/store.js | 39 | ||||
| -rw-r--r-- | frontend/site/types.js | 11 | ||||
| -rw-r--r-- | frontend/site/viewer/viewer.container.js | 223 |
11 files changed, 583 insertions, 0 deletions
diff --git a/frontend/site/actions.js b/frontend/site/actions.js new file mode 100644 index 0000000..dea882c --- /dev/null +++ b/frontend/site/actions.js @@ -0,0 +1,19 @@ +import { bindActionCreators } from 'redux' +// import { actions as crudActions } from './api' + +import * as siteActions from 'site/site/site.actions' + +import { store } from 'site/store' + +export default + // Object.keys(crudActions) + // .map(a => [a, crudActions[a]]) + // .concat( + [ + ['site', siteActions], + ] //) + .map(p => [p[0], bindActionCreators(p[1], store.dispatch)]) + .concat([ + // ['socket', socketActions], + ]) + .reduce((a,b) => (a[b[0]] = b[1])&&a,{})
\ No newline at end of file diff --git a/frontend/site/app.js b/frontend/site/app.js new file mode 100644 index 0000000..098bd44 --- /dev/null +++ b/frontend/site/app.js @@ -0,0 +1,35 @@ +import React, { Component } from 'react' +import { ConnectedRouter } from 'connected-react-router' +import { Route } from 'react-router' + +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] + // } + // console.log('loading', graph_name, path_name) + actions.site.loadSite(graph_name) + } + + render() { + return ( + <ConnectedRouter history={this.props.history}> + <div className='app'> + <Route path={'/:graph_name/:page_name'} component={ViewerContainer} exact /> + <Route exact key='root' path='/' render={() => { + // setTimeout(() => this.props.history.push('/'), 10) + return null + }} /> + </div> + </ConnectedRouter> + ) + } +} +/* +*/ 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/site/index.js b/frontend/site/index.js new file mode 100644 index 0000000..337d362 --- /dev/null +++ b/frontend/site/index.js @@ -0,0 +1,19 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import { Provider } from 'react-redux' + +import './site.css' + +import App from 'site/app' + +import { store, history } from 'site/store' + +const container = document.createElement('div') +container.classList.add('container') +document.body.appendChild(container) + +ReactDOM.render( + <Provider store={store}> + <App history={history} /> + </Provider>, 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/site/site/site.actions.js b/frontend/site/site/site.actions.js new file mode 100644 index 0000000..aab68e8 --- /dev/null +++ b/frontend/site/site/site.actions.js @@ -0,0 +1,15 @@ +import * as types from 'site/types' +import { api } from 'app/utils' + +export const setSiteTitle = title => dispatch => { + document.querySelector('title').innerText = title + dispatch({ type: types.site.set_site_title, payload: title }) +} + +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/site/site/site.reducer.js b/frontend/site/site/site.reducer.js new file mode 100644 index 0000000..9763e48 --- /dev/null +++ b/frontend/site/site/site.reducer.js @@ -0,0 +1,43 @@ +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) + switch (action.type) { + case types.site.set_site_title: + return { + ...state, + siteTitle: action.payload, + } + + case types.site.loaded: + return { + ...state, + graph: action.data.graph, + } + + case types.site.interact: + return { + ...state, + interactive: true, + } + + case '@@router/LOCATION_CHANGE': + return { + ...state, + graph: { + ...state.graph, + } + } + + default: + return state + } +} diff --git a/frontend/site/store.js b/frontend/site/store.js new file mode 100644 index 0000000..60c3116 --- /dev/null +++ b/frontend/site/store.js @@ -0,0 +1,39 @@ +import { applyMiddleware, compose, combineReducers, createStore } from 'redux' +import { connectRouter, routerMiddleware } from 'connected-react-router' +import { createBrowserHistory } from 'history' +import thunk from 'redux-thunk' + +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, + }) +) + +const configureStore = (initialState = {}, history) => { + const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose + + const store = createStore( + createRootReducer(history), + initialState, + composeEnhancers( + applyMiddleware( + thunk, + routerMiddleware(history) + ), + ), + ) + + return store +} + +const history = createBrowserHistory() +const store = configureStore({}, history) +const { dispatch } = store + +export { store, history, dispatch } diff --git a/frontend/site/types.js b/frontend/site/types.js new file mode 100644 index 0000000..4ab897f --- /dev/null +++ b/frontend/site/types.js @@ -0,0 +1,11 @@ +import { with_type } from 'app/api/crud.types' + +export const site = with_type('site', [ + 'set_site_title', 'loading', 'loaded', 'error', 'interact' +]) + +export const system = with_type('system', [ + 'load_site', +]) + +export const init = '@@INIT' 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) |
