summaryrefslogtreecommitdiff
path: root/frontend/app/views
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/app/views')
-rw-r--r--frontend/app/views/graph/components/graph.canvas.js138
-rw-r--r--frontend/app/views/graph/components/graph.editor.js212
-rw-r--r--frontend/app/views/graph/components/graph.header.js31
-rw-r--r--frontend/app/views/graph/components/page.edit.js65
-rw-r--r--frontend/app/views/graph/components/page.form.js185
-rw-r--r--frontend/app/views/graph/components/page.handle.js59
-rw-r--r--frontend/app/views/graph/components/page.new.js47
-rw-r--r--frontend/app/views/graph/graph.actions.js37
-rw-r--r--frontend/app/views/graph/graph.container.js82
-rw-r--r--frontend/app/views/graph/graph.css172
-rw-r--r--frontend/app/views/graph/graph.reducer.js100
-rw-r--r--frontend/app/views/index.js4
-rw-r--r--frontend/app/views/index/components/graph.form.js153
-rw-r--r--frontend/app/views/index/containers/graph.edit.js53
-rw-r--r--frontend/app/views/index/containers/graph.index.js53
-rw-r--r--frontend/app/views/index/containers/graph.new.js44
-rw-r--r--frontend/app/views/index/index.container.js36
-rw-r--r--frontend/app/views/index/index.css38
-rw-r--r--frontend/app/views/page/components/page.editor.js215
-rw-r--r--frontend/app/views/page/components/page.header.js36
-rw-r--r--frontend/app/views/page/components/tile.edit.js84
-rw-r--r--frontend/app/views/page/components/tile.form.js645
-rw-r--r--frontend/app/views/page/components/tile.handle.js139
-rw-r--r--frontend/app/views/page/components/tile.list.js142
-rw-r--r--frontend/app/views/page/components/tile.new.js55
-rw-r--r--frontend/app/views/page/cursors.css21
-rw-r--r--frontend/app/views/page/page.actions.js110
-rw-r--r--frontend/app/views/page/page.container.js95
-rw-r--r--frontend/app/views/page/page.css167
-rw-r--r--frontend/app/views/page/page.reducer.js202
-rw-r--r--frontend/app/views/site/site.actions.js6
-rw-r--r--frontend/app/views/site/site.reducer.js19
-rw-r--r--frontend/app/views/tile/tile.actions.js9
-rw-r--r--frontend/app/views/tile/tile.reducer.js31
-rw-r--r--frontend/app/views/upload/components/upload.form.js16
-rw-r--r--frontend/app/views/upload/components/upload.index.js104
-rw-r--r--frontend/app/views/upload/components/upload.indexOptions.js62
-rw-r--r--frontend/app/views/upload/components/upload.menu.js18
-rw-r--r--frontend/app/views/upload/components/upload.show.js70
-rw-r--r--frontend/app/views/upload/upload.actions.js14
-rw-r--r--frontend/app/views/upload/upload.container.js35
-rw-r--r--frontend/app/views/upload/upload.css10
-rw-r--r--frontend/app/views/upload/upload.reducer.js21
43 files changed, 3835 insertions, 0 deletions
diff --git a/frontend/app/views/graph/components/graph.canvas.js b/frontend/app/views/graph/components/graph.canvas.js
new file mode 100644
index 0000000..2896c6b
--- /dev/null
+++ b/frontend/app/views/graph/components/graph.canvas.js
@@ -0,0 +1,138 @@
+import React, { Component } from 'react'
+
+import { mod, angle } from 'app/utils'
+
+const DEFAULT_MEASUREMENT = { width: 16, height: 16 }
+const BACKLINK_SPACING = 10
+const ARROWHEAD_LENGTH = 10
+const GRAPH_LINK_COLOR = "#ff88ff"
+const GRAPH_BACKLINK_COLOR = "#88ffff"
+const GRAPH_UNHOVER_LINK_COLOR = "#884488"
+const GRAPH_UNHOVER_BACKLINK_COLOR = "#448888"
+
+export default class GraphCanvas extends Component {
+ constructor(props) {
+ super(props)
+ this.canvasRef = React.createRef()
+ }
+
+ componentDidMount() {
+ if (this.props.bounds) {
+ this.draw({})
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ this.draw(prevProps)
+ }
+
+ draw(prevProps) {
+ const { current: canvas } = this.canvasRef
+ const { bounds, pages, currentPage, box, measurements, highlightedPageId } = this.props
+ const { width, height } = bounds
+ if (prevProps.bounds !== bounds) {
+ canvas.width = width
+ canvas.height = height
+ }
+ const ctx = canvas.getContext('2d')
+ ctx.clearRect(0, 0, width, height)
+ ctx.lineWidth = 2
+ const coordsLookup = pages.reduce((a,b) => {
+ if (currentPage && box && b.id === currentPage.id) {
+ a[b.id] = {
+ x: box.x,
+ y: box.y,
+ backlinks: new Set([]),
+ }
+ } else {
+ a[b.id] = {
+ x: b.settings.x,
+ y: b.settings.y,
+ backlinks: new Set([]),
+ }
+ }
+ return a
+ }, {})
+ pages.map(page => {
+ const sourceCoord = coordsLookup[page.id]
+ page.backlinks.map(tile => {
+ if (tile.target_page_id <= 0) return
+ const targetCoord = coordsLookup[tile.page_id]
+ const isHighlightedPage = !highlightedPageId || highlightedPageId === page.id || highlightedPageId === tile.page_id
+ let tile_measurement = measurements[tile.page_id] || DEFAULT_MEASUREMENT
+ let target_measurement = measurements[tile.target_page_id] || DEFAULT_MEASUREMENT
+ let theta = angle(targetCoord.x, targetCoord.y, sourceCoord.x, sourceCoord.y)
+ let x1_offset = tile_measurement.width / 2 // * (0.5 - Math.cos(theta))
+ let y1_offset = tile_measurement.height / 2
+ let x2_offset = target_measurement.width / 2 // (0.5 - Math.cos(theta))
+ let y2_offset = target_measurement.height / 2
+ // skip duplicate links
+ if (sourceCoord.backlinks.has(tile.page_id)) {
+ return
+ }
+ /*
+ if it's pointing right, cos(t) is 1
+ if it's pointing left, cos(t) is -1
+ */
+ // if (Math.abs(Math.cos(theta)) > 0.5) {
+ // x1_offset += target_measurement.width / 3 * (- Math.cos(theta))
+ // x1_offset += target_measurement.height / 4 * (- Math.sin(theta))
+ x2_offset += target_measurement.width / 3 * (- Math.cos(theta))
+ y2_offset += target_measurement.height / 6 * (- Math.sin(theta))
+ // }
+ // if this is the first time encountering this link...
+ if (!targetCoord.backlinks.has(tile.target_page_id)) {
+ sourceCoord.backlinks.add(tile.page_id)
+ ctx.strokeStyle = isHighlightedPage
+ ? GRAPH_LINK_COLOR
+ : GRAPH_UNHOVER_LINK_COLOR
+ } else { // otherwise this is a two-way link
+ x1_offset += BACKLINK_SPACING * Math.sin(theta)
+ y1_offset += BACKLINK_SPACING * Math.cos(theta)
+ x2_offset += BACKLINK_SPACING * Math.sin(theta)
+ y2_offset += BACKLINK_SPACING * Math.cos(theta)
+ // x1_offset += BACKLINK_SPACING * Math.cos(theta + Math.PI /2)
+ // y1_offset += BACKLINK_SPACING * Math.sin(theta + Math.PI /2)
+ // x2_offset += BACKLINK_SPACING * Math.cos(theta + Math.PI /2)
+ // y2_offset += BACKLINK_SPACING * Math.sin(theta + Math.PI /2)
+ ctx.strokeStyle = isHighlightedPage
+ ? GRAPH_BACKLINK_COLOR
+ : GRAPH_UNHOVER_BACKLINK_COLOR
+ }
+ ctx.beginPath()
+ const x1 = targetCoord.x * width + x1_offset
+ const y1 = targetCoord.y * height + y1_offset
+ const x2 = sourceCoord.x * width + x2_offset
+ const y2 = sourceCoord.y * height + y2_offset
+ this.arrow(ctx, x1, y1, x2, y2)
+ ctx.stroke()
+ })
+ })
+ }
+
+ arrow(ctx, x1, y1, x2, y2) {
+ const farOffset = 20
+ const endOffset = 1
+ const theta = angle(x1, y1, x2, y2)
+ x1 += Math.cos(theta) * 0
+ x2 -= Math.cos(theta) * farOffset
+ y1 += Math.sin(theta) * 0
+ y2 -= Math.sin(theta) * farOffset
+ const xEnd = x2 - Math.cos(theta) * endOffset
+ const yEnd = y2 - Math.sin(theta) * endOffset
+ const leftAngle = mod(theta - Math.PI / 6, Math.PI * 2)
+ const rightAngle = mod(theta + Math.PI / 6, Math.PI * 2)
+ ctx.moveTo(x1, y1)
+ ctx.lineTo(xEnd, yEnd)
+ ctx.moveTo(x2, y2)
+ ctx.lineTo(x2 - ARROWHEAD_LENGTH * Math.cos(leftAngle), y2 - ARROWHEAD_LENGTH * Math.sin(leftAngle))
+ ctx.moveTo(x2, y2)
+ ctx.lineTo(x2 - ARROWHEAD_LENGTH * Math.cos(rightAngle), y2 - ARROWHEAD_LENGTH * Math.sin(rightAngle))
+ }
+
+ render() {
+ return (
+ <canvas ref={this.canvasRef} />
+ )
+ }
+}
diff --git a/frontend/app/views/graph/components/graph.editor.js b/frontend/app/views/graph/components/graph.editor.js
new file mode 100644
index 0000000..61f26b7
--- /dev/null
+++ b/frontend/app/views/graph/components/graph.editor.js
@@ -0,0 +1,212 @@
+import React, { Component } from 'react'
+import { Route, Link } from 'react-router-dom'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+
+import { session } from 'app/session'
+import actions from 'app/actions'
+import * as graphActions from '../graph.actions'
+
+import { Loader } from 'app/common'
+import { clamp, dist, mod, angle } from 'app/utils'
+
+import GraphCanvas from './graph.canvas'
+import PageHandle from './page.handle'
+
+const defaultState = {
+ dragging: false,
+ bounds: null,
+ mouseX: 0,
+ mouseY: 0,
+ box: {
+ x: 0, y: 0,
+ w: 0, h: 0,
+ },
+ page: null,
+ highlightedPageId: null,
+ measurements: {},
+}
+
+class GraphEditor extends Component {
+ state = {
+ ...defaultState,
+ }
+
+ constructor() {
+ super()
+ // bind these events in the constructor, so we can remove event listeners later
+ this.handleMouseDown = this.handleMouseDown.bind(this)
+ this.handleMouseMove = this.handleMouseMove.bind(this)
+ this.handleMouseUp = this.handleMouseUp.bind(this)
+ this.handleWindowResize = this.handleWindowResize.bind(this)
+ this.handleMouseEnter = this.handleMouseEnter.bind(this)
+ this.handleMouseLeave = this.handleMouseLeave.bind(this)
+ this.graphRef = React.createRef()
+ this.measurements = {}
+ }
+
+ getBoundingClientRect() {
+ if (!this.graphRef.current) return null
+ const rect = this.graphRef.current.getBoundingClientRect()
+ const scrollTop = document.body.scrollTop || document.body.parentNode.scrollTop
+ const scrollLeft = document.body.scrollLeft || document.body.parentNode.scrollLeft
+ const bounds = {
+ top: rect.top + scrollTop,
+ left: rect.left + scrollLeft,
+ width: rect.width,
+ height: rect.height,
+ }
+ // console.log(bounds)
+ return bounds
+ }
+
+ componentDidMount() {
+ document.body.addEventListener('mousemove', this.handleMouseMove)
+ document.body.addEventListener('mouseup', this.handleMouseUp)
+ window.addEventListener('resize', this.handleWindowResize)
+ this.setState({ bounds: this.getBoundingClientRect() })
+ }
+
+ componentDidUpdate(prevProps) {
+ if (!this.state.bounds) {
+ this.setState({ bounds: this.getBoundingClientRect() })
+ }
+ }
+
+ addMeasurement({ id, width, height }) {
+ this.measurements[id] = { width, height }
+ this.setState({
+ measurements: { ...this.measurements },
+ })
+ }
+
+ handleWindowResize() {
+ this.setState({ bounds: this.getBoundingClientRect() })
+ }
+
+ handleMouseDown(e, page) {
+ if (e.shiftKey) {
+ e.preventDefault()
+ this.props.graphActions.setHomePageId(this.props.graph.show.res, page)
+ return
+ }
+ const bounds = this.getBoundingClientRect()
+ const mouseX = e.pageX
+ const mouseY = e.pageY
+ let w = 128 / bounds.width
+ let h = 16 / bounds.height
+ let { x, y } = page.settings
+ x = clamp(x, 0, 1)
+ y = clamp(y, 0, 1)
+ this.setState({
+ page,
+ dragging: true,
+ bounds,
+ mouseX,
+ mouseY,
+ box: {
+ x, y,
+ w, h,
+ },
+ initialBox: {
+ x, y,
+ w, h,
+ }
+ })
+ }
+
+ handleMouseMove(e) {
+ const {
+ dragging,
+ bounds, mouseX, mouseY, initialBox, box
+ } = this.state
+ if (dragging) {
+ e.preventDefault()
+ let { x, y, w, h } = initialBox
+ let dx = (e.pageX - mouseX) / bounds.width
+ let dy = (e.pageY - mouseY) / bounds.height
+ this.setState({
+ box: {
+ x: clamp(x + dx, 0, 1.0 - w),
+ y: clamp(y + dy, 0, 1.0 - h),
+ w, h,
+ }
+ })
+ }
+ }
+
+ handleMouseUp(e) {
+ // const { actions } = this.props
+ const { dragging, bounds, initialBox, box, page } = this.state
+ if (!dragging) return
+ e.preventDefault()
+ const { width, height } = bounds
+ const { x, y, w, h } = box
+ let url = window.location.pathname
+ this.setState({
+ page: null,
+ box: null,
+ initialBox: null,
+ dragging: false,
+ })
+ if (dist(width * x, height * y, width * initialBox.x, height * initialBox.y) < 3) return
+ const updatedPage = {
+ ...page,
+ settings: {
+ ...page.settings,
+ x, y,
+ }
+ }
+ this.props.graphActions.updateGraphPage(updatedPage)
+ actions.page.update(updatedPage)
+ }
+
+ handleMouseEnter(e, page) {
+ this.setState({ highlightedPageId: page.id })
+ }
+ handleMouseLeave(e, page) {
+ this.setState({ highlightedPageId: null })
+ }
+
+ render(){
+ // console.log(this.props.graph.show.res)
+ const { page: currentPage, box, measurements, highlightedPageId } = this.state
+ const { res: graph } = this.props.graph.show
+ // console.log(res.pages)
+ return (
+ <div className='graph' ref={this.graphRef}>
+ <GraphCanvas
+ bounds={this.state.bounds}
+ pages={graph.pages}
+ currentPage={currentPage}
+ highlightedPageId={highlightedPageId}
+ measurements={measurements}
+ box={box}
+ />
+ {this.state.bounds && graph.pages.map(page => (
+ <PageHandle
+ key={page.id}
+ graph={graph}
+ page={page}
+ bounds={this.state.bounds}
+ box={currentPage && page.id === currentPage.id && box}
+ onMouseDown={e => this.handleMouseDown(e, page)}
+ onMouseEnter={e => this.handleMouseEnter(e, page)}
+ onMouseLeave={e => this.handleMouseLeave(e, page)}
+ onMeasure={measurement => this.addMeasurement(measurement)}
+ />
+ ))}
+ </div>
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ graph: state.graph,
+})
+
+const mapDispatchToProps = dispatch => ({
+ graphActions: bindActionCreators({ ...graphActions }, dispatch),
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(GraphEditor)
diff --git a/frontend/app/views/graph/components/graph.header.js b/frontend/app/views/graph/components/graph.header.js
new file mode 100644
index 0000000..46ad962
--- /dev/null
+++ b/frontend/app/views/graph/components/graph.header.js
@@ -0,0 +1,31 @@
+import React from 'react'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+import { Link } from 'react-router-dom'
+
+import * as graphActions from '../graph.actions'
+
+function GraphHeader(props) {
+ return (
+ <header>
+ <div>
+ <Link to="/" className="logo"><b>{props.site.siteTitle}</b></Link>
+ </div>
+ <div>
+ <button onClick={() => props.graphActions.toggleAddPageForm()}>+ Add page</button>
+ </div>
+ </header>
+ )
+}
+
+const mapStateToProps = (state) => ({
+ // auth: state.auth,
+ site: state.site,
+ // isAuthenticated: state.auth.isAuthenticated,
+})
+
+const mapDispatchToProps = (dispatch) => ({
+ graphActions: bindActionCreators({ ...graphActions }, dispatch),
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(GraphHeader)
diff --git a/frontend/app/views/graph/components/page.edit.js b/frontend/app/views/graph/components/page.edit.js
new file mode 100644
index 0000000..4025726
--- /dev/null
+++ b/frontend/app/views/graph/components/page.edit.js
@@ -0,0 +1,65 @@
+import React, { Component } from 'react'
+import { Link } from 'react-router-dom'
+import { connect } from 'react-redux'
+import { bindActionCreators } from 'redux'
+
+import { history } from 'app/store'
+import actions from 'app/actions'
+import * as siteActions from 'app/views/site/site.actions'
+import * as graphActions from 'app/views/graph/graph.actions'
+
+import { Loader } from 'app/common'
+
+import PageForm from '../components/page.form'
+
+class PageEdit extends Component {
+ componentDidMount() {
+ // actions.page.show(this.props.match.params.id)
+ }
+
+ handleSubmit(data) {
+ const { path: graphPath } = this.props.graph.show.res
+ const { path: oldPagePath } = this.props.page.show.res
+ const { path: newPagePath } = data
+ actions.page.update(data)
+ .then(response => {
+ // console.log(response)
+ actions.site.setSiteTitle(response.res.title)
+ this.props.graphActions.hideEditPageForm()
+ if (oldPagePath !== newPagePath) {
+ const newPath = '/' + graphPath + '/' + newPagePath
+ history.push(newPath)
+ }
+ })
+ }
+
+ render() {
+ const { show } = this.props.page
+ if (show.loading || !show.res) {
+ return (
+ <div className='form'>
+ <Loader />
+ </div>
+ )
+ }
+ return (
+ <PageForm
+ data={show.res}
+ graph={this.props.graph.show.res}
+ onSubmit={this.handleSubmit.bind(this)}
+ />
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ graph: state.graph,
+ page: state.page,
+})
+
+const mapDispatchToProps = dispatch => ({
+ siteActions: bindActionCreators({ ...siteActions }, dispatch),
+ graphActions: bindActionCreators({ ...graphActions }, dispatch),
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(PageEdit)
diff --git a/frontend/app/views/graph/components/page.form.js b/frontend/app/views/graph/components/page.form.js
new file mode 100644
index 0000000..8fc00b0
--- /dev/null
+++ b/frontend/app/views/graph/components/page.form.js
@@ -0,0 +1,185 @@
+import React, { Component } from 'react'
+import { Link } from 'react-router-dom'
+
+import { session } from 'app/session'
+
+import { TextInput, ColorInput, LabelDescription, TextArea, Checkbox, SubmitButton, Loader } from 'app/common'
+
+const newPage = (data) => ({
+ path: '',
+ title: '',
+ username: session('username'),
+ description: '',
+ settings: {
+ x: 0.05,
+ y: 0.05,
+ background_color: '#000000',
+ },
+ ...data,
+})
+
+export default class PageForm extends Component {
+ state = {
+ title: "",
+ submitTitle: "",
+ data: { ...newPage() },
+ errorFields: new Set([]),
+ }
+
+ componentDidMount() {
+ const { graph, data, isNew } = this.props
+ const title = isNew ? 'new page' : 'editing ' + data.title
+ const submitTitle = isNew ? "Create Page" : "Save Changes"
+ this.setState({
+ title,
+ submitTitle,
+ errorFields: new Set([]),
+ data: {
+ ...newPage({ graph_id: graph.id }),
+ ...data,
+ },
+ })
+ }
+
+ handleChange(e) {
+ const { errorFields } = this.state
+ const { name, value } = e.target
+ if (errorFields.has(name)) {
+ errorFields.delete(name)
+ }
+ let sanitizedValue = value
+ if (name === 'path') {
+ sanitizedValue = sanitizedValue.toLowerCase().replace(/ /, '-').replace(/[!@#$%^&*()[\]{}]/, '-').replace(/-+/, '-')
+ }
+ this.setState({
+ errorFields,
+ data: {
+ ...this.state.data,
+ [name]: sanitizedValue,
+ }
+ })
+ }
+
+ handleSelect(name, value) {
+ const { errorFields } = this.state
+ if (errorFields.has(name)) {
+ errorFields.delete(name)
+ }
+ this.setState({
+ errorFields,
+ data: {
+ ...this.state.data,
+ [name]: value,
+ }
+ })
+ }
+
+ handleSettingsChange(e) {
+ const { name, value } = e.target
+ this.setState({
+ data: {
+ ...this.state.data,
+ settings: {
+ ...this.state.data.settings,
+ [name]: value,
+ }
+ }
+ })
+ }
+
+ handleSubmit(e) {
+ e.preventDefault()
+ const { isNew, onSubmit } = this.props
+ const { data } = this.state
+ const requiredKeys = "path title".split(" ")
+ const validKeys = "graph_id path title username description settings".split(" ")
+ const validData = validKeys.reduce((a,b) => { a[b] = data[b]; return a }, {})
+ const errorFields = requiredKeys.filter(key => !validData[key])
+ if (errorFields.length) {
+ console.log('error', errorFields, validData)
+ this.setState({ errorFields: new Set(errorFields) })
+ } else {
+ if (isNew) {
+ // side effect: set username if we're creating a new page
+ // session.set('username', data.username)
+ } else {
+ validData.id = data.id
+ }
+ console.log('submit', validData)
+ onSubmit(validData)
+ }
+ }
+
+ handleDelete() {
+ const { data } = this.state
+ console.log(data)
+ if (confirm('Really delete this page?')) {
+ actions.page.delete(page_id)
+ }
+ }
+
+ render() {
+ const { graph, isNew } = this.props
+ const { title, submitTitle, errorFields, data } = this.state
+ return (
+ <div className='box'>
+ <h1>{title}</h1>
+ <form onSubmit={this.handleSubmit.bind(this)}>
+ <TextInput
+ title="Path"
+ name="path"
+ required
+ data={data}
+ error={errorFields.has('path')}
+ onChange={this.handleChange.bind(this)}
+ autoComplete="off"
+ />
+ <LabelDescription>
+ {'Page URL: /' + graph.path + '/'}<b>{data.path}</b>
+ </LabelDescription>
+ <TextInput
+ title="Title"
+ name="title"
+ required
+ data={data}
+ error={errorFields.has('title')}
+ onChange={this.handleChange.bind(this)}
+ autoComplete="off"
+ />
+ <ColorInput
+ title='BG'
+ name='background_color'
+ data={data.settings}
+ onChange={this.handleSettingsChange.bind(this)}
+ autoComplete="off"
+ />
+ <TextArea
+ title="Description"
+ name="description"
+ data={data}
+ onChange={this.handleChange.bind(this)}
+ />
+ <div className='row buttons'>
+ <SubmitButton
+ title={submitTitle}
+ onClick={this.handleSubmit.bind(this)}
+ />
+ {!isNew &&
+ <SubmitButton
+ title={'Delete'}
+ className='destroy'
+ onClick={this.handleDelete.bind(this)}
+ />
+ }
+ </div>
+ {!!errorFields.size &&
+ <label>
+ <span></span>
+ <span>Please complete the required fields =)</span>
+ </label>
+ }
+ </form>
+ </div>
+ )
+ }
+}
diff --git a/frontend/app/views/graph/components/page.handle.js b/frontend/app/views/graph/components/page.handle.js
new file mode 100644
index 0000000..c024f1e
--- /dev/null
+++ b/frontend/app/views/graph/components/page.handle.js
@@ -0,0 +1,59 @@
+import React, { Component } from 'react'
+import { Link } from 'react-router-dom'
+
+import { history } from 'app/store'
+
+export default class PageHandle extends Component {
+ constructor(props){
+ super(props)
+ this.ref = React.createRef()
+ }
+ componentDidMount(){
+ this.measure()
+ }
+ componentDidUpdate(prevProps){
+ if (this.props.page.title !== prevProps.page.title) {
+ this.measure()
+ }
+ }
+ measure() {
+ const { offsetWidth: width, offsetHeight: height } = this.ref.current
+ const { id } = this.props.page
+ // console.log(id, width, height)
+ this.props.onMeasure({ id, width, height })
+ }
+ render() {
+ const { graph, page, bounds, box, onMouseDown, onMouseEnter, onMouseLeave } = this.props
+ let style;
+ if (box) {
+ style = {
+ top: (bounds.height) * box.y,
+ left: (bounds.width) * box.x,
+ }
+ } else {
+ style = {
+ top: (bounds.height) * Math.min(page.settings.y, 0.95),
+ left: (bounds.width) * Math.min(page.settings.x, 0.95),
+ }
+ }
+ const className = (graph.home_page_id === page.id)
+ ? 'handle homepage'
+ : 'handle'
+ const url = '/' + graph.path + '/' + page.path
+ // console.log(style)
+ return (
+ <div
+ className={className}
+ ref={this.ref}
+ onMouseDown={onMouseDown}
+ onMouseEnter={onMouseEnter}
+ onMouseLeave={onMouseLeave}
+ onDoubleClick={() => history.push(url)}
+ style={style}
+ >
+ {page.path}
+ <Link to={url}>{'>'}</Link>
+ </div>
+ )
+ }
+}
diff --git a/frontend/app/views/graph/components/page.new.js b/frontend/app/views/graph/components/page.new.js
new file mode 100644
index 0000000..e7c3609
--- /dev/null
+++ b/frontend/app/views/graph/components/page.new.js
@@ -0,0 +1,47 @@
+import React, { Component } from 'react'
+import { Link } from 'react-router-dom'
+import { connect } from 'react-redux'
+
+import { history } from 'app/store'
+import actions from 'app/actions'
+
+import PageForm from '../components/page.form'
+
+class PageNew extends Component {
+ handleSubmit(data) {
+ console.log(data)
+ actions.page.create(data)
+ .then(res => {
+ console.log(res)
+ const graph = this.props.graph.show.res
+ if (res.res && res.res.id) {
+ history.push('/' + graph.path + '/' + res.res.path)
+ }
+ })
+ .catch(err => {
+ console.error('error', err)
+ })
+ }
+
+ render() {
+ return (
+ <PageForm
+ isNew
+ graph={this.props.graph.show.res}
+ data={{}}
+ onSubmit={this.handleSubmit.bind(this)}
+ />
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ graph: state.graph,
+ page: state.page,
+})
+
+const mapDispatchToProps = dispatch => ({
+ // searchActions: bindActionCreators({ ...searchActions }, dispatch),
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(PageNew)
diff --git a/frontend/app/views/graph/graph.actions.js b/frontend/app/views/graph/graph.actions.js
new file mode 100644
index 0000000..a24ccc2
--- /dev/null
+++ b/frontend/app/views/graph/graph.actions.js
@@ -0,0 +1,37 @@
+import * as types from 'app/types'
+import actions from 'app/actions'
+
+export const showAddPageForm = () => dispatch => {
+ dispatch({ type: types.graph.show_add_page_form })
+}
+
+export const hideAddPageForm = () => dispatch => {
+ dispatch({ type: types.graph.hide_add_page_form })
+}
+
+export const toggleAddPageForm = () => dispatch => {
+ dispatch({ type: types.graph.toggle_add_page_form })
+}
+
+export const showEditPageForm = () => dispatch => {
+ dispatch({ type: types.graph.show_edit_page_form })
+}
+
+export const hideEditPageForm = () => dispatch => {
+ dispatch({ type: types.graph.hide_edit_page_form })
+}
+
+export const toggleEditPageForm = () => dispatch => {
+ dispatch({ type: types.graph.toggle_edit_page_form })
+}
+
+export const updateGraphPage = page => dispatch => {
+ dispatch({ type: types.graph.update_graph_page, page })
+}
+
+export const setHomePageId = (graph, page) => dispatch => {
+ let updated_graph = { ...graph }
+ delete updated_graph.pages
+ updated_graph.home_page_id = page.id
+ actions.graph.update(updated_graph)
+} \ No newline at end of file
diff --git a/frontend/app/views/graph/graph.container.js b/frontend/app/views/graph/graph.container.js
new file mode 100644
index 0000000..9e354fc
--- /dev/null
+++ b/frontend/app/views/graph/graph.container.js
@@ -0,0 +1,82 @@
+import React, { Component } from 'react'
+import { Route } from 'react-router-dom'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+
+import './graph.css'
+
+import actions from 'app/actions'
+import { Loader } from 'app/common'
+
+// import * as uploadActions from './upload.actions'
+
+import PageNew from './components/page.new'
+import PageEdit from './components/page.edit'
+
+import GraphHeader from './components/graph.header'
+import GraphEditor from './components/graph.editor'
+
+class GraphContainer extends Component {
+ componentDidMount() {
+ if (this.shouldShowGraph()) this.load()
+ }
+ componentDidUpdate(prevProps) {
+ if (this.shouldLoadGraph(prevProps)) this.load()
+ }
+ shouldShowGraph() {
+ const { graph_name, page_name } = this.props.match.params
+ return (graph_name && !page_name && graph_name !== 'index')
+ }
+ shouldLoadGraph(prevProps) {
+ const { graph, location } = this.props
+ const { key } = location
+ if (key === prevProps.location.key) return false
+ if (!this.shouldShowGraph()) return false
+ return (graph.show.name === prevProps.graph.show.name)
+ }
+ load() {
+ actions.site.setSiteTitle("loading " + this.props.match.params.graph_name + "...")
+ actions.graph.show('name/' + this.props.match.params.graph_name)
+ .then(data => {
+ actions.site.setSiteTitle(data.res.title)
+ })
+ }
+ render() {
+ if (!this.shouldShowGraph()) return <div />
+ if (!this.props.graph.show.res || this.props.graph.show.loading) {
+ return (
+ <div>
+ <GraphHeader />
+ <div className='body'>
+ <div className='graph loading'>
+ <Loader />
+ </div>
+ </div>
+ </div>
+ )
+ }
+ return (
+ <div>
+ <GraphHeader />
+ <div className='body'>
+ <GraphEditor />
+ <div className='sidebar'>
+ {this.props.graph.editor.addingPage && <PageNew />}
+ {this.props.graph.editor.editingPage && <PageEdit />}
+ </div>
+ </div>
+ </div>
+ )
+ }
+}
+
+// <Route exact path='/:graph_name' component={GraphView} />
+const mapStateToProps = state => ({
+ graph: state.graph,
+})
+
+const mapDispatchToProps = dispatch => ({
+ // uploadActions: bindActionCreators({ ...uploadActions }, dispatch),
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(GraphContainer)
diff --git a/frontend/app/views/graph/graph.css b/frontend/app/views/graph/graph.css
new file mode 100644
index 0000000..389a55d
--- /dev/null
+++ b/frontend/app/views/graph/graph.css
@@ -0,0 +1,172 @@
+.graph.loading {
+ padding: 1rem;
+}
+.graph {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ background: linear-gradient(
+ 0deg,
+ rgba(128, 0, 64, 0.5),
+ rgba(0, 0, 64, 0.5)
+ );
+}
+.graph canvas {
+ position: absolute;
+ top: 0;
+ left: 0;
+}
+
+/* Sidebar boxes */
+
+.sidebar {
+ position: absolute;
+ top: 0;
+ right: 0;
+ padding: 1rem;
+ overflow: auto;
+ max-height: 100%;
+ z-index: 20;
+}
+.box {
+ width: 15rem;
+ padding: 0.5rem;
+ background: rgba(64,12,64,0.9);
+ border: 2px solid #000;
+ box-shadow: 2px 2px 4px rgba(0,0,0,0.5);
+}
+.box h1,
+.box h2 {
+ font-size: 1rem;
+ margin: 0 0 0.5rem 0;
+}
+.box form label {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ margin-bottom: 0.25rem;
+}
+.box form .buttons {
+ justify-content: space-between;
+}
+.box form .buttons label {
+ width: auto;
+ min-width: auto;
+}
+.box form .buttons label span {
+ display: none;
+}
+.box form .buttons button:last-child {
+ margin-right: 0;
+}
+.box form label.checkbox {
+ flex-direction: row;
+ justify-content: flex-start;
+ align-items: center;
+}
+.box form input[type="checkbox"] {
+ margin-left: 0rem;
+}
+.box form input[type=checkbox] + span {
+ color: #ddd;
+}
+.box form input[type=checkbox]:hover + span {
+ color: #fff;
+}
+.box form input[type="checkbox"]:after {
+ border-color: #84f;
+}
+.box form input[type="checkbox"]:checked:after {
+ border-color: #84f;
+ background-color: #84f;
+}
+.box form input[type=checkbox]:hover + span {
+ color: #84f;
+}
+.box input[type=text],
+.box input[type=number],
+.box input[type=password] {
+ padding: 0.25rem;
+ max-width: 100%;
+ border-color: #888;
+}
+.box textarea {
+ max-width: 100%;
+ height: 5rem;
+ border-color: #888;
+}
+.box .select {
+ padding: 0.25rem;
+ margin-right: 0;
+}
+.box .selects label {
+ flex-direction: row;
+ width: 6.5rem;
+ margin-right: 0.5rem;
+ min-width: auto;
+}
+.box form textarea {
+ padding: 0.25rem;
+}
+
+.box form .pair label span {
+ min-width: 3rem;
+ padding: 0.25rem 0;
+}
+.box .pair label {
+ flex-direction: row;
+ width: 6.5rem;
+ margin-right: 0.5px;
+ min-width: auto;
+}
+.box .pair input[type=text] {
+ width: 3rem;
+}
+.box .position {
+ font-size: smaller;
+ margin-bottom: 0.25rem;
+}
+
+.box .slider {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+}
+.box .slider span {
+ min-width: 4rem;
+ width: 4rem;
+ padding: 0.125rem 0;
+}
+.box .slider input[type='number'] {
+ width: 3.5rem;
+}
+.box .slider input[type='range'] {
+ width: 5.5rem;
+}
+
+/* Graph handles */
+
+.handle {
+ position: absolute;
+ border: 2px solid #888;
+ background: #8833dd;
+ padding: 0.25rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 8rem;
+ user-select: none;
+ cursor: arrow;
+}
+.handle.homepage {
+ background: #533854;
+ border-color: #edc40e;
+}
+.handle a {
+ margin-left: 0.25rem;
+ color: #fff;
+ font-weight: bold;
+ text-decoration: none;
+}
diff --git a/frontend/app/views/graph/graph.reducer.js b/frontend/app/views/graph/graph.reducer.js
new file mode 100644
index 0000000..6be5089
--- /dev/null
+++ b/frontend/app/views/graph/graph.reducer.js
@@ -0,0 +1,100 @@
+import * as types from 'app/types'
+// import { session, getDefault, getDefaultInt } from 'app/session'
+
+import { crudState, crudReducer } from 'app/api/crud.reducer'
+
+const initialState = crudState('graph', {
+ editor: {
+ addingPage: false,
+ editingPage: false,
+ },
+ options: {
+ }
+})
+
+const reducer = crudReducer('graph')
+
+export default function graphReducer(state = initialState, action) {
+ // console.log(action.type, action)
+ state = reducer(state, action)
+ switch (action.type) {
+ case types.graph.update_graph_page:
+ return {
+ ...state,
+ show: {
+ ...state.show,
+ res: {
+ ...state.show.res,
+ pages: state.show.res.pages.map(page => {
+ if (page.id === action.page.id) {
+ return { ...action.page }
+ } else {
+ return page
+ }
+ }),
+ }
+ }
+ }
+
+ case types.graph.show_add_page_form:
+ return {
+ ...state,
+ editor: {
+ ...state.editor,
+ addingPage: true,
+ editingPage: false,
+ }
+ }
+
+ case types.graph.hide_add_page_form:
+ return {
+ ...state,
+ editor: {
+ ...state.editor,
+ addingPage: false,
+ }
+ }
+
+ case types.graph.toggle_add_page_form:
+ return {
+ ...state,
+ editor: {
+ ...state.editor,
+ addingPage: !state.editor.addingPage,
+ editingPage: false,
+ }
+ }
+
+ case types.graph.show_edit_page_form:
+ return {
+ ...state,
+ editor: {
+ ...state.editor,
+ addingPage: false,
+ editingPage: true,
+ }
+ }
+
+ case types.graph.hide_edit_page_form:
+ return {
+ ...state,
+ editor: {
+ ...state.editor,
+ editingPage: false,
+ }
+ }
+
+ case types.graph.toggle_edit_page_form:
+ return {
+ ...state,
+ editor: {
+ ...state.editor,
+ addingPage: false,
+ editingPage: !state.editor.editingPage,
+ }
+ }
+
+ default:
+ return state
+ }
+}
diff --git a/frontend/app/views/index.js b/frontend/app/views/index.js
new file mode 100644
index 0000000..c50ab80
--- /dev/null
+++ b/frontend/app/views/index.js
@@ -0,0 +1,4 @@
+export { default as index } from './index/index.container'
+export { default as graph } from './graph/graph.container'
+export { default as page } from './page/page.container'
+export { default as upload } from './upload/upload.container'
diff --git a/frontend/app/views/index/components/graph.form.js b/frontend/app/views/index/components/graph.form.js
new file mode 100644
index 0000000..4b3a7af
--- /dev/null
+++ b/frontend/app/views/index/components/graph.form.js
@@ -0,0 +1,153 @@
+import React, { Component } from 'react'
+import { Link } from 'react-router-dom'
+
+import { session } from 'app/session'
+
+import { TextInput, LabelDescription, TextArea, Checkbox, SubmitButton, Loader } from 'app/common'
+
+const newGraph = () => ({
+ path: '',
+ title: '',
+ username: session('username'),
+ description: '',
+})
+
+export default class GraphForm extends Component {
+ state = {
+ title: "",
+ submitTitle: "",
+ data: { ...newGraph() },
+ errorFields: new Set([]),
+ }
+
+ componentDidMount() {
+ const { data, isNew } = this.props
+ const title = isNew ? 'new project' : 'editing ' + data.title
+ const submitTitle = isNew ? "Create Graph" : "Save Changes"
+ this.setState({
+ title,
+ submitTitle,
+ errorFields: new Set([]),
+ data: {
+ ...newGraph(),
+ ...data
+ },
+ })
+ }
+
+ handleChange(e) {
+ const { errorFields } = this.state
+ const { name, value } = e.target
+ if (errorFields.has(name)) {
+ errorFields.delete(name)
+ }
+ let sanitizedValue = value
+ if (name === 'path') {
+ sanitizedValue = sanitizedValue.toLowerCase().replace(/ /, '-').replace(/[!@#$%^&*()[\]{}]/, '-').replace(/-+/, '-')
+ }
+ this.setState({
+ errorFields,
+ data: {
+ ...this.state.data,
+ [name]: sanitizedValue,
+ }
+ })
+ }
+
+ handleSelect(name, value) {
+ const { errorFields } = this.state
+ if (errorFields.has(name)) {
+ errorFields.delete(name)
+ }
+ this.setState({
+ errorFields,
+ data: {
+ ...this.state.data,
+ [name]: value,
+ }
+ })
+ }
+
+ handleSubmit(e) {
+ e.preventDefault()
+ const { isNew, onSubmit } = this.props
+ const { data } = this.state
+ const requiredKeys = "title username path description".split(" ")
+ const validKeys = "title username path description".split(" ")
+ const validData = validKeys.reduce((a,b) => { a[b] = data[b]; return a }, {})
+ const errorFields = requiredKeys.filter(key => !validData[key])
+ if (errorFields.length) {
+ console.log('error', errorFields, validData)
+ this.setState({ errorFields: new Set(errorFields) })
+ } else {
+ if (isNew) {
+ // side effect: set username if we're creating a new graph
+ session.set('username', data.username)
+ } else {
+ validData.id = data.id
+ }
+ console.log('submit', validData)
+ onSubmit(validData)
+ }
+ }
+
+ render() {
+ const { isNew } = this.props
+ const { title, submitTitle, errorFields, data } = this.state
+ return (
+ <div className='form'>
+ <h1>{title}</h1>
+ <form onSubmit={this.handleSubmit.bind(this)}>
+ <TextInput
+ title="Path"
+ name="path"
+ required
+ data={data}
+ error={errorFields.has('path')}
+ onChange={this.handleChange.bind(this)}
+ autoComplete="off"
+ />
+ <LabelDescription>
+ {data.path
+ ? 'Project URLs will be: /' + data.path + '/example'
+ : 'Enter the base path for this project.'}
+ </LabelDescription>
+ <TextInput
+ title="Title"
+ name="title"
+ required
+ data={data}
+ error={errorFields.has('title')}
+ onChange={this.handleChange.bind(this)}
+ autoComplete="off"
+ />
+ <TextInput
+ title="Author"
+ name="username"
+ required
+ data={data}
+ error={errorFields.has('username')}
+ onChange={this.handleChange.bind(this)}
+ autoComplete="off"
+ />
+ <TextArea
+ title="Description"
+ name="description"
+ data={data}
+ onChange={this.handleChange.bind(this)}
+ />
+ <SubmitButton
+ title={submitTitle}
+ onClick={this.handleSubmit.bind(this)}
+ />
+ {!!errorFields.size &&
+ <label>
+ <span></span>
+ <span>Please complete the required fields =)</span>
+ </label>
+ }
+ </form>
+ </div>
+ )
+ }
+}
diff --git a/frontend/app/views/index/containers/graph.edit.js b/frontend/app/views/index/containers/graph.edit.js
new file mode 100644
index 0000000..b459cd8
--- /dev/null
+++ b/frontend/app/views/index/containers/graph.edit.js
@@ -0,0 +1,53 @@
+import React, { Component } from 'react'
+import { Link } from 'react-router-dom'
+import { connect } from 'react-redux'
+
+import { history } from 'app/store'
+import actions from 'app/actions'
+
+import { Loader } from 'app/common'
+
+import GraphForm from '../components/graph.form'
+
+class GraphEdit extends Component {
+ componentDidMount() {
+ console.log(this.props.match.params.id)
+ actions.graph.show(this.props.match.params.id)
+ }
+
+ handleSubmit(data) {
+ actions.graph.update(data)
+ .then(response => {
+ // response
+ console.log(response)
+ history.push('/' + data.path)
+ })
+ }
+
+ render() {
+ const { show } = this.props.graph
+ if (show.loading || !show.res) {
+ return (
+ <div className='form'>
+ <Loader />
+ </div>
+ )
+ }
+ return (
+ <GraphForm
+ data={show.res}
+ onSubmit={this.handleSubmit.bind(this)}
+ />
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ graph: state.graph,
+})
+
+const mapDispatchToProps = dispatch => ({
+ // searchActions: bindActionCreators({ ...searchActions }, dispatch),
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(GraphEdit)
diff --git a/frontend/app/views/index/containers/graph.index.js b/frontend/app/views/index/containers/graph.index.js
new file mode 100644
index 0000000..91098a7
--- /dev/null
+++ b/frontend/app/views/index/containers/graph.index.js
@@ -0,0 +1,53 @@
+import React, { Component } from 'react'
+import { Link } from 'react-router-dom'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+
+import { Loader } from 'app/common'
+import actions from 'app/actions'
+// import * as uploadActions from './upload.actions'
+
+class GraphIndex extends Component {
+ componentDidMount() {
+ actions.graph.index()
+ }
+ render() {
+ const { index } = this.props
+ // console.log(this.props)
+ if (!index.order) {
+ return (
+ <div className='graphIndex'>
+ <Loader />
+ </div>
+ )
+ }
+ // console.log(state)
+ return (
+ <div className='graphIndex'>
+ <div>
+ <b>welcome, swimmer</b>
+ <Link to='/index/new'>+ new project</Link>
+ </div>
+ {index.order.map(id => {
+ const graph = index.lookup[id]
+ return (
+ <div key={id}>
+ <Link to={'/' + graph.path}>{graph.title}</Link>
+ <Link to={'/index/' + id + '/edit'}>{'edit project'}</Link>
+ </div>
+ )
+ })}
+ </div>
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ index: state.graph.index,
+})
+
+const mapDispatchToProps = dispatch => ({
+ // uploadActions: bindActionCreators({ ...uploadActions }, dispatch),
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(GraphIndex)
diff --git a/frontend/app/views/index/containers/graph.new.js b/frontend/app/views/index/containers/graph.new.js
new file mode 100644
index 0000000..28d2f73
--- /dev/null
+++ b/frontend/app/views/index/containers/graph.new.js
@@ -0,0 +1,44 @@
+import React, { Component } from 'react'
+import { Link } from 'react-router-dom'
+import { connect } from 'react-redux'
+
+import { history } from 'app/store'
+import actions from 'app/actions'
+
+import GraphForm from '../components/graph.form'
+
+class GraphNew extends Component {
+ handleSubmit(data) {
+ console.log(data)
+ actions.graph.create(data)
+ .then(res => {
+ console.log(res)
+ if (res.res && res.res.id) {
+ history.push('/' + res.res.path)
+ }
+ })
+ .catch(err => {
+ console.error('error')
+ })
+ }
+
+ render() {
+ return (
+ <GraphForm
+ isNew
+ data={{}}
+ onSubmit={this.handleSubmit.bind(this)}
+ />
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ graph: state.graph,
+})
+
+const mapDispatchToProps = dispatch => ({
+ // searchActions: bindActionCreators({ ...searchActions }, dispatch),
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(GraphNew)
diff --git a/frontend/app/views/index/index.container.js b/frontend/app/views/index/index.container.js
new file mode 100644
index 0000000..b1fa59f
--- /dev/null
+++ b/frontend/app/views/index/index.container.js
@@ -0,0 +1,36 @@
+import React, { Component } from 'react'
+import { Route } from 'react-router-dom'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+
+import './index.css'
+
+import actions from 'app/actions'
+import { Header } from 'app/common'
+// import * as uploadActions from './upload.actions'
+
+import GraphIndex from './containers/graph.index'
+import GraphNew from './containers/graph.new'
+import GraphEdit from './containers/graph.edit'
+
+class Container extends Component {
+ componentDidMount() {
+ actions.site.setSiteTitle("swimmer")
+ }
+ render() {
+ return (
+ <div>
+ <Header />
+ <div className='body'>
+ <div className='index'>
+ <Route exact path='/index/new' component={GraphNew} />
+ <Route exact path='/index/:id/edit' component={GraphEdit} />
+ <Route exact path='/index' component={GraphIndex} />
+ </div>
+ </div>
+ </div>
+ )
+ }
+}
+
+export default Container
diff --git a/frontend/app/views/index/index.css b/frontend/app/views/index/index.css
new file mode 100644
index 0000000..028f6c2
--- /dev/null
+++ b/frontend/app/views/index/index.css
@@ -0,0 +1,38 @@
+* {
+
+}
+.index {
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(
+ -45deg,
+ rgba(0, 0, 64, 0.5),
+ rgba(128, 0, 64, 0.5)
+ );
+ padding: 1rem;
+}
+.index > div {
+ display: inline-block;
+ padding: 1rem;
+ max-height: calc(100% - 2rem);
+ overflow: scroll;
+ background: rgba(64,12,64,0.9);
+ box-shadow: 3px 3px 6px rgba(0,0,0,0.4),
+ inset 0 0 60px rgba(128,255,255,0.1);
+}
+.graphIndex {
+ min-width: 20rem;
+ display: flex;
+ flex-direction: column;
+}
+.graphIndex > * {
+ margin-bottom: 0.5rem;
+}
+.graphIndex > div {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between
+}
+.graphIndex > div > a:first-child {
+ color: #fff;
+} \ No newline at end of file
diff --git a/frontend/app/views/page/components/page.editor.js b/frontend/app/views/page/components/page.editor.js
new file mode 100644
index 0000000..d324874
--- /dev/null
+++ b/frontend/app/views/page/components/page.editor.js
@@ -0,0 +1,215 @@
+import React, { Component } from 'react'
+import { Route } from 'react-router-dom'
+import { bindActionCreators } from 'redux'
+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 { Loader } from 'app/common'
+import { clamp, dist } from 'app/utils'
+
+import TileHandle from './tile.handle'
+
+const defaultState = {
+ dragging: false,
+ bounds: null,
+ mouseX: 0,
+ mouseY: 0,
+ box: {
+ x: 0, y: 0,
+ w: 0, h: 0,
+ },
+ tile: null,
+}
+
+class PageEditor extends Component {
+ state = {
+ ...defaultState,
+ }
+
+ constructor() {
+ super()
+ // bind these events in the constructor, so we can remove event listeners later
+ this.handleMouseDown = this.handleMouseDown.bind(this)
+ this.handleMouseMove = this.handleMouseMove.bind(this)
+ this.handleMouseUp = this.handleMouseUp.bind(this)
+ this.handleWindowResize = this.handleWindowResize.bind(this)
+ this.pageRef = React.createRef()
+ }
+
+ getBoundingClientRect() {
+ if (!this.pageRef.current) return null
+ const rect = this.pageRef.current.getBoundingClientRect()
+ const scrollTop = document.body.scrollTop || document.body.parentNode.scrollTop
+ const scrollLeft = document.body.scrollLeft || document.body.parentNode.scrollLeft
+ const bounds = {
+ top: rect.top + scrollTop,
+ left: rect.left + scrollLeft,
+ width: rect.width,
+ height: rect.height,
+ }
+ // console.log(bounds)
+ return bounds
+ }
+
+ componentDidMount() {
+ document.body.addEventListener('mousemove', this.handleMouseMove)
+ document.body.addEventListener('mouseup', this.handleMouseUp)
+ window.addEventListener('resize', this.handleWindowResize)
+ this.setState({ bounds: this.getBoundingClientRect() })
+ }
+
+ componentDidUpdate(prevProps) {
+ if (!this.state.bounds) {
+ this.setState({ bounds: this.getBoundingClientRect() })
+ }
+ }
+
+ handleWindowResize() {
+ this.setState({ bounds: this.getBoundingClientRect() })
+ }
+
+ handleMouseDown(e, tile) {
+ const bounds = this.getBoundingClientRect()
+ const mouseX = e.pageX
+ const mouseY = e.pageY
+ // let w = 128 / bounds.width
+ // let h = 16 / bounds.height
+ let { x, y } = tile.settings
+ // x = clamp(x, 0, 1)
+ // y = clamp(y, 0, 1)
+ this.setState({
+ tile,
+ draggingBox: true,
+ bounds,
+ mouseX,
+ mouseY,
+ box: {
+ dx: 0, dy: 0,
+ // x, y,
+ // w, h,
+ },
+ initialBox: {
+ // x, y,
+ // w, h,
+ }
+ })
+ }
+
+ handleMouseMove(e) {
+ const {
+ dragging, draggingBox,
+ bounds, mouseX, mouseY, initialBox, box
+ } = this.state
+ if (draggingBox) {
+ e.preventDefault()
+ let { x, y, w, h } = initialBox
+ let dx = (e.pageX - mouseX)
+ let dy = (e.pageY - mouseY)
+ this.setState({
+ box: {
+ dx, dy,
+ // x: clamp(x + (dx / bounds.width), 0, 1.0 - w),
+ // y: clamp(y + (dy / bounds.height), 0, 1.0 - h),
+ // w, h,
+ }
+ })
+ }
+ }
+
+ handleMouseUp(e) {
+ // const { actions } = this.props
+ const { temporaryTile } = this.props
+ const { dragging, draggingBox, bounds, box, tile } = this.state
+ if (!dragging && !draggingBox) return
+ e.preventDefault()
+ // const { x, y, w, h } = box
+ const { dx, dy } = box
+ let url = window.location.pathname
+ this.setState({
+ page: null,
+ box: null,
+ initialBox: null,
+ dragging: false,
+ draggingBox: false,
+ })
+ // console.log(page)
+ if (dist(0, 0, dx, dy) < 2) {
+ return
+ }
+ const updatedTile = {
+ ...tile,
+ settings: {
+ ...tile.settings,
+ x: tile.settings.x + dx,
+ y: tile.settings.y + dy,
+ }
+ }
+ if (temporaryTile && tile.id === temporaryTile.id) {
+ this.props.tileActions.updateTemporaryTile(updatedTile)
+ }
+ if (tile.id !== 'new') {
+ console.log(updatedTile)
+ this.props.pageActions.updatePageTile(updatedTile)
+ actions.tile.update(updatedTile)
+ }
+ }
+
+ render(){
+ if (!this.state.bounds || (!this.props.page.show.res && !this.props.page.show.res.tiles)) {
+ return (
+ <div className='page' ref={this.pageRef} />
+ )
+ }
+ const { temporaryTile } = this.props
+ const currentTile = this.state.tile
+ const currentBox = this.state.box
+ const { res } = this.props.page.show
+ const { settings } = res
+ const pageStyle = { backgroundColor: settings ? settings.background_color : '#000000' }
+ return (
+ <div className='page' ref={this.pageRef} style={pageStyle}>
+ {res.tiles.map(tile => {
+ if (temporaryTile && temporaryTile.id === tile.id) {
+ tile = temporaryTile
+ }
+ return (
+ <TileHandle
+ key={tile.id}
+ tile={tile}
+ bounds={this.state.bounds}
+ box={currentTile && tile.id === currentTile.id && currentBox}
+ onMouseDown={e => this.handleMouseDown(e, tile)}
+ onDoubleClick={e => this.props.pageActions.showEditTileForm(tile.id)}
+ />
+ )
+ })}
+ {!!(temporaryTile && temporaryTile.id === 'new') && (
+ <TileHandle
+ key={temporaryTile.id}
+ tile={temporaryTile}
+ bounds={this.state.bounds}
+ box={currentTile && temporaryTile.id === currentTile.id && currentBox}
+ onMouseDown={e => this.handleMouseDown(e, temporaryTile)}
+ />
+ )}
+ </div>
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ graph: state.graph,
+ page: state.page,
+ temporaryTile: state.tile.temporaryTile,
+})
+
+const mapDispatchToProps = dispatch => ({
+ pageActions: bindActionCreators({ ...pageActions }, dispatch),
+ tileActions: bindActionCreators({ ...tileActions }, dispatch),
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(PageEditor)
diff --git a/frontend/app/views/page/components/page.header.js b/frontend/app/views/page/components/page.header.js
new file mode 100644
index 0000000..eb1c3b9
--- /dev/null
+++ b/frontend/app/views/page/components/page.header.js
@@ -0,0 +1,36 @@
+import React from 'react'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+import { Link } from 'react-router-dom'
+
+import * as graphActions from '../../graph/graph.actions'
+import * as pageActions from '../page.actions'
+
+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>
+ </div>
+ <div>
+ <button onClick={() => props.pageActions.toggleAddTileForm()}>+ Add tile</button>
+ <button onClick={() => props.pageActions.toggleTileList()}>Sort tiles</button>
+ <button onClick={() => props.graphActions.toggleEditPageForm()}>Edit page</button>
+ </div>
+ </header>
+ )
+}
+
+const mapStateToProps = (state) => ({
+ // auth: state.auth,
+ site: state.site,
+ graph: state.graph,
+ // isAuthenticated: state.auth.isAuthenticated,
+})
+
+const mapDispatchToProps = (dispatch) => ({
+ graphActions: bindActionCreators({ ...graphActions }, dispatch),
+ pageActions: bindActionCreators({ ...pageActions }, dispatch),
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(PageHeader)
diff --git a/frontend/app/views/page/components/tile.edit.js b/frontend/app/views/page/components/tile.edit.js
new file mode 100644
index 0000000..2ea09d1
--- /dev/null
+++ b/frontend/app/views/page/components/tile.edit.js
@@ -0,0 +1,84 @@
+import React, { Component } from 'react'
+// import { Link } from 'react-router-dom'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+
+// import { history } from 'app/store'
+import actions from 'app/actions'
+import * as pageActions from '../../page/page.actions'
+import * as tileActions from '../../tile/tile.actions'
+
+import { Loader } from 'app/common'
+
+import TileForm from '../components/tile.form'
+
+class TileEdit extends Component {
+ state = {
+ tile: null
+ }
+
+ componentDidMount() {
+ this.load()
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.page.editor.currentEditTileId !== this.props.page.editor.currentEditTileId) {
+ this.load()
+ }
+ }
+
+ load() {
+ const { currentEditTileId } = this.props.page.editor
+ const tile = this.props.page.show.res.tiles.filter(tile => tile.id === currentEditTileId)[0]
+ console.log('edit', currentEditTileId)
+ this.setState({ tile })
+ }
+
+ handleSubmit(data) {
+ actions.tile.update(data)
+ .then(response => {
+ // console.log(response)
+ if (response.status === 'ok') {
+ this.props.pageActions.updatePageTile(response.res)
+ }
+ })
+ }
+
+ handleClose() {
+ this.props.pageActions.hideEditTileForm()
+ this.props.tileActions.clearTemporaryTile()
+ }
+
+ render() {
+ const { tile } = this.state
+ if (!tile) {
+ return (
+ <div className='form'>
+ <Loader />
+ </div>
+ )
+ }
+ return (
+ <TileForm
+ initialData={tile}
+ graph={this.props.graph.show.res}
+ page={this.props.page.show.res}
+ onSubmit={this.handleSubmit.bind(this)}
+ onClose={this.handleClose.bind(this)}
+ />
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ graph: state.graph,
+ page: state.page,
+ tile: state.tile,
+})
+
+const mapDispatchToProps = dispatch => ({
+ pageActions: bindActionCreators({ ...pageActions }, dispatch),
+ tileActions: bindActionCreators({ ...tileActions }, dispatch),
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(TileEdit)
diff --git a/frontend/app/views/page/components/tile.form.js b/frontend/app/views/page/components/tile.form.js
new file mode 100644
index 0000000..5b25f13
--- /dev/null
+++ b/frontend/app/views/page/components/tile.form.js
@@ -0,0 +1,645 @@
+import React, { Component } from 'react'
+import { connect } from 'react-redux'
+import { bindActionCreators } from 'redux'
+import { Link } from 'react-router-dom'
+
+import actions from 'app/actions'
+import { session } from 'app/session'
+
+import {
+ TextInput, NumberInput, ColorInput, Slider,
+ Select, LabelDescription, TextArea, Checkbox,
+ SubmitButton, Loader } from 'app/common'
+import { preloadImage } from 'app/utils'
+
+import * as tileActions from '../../tile/tile.actions'
+
+const SELECT_TYPES = [
+ "image", "text", "link"
+].map(s => ({ name: s, label: s }))
+
+const ALIGNMENTS = [
+ "top_left", "top_center", "top_right",
+ "center_left", "center_center", "center_right",
+ "bottom_left", "bottom_center", "bottom_right",
+].map(align => ({
+ name: align,
+ label: align === 'center_center'
+ ? 'center'
+ : align.replace('_', ' ')
+ }))
+
+const REQUIRED_KEYS = {
+ image: ['url'],
+ text: ['content'],
+ link: [],
+}
+
+const IMAGE_TILE_STYLES = [
+ 'tile', 'cover', 'contain', 'contain no-repeat'
+].map(style => ({ name: style, label: style }))
+
+const TEXT_FONT_FAMILIES = [
+ 'sans-serif', 'serif', 'fantasy', 'monospace', 'cursive',
+].map(style => ({ name: style, label: style }))
+
+const TEXT_FONT_STYLES = [
+ 'normal', 'bold', 'italic', 'bold-italic',
+].map(style => ({ name: style, label: style }))
+
+const CURSORS = [
+ { name: 'hand_up', label: 'Up', },
+ { name: 'hand_down', label: 'Down', },
+ { name: 'hand_left', label: 'Left', },
+ { name: 'hand_right', label: 'Right', },
+]
+
+const NO_LINK = 0
+const EXTERNAL_LINK = -1
+const PAGE_LIST_TOP_OPTIONS = [
+ { name: NO_LINK, label: 'No link' },
+ { name: EXTERNAL_LINK, label: 'External link' },
+ { name: -2, label: '──────────', disabled: true },
+]
+
+// target_page_id = Column(Integer, ForeignKey('page.id'), nullable=True)
+// https://s3.amazonaws.com/i.asdf.us/im/1c/gradient_gold1-SpringGreen1_1321159749.jpg
+
+const newImage = (data) => ({
+ settings: {
+ ...newPosition(),
+ is_tiled: false,
+ tile_style: 'tile',
+ url: "",
+ external_link_url: "",
+ cursor: 'hand_up',
+ },
+ type: 'image',
+ target_page_id: null,
+ ...data,
+})
+
+const newText = (data) => ({
+ settings: {
+ ...newPosition(),
+ content: "",
+ font_family: 'sans-serif',
+ font_size: 16,
+ font_style: 'normal',
+ font_color: '#dddddd',
+ background_color: 'transparent',
+ width: 0,
+ height: 0,
+ external_link_url: "",
+ cursor: 'hand_up',
+ },
+ type: 'text',
+ target_page_id: null,
+ ...data,
+})
+
+const newLink = (data) => ({
+ settings: {
+ ...newPosition({ width: 100, height: 100, }),
+ external_link_url: "",
+ cursor: 'hand_up',
+ },
+ type: 'link',
+ target_page_id: null,
+ ...data,
+})
+
+const newPosition = (data) => ({
+ x: 0, y: 0,
+ width: 0, height: 0,
+ rotation: 0, scale: 1,
+ opacity: 1,
+ align: "center_center",
+ ...data,
+})
+
+const TYPE_CONSTRUCTORS = {
+ image: newImage,
+ text: newText,
+ link: newLink,
+}
+
+class TileForm extends Component {
+ state = {
+ title: "",
+ submitTitle: "",
+ errorFields: new Set([]),
+ modified: false,
+ pageList: [],
+ }
+
+ componentDidMount() {
+ const { graph, page, isNew, initialData, sortOrder } = this.props
+ const title = isNew ? 'new tile' : 'editing tile'
+ const submitTitle = isNew ? "Create Tile" : "Save Changes"
+ this.setState({
+ title,
+ submitTitle,
+ errorFields: new Set([]),
+ })
+ const { pages } = graph.show.res
+ const linkPages = initialData ? pages.filter(page => page.id !== initialData.id) : pages
+ let pageList = [
+ ...PAGE_LIST_TOP_OPTIONS,
+ ...linkPages.map(page => ({ name: page.id, label: page.path }))
+ ]
+ this.setState({ pageList })
+ if (isNew) {
+ const newTile = newImage({
+ id: "new",
+ graph_id: graph.show.res.id,
+ page_id: page.show.res.id,
+ sort_order: sortOrder,
+ })
+ this.props.tileActions.updateTemporaryTile(newTile)
+ } else {
+ this.props.tileActions.updateTemporaryTile({ ...initialData })
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (!this.props.isNew && this.props.initialData !== prevProps.initialData) {
+ this.handleSubmit()
+ this.props.tileActions.updateTemporaryTile({ ...this.props.initialData })
+ this.setState({
+ errorFields: new Set([]),
+ })
+ }
+ }
+
+ componentWillUnmount() {
+ // if the item has changed, save before we close the form!
+ if (!this.props.isNew && this.state.modified) {
+ this.handleSubmit()
+ }
+ }
+
+ handleChange(e) {
+ const { name, value } = e.target
+ this.clearErrorField(name)
+ this.props.tileActions.updateTemporaryTile({
+ ...this.props.temporaryTile,
+ [name]: value,
+ })
+ }
+
+ handleTypeChange(type) {
+ const { graph, page, temporaryTile } = this.props
+ let newTile = TYPE_CONSTRUCTORS[type]({
+ id: temporaryTile.id,
+ graph_id: temporaryTile.graph_id,
+ page_id: temporaryTile.page_id,
+ })
+ newTile.settings.align = temporaryTile.settings.align
+ this.clearErrorField('type')
+ this.props.tileActions.updateTemporaryTile(newTile)
+ }
+
+ handleSettingsChange(e) {
+ const { name, value } = e.target
+ this.clearErrorField(name)
+ this.props.tileActions.updateTemporaryTile({
+ ...this.props.temporaryTile,
+ settings: {
+ ...this.props.temporaryTile.settings,
+ [name]: value,
+ }
+ })
+ }
+
+ handleSelect(name, value) {
+ this.clearErrorField(name)
+ if (name === 'type') {
+ return this.handleTypeChange(value)
+ }
+ if (name === 'target_page_id') {
+ value = parseInt(value)
+ }
+ this.props.tileActions.updateTemporaryTile({
+ ...this.props.temporaryTile,
+ [name]: value,
+ })
+ }
+
+ handleSettingsSelect(name, value) {
+ this.clearErrorField(name)
+ this.props.tileActions.updateTemporaryTile({
+ ...this.props.temporaryTile,
+ settings: {
+ ...this.props.temporaryTile.settings,
+ [name]: value,
+ }
+ })
+ }
+
+ handleAlignment(name, value) {
+ this.clearErrorField(name)
+ this.props.tileActions.updateTemporaryTile({
+ ...this.props.temporaryTile,
+ settings: {
+ ...this.props.temporaryTile.settings,
+ [name]: value,
+ x: 0, y: 0,
+ }
+ })
+ }
+
+ handleImageChange(e) {
+ const { name, value } = e.target
+ this.handleSettingsSelect(name, value)
+ preloadImage(value).then(img => {
+ // console.log(img)
+ this.props.tileActions.updateTemporaryTile({
+ ...this.props.temporaryTile,
+ settings: {
+ ...this.props.temporaryTile.settings,
+ [name]: value,
+ width: img.naturalWidth,
+ height: img.naturalHeight,
+ x: 0, y: 0,
+ }
+ })
+ })
+ }
+
+ clearErrorField(name) {
+ const { errorFields } = this.state
+ if (errorFields.has(name)) {
+ errorFields.delete(name)
+ this.setState({
+ errorFields,
+ modified: true,
+ })
+ } else if (!this.state.modified) {
+ this.setState({
+ errorFields,
+ modified: true,
+ })
+ }
+ }
+
+ handleSubmit(e) {
+ if (e) e.preventDefault()
+ const { isNew, temporaryTile, onSubmit, onClose } = this.props
+ const requiredSettings = REQUIRED_KEYS[temporaryTile.type]
+ const validKeys = "id graph_id page_id target_page_id type settings".split(" ")
+ const validData = validKeys.reduce((a,b) => { a[b] = temporaryTile[b]; return a }, {})
+ const errorFields = requiredSettings.filter(key => !validData.settings[key])
+ if (errorFields.length) {
+ console.log('error', errorFields, validData)
+ if (e) {
+ this.setState({ errorFields: new Set(errorFields) })
+ }
+ } else {
+ if (isNew) {
+ // side effect: set username if we're creating a new tile
+ // session.set('username', data.username)
+ delete validData.id
+ } else {
+ validData.id = temporaryTile.id
+ }
+ this.setState({ modified: false })
+ console.log('submit', validData)
+ onSubmit(validData)
+ // if submitting after switching elements, don't close the form
+ if (e && onClose) {
+ onClose()
+ }
+ }
+ }
+
+ handleDelete() {
+ const { temporaryTile, isNew, onClose } = this.props
+ if (confirm('Really delete this tile?')) {
+ actions.tile.destroy(temporaryTile)
+ .then(() => {
+ onClose()
+ })
+ }
+ }
+
+ render() {
+ const { temporaryTile, isNew } = this.props
+ const { title, submitTitle, errorFields } = this.state
+ if (!temporaryTile || !temporaryTile.settings) return <div className='box' />
+ return (
+ <div className='box'>
+ <h1>{title}</h1>
+ <form onSubmit={this.handleSubmit.bind(this)}>
+ <div className="row selects">
+ <Select
+ name='type'
+ selected={temporaryTile.type}
+ options={SELECT_TYPES}
+ title=''
+ onChange={this.handleSelect.bind(this)}
+ />
+ <Select
+ name='align'
+ selected={temporaryTile.settings.align}
+ options={ALIGNMENTS}
+ title=''
+ onChange={this.handleAlignment.bind(this)}
+ />
+ </div>
+
+ {this.renderPositionInfo()}
+
+ {temporaryTile.type === 'image'
+ ? this.renderImageForm()
+ : temporaryTile.type === 'text'
+ ? this.renderTextForm()
+ : temporaryTile.type === 'link'
+ ? this.renderLinkForm()
+ : ""}
+
+ {this.renderHyperlinkForm()}
+ {this.renderMiscForm()}
+
+ <div className='row buttons'>
+ <SubmitButton
+ title={submitTitle}
+ onClick={this.handleSubmit.bind(this)}
+ />
+ {!isNew &&
+ <SubmitButton
+ title={'Delete'}
+ className='destroy'
+ onClick={this.handleDelete.bind(this)}
+ />
+ }
+ </div>
+ {!!errorFields.size &&
+ <label>
+ <span></span>
+ <span>Please add the required fields =)</span>
+ </label>
+ }
+ </form>
+ </div>
+ )
+ }
+
+ renderPositionInfo() {
+ const { temporaryTile } = this.props
+ const { x, y, width, height, rotation, scale } = temporaryTile.settings
+ return (
+ <div className='position'>
+ {parseInt(x)}{', '}
+ {parseInt(y)}{' '}
+ {parseInt(width)}{'x'}{parseInt(height)}{' '}
+ {rotation === 0 || <span>{parseInt(rotation)}{'\u00B0 '}</span>}
+ {scale === 1 || <span>{'x'}{scale.toFixed(2)}</span>}
+ </div>
+ )
+ }
+
+ renderImageForm() {
+ // const { isNew } = this.props
+ const { temporaryTile } = this.props
+ const { errorFields } = this.state
+ // console.log(temporaryTile.settings)
+ return (
+ <div>
+ <div className='row imageUrl'>
+ {temporaryTile.settings.url && <div className='thumb'><img src={temporaryTile.settings.url} /></div>}
+ <TextInput
+ title=""
+ placeholder='http://'
+ name="url"
+ required
+ data={temporaryTile.settings}
+ error={errorFields.has('url')}
+ onChange={this.handleImageChange.bind(this)}
+ autoComplete="off"
+ />
+ </div>
+ <div className='row pair'>
+ <Checkbox
+ label="Tiled"
+ name="is_tiled"
+ checked={temporaryTile.settings.is_tiled}
+ onChange={this.handleSettingsSelect.bind(this)}
+ autoComplete="off"
+ />
+ {temporaryTile.settings.is_tiled &&
+ <Select
+ name='tile_style'
+ selected={temporaryTile.settings.tile_style || 'tile'}
+ options={IMAGE_TILE_STYLES}
+ title=''
+ onChange={this.handleSettingsSelect.bind(this)}
+ />
+ }
+ </div>
+ </div>
+ )
+ }
+
+ renderTextForm() {
+ const { temporaryTile } = this.props
+ const { errorFields } = this.state
+ return (
+ <div>
+ <TextArea
+ title=""
+ name="content"
+ required
+ data={temporaryTile.settings}
+ error={errorFields.has('content')}
+ onChange={this.handleSettingsChange.bind(this)}
+ autoComplete="off"
+ />
+ <div className='row font'>
+ <Select
+ title="Font"
+ name='font_family'
+ selected={temporaryTile.settings.font_family || 'sans-serif'}
+ options={TEXT_FONT_FAMILIES}
+ title=''
+ onChange={this.handleSettingsSelect.bind(this)}
+ />
+ <NumberInput
+ title=''
+ name='font_size'
+ data={temporaryTile.settings}
+ min={1}
+ max={1200}
+ error={errorFields.has('font_size')}
+ onChange={this.handleSettingsChange.bind(this)}
+ autoComplete="off"
+ />
+ <Select
+ name='font_style'
+ selected={temporaryTile.settings.font_style || 'normal'}
+ options={TEXT_FONT_STYLES}
+ title=''
+ onChange={this.handleSettingsSelect.bind(this)}
+ />
+ </div>
+ <ColorInput
+ title='Text'
+ name='font_color'
+ data={temporaryTile.settings}
+ error={errorFields.has('font_color')}
+ onChange={this.handleSettingsChange.bind(this)}
+ autoComplete="off"
+ />
+ <ColorInput
+ title='BG'
+ name='background_color'
+ data={temporaryTile.settings}
+ error={errorFields.has('background_color')}
+ onChange={this.handleSettingsChange.bind(this)}
+ autoComplete="off"
+ />
+ <div className='row pair'>
+ <NumberInput
+ title="Width"
+ name="width"
+ data={temporaryTile.settings}
+ min={0}
+ max={1200}
+ error={errorFields.has('width')}
+ onChange={this.handleSettingsChange.bind(this)}
+ autoComplete="off"
+ />
+ <NumberInput
+ title="Height"
+ name="height"
+ data={temporaryTile.settings}
+ min={0}
+ max={1200}
+ error={errorFields.has('height')}
+ onChange={this.handleSettingsChange.bind(this)}
+ autoComplete="off"
+ />
+ </div>
+ </div>
+ )
+ }
+
+ renderLinkForm() {
+ const { temporaryTile } = this.props
+ const { errorFields } = this.state
+ return (
+ <div>
+ <div className='row pair'>
+ <NumberInput
+ title="Width"
+ name="width"
+ data={temporaryTile.settings}
+ min={0}
+ max={1200}
+ error={errorFields.has('width')}
+ onChange={this.handleSettingsChange.bind(this)}
+ autoComplete="off"
+ />
+ <NumberInput
+ title="Height"
+ name="height"
+ data={temporaryTile.settings}
+ min={0}
+ max={1200}
+ error={errorFields.has('height')}
+ onChange={this.handleSettingsChange.bind(this)}
+ autoComplete="off"
+ />
+ </div>
+ </div>
+ )
+ }
+
+ renderHyperlinkForm() {
+ const { temporaryTile } = this.props
+ const { pageList } = this.state
+ const isExternalLink = temporaryTile.target_page_id === EXTERNAL_LINK
+ return (
+ <div>
+ <div className={'row selects'}>
+ <Select
+ title=''
+ name='target_page_id'
+ selected={temporaryTile.target_page_id || NO_LINK}
+ options={pageList}
+ onChange={this.handleSelect.bind(this)}
+ />
+ <Select
+ title=''
+ name='cursor'
+ selected={temporaryTile.settings.cursor}
+ options={CURSORS}
+ defaultOption="Cursor"
+ onChange={this.handleSettingsSelect.bind(this)}
+ />
+ </div>
+ <div>
+ {isExternalLink &&
+ <TextInput
+ title=""
+ placeholder='http://'
+ name="external_link_url"
+ data={temporaryTile.settings}
+ onChange={this.handleSettingsChange.bind(this)}
+ autoComplete="off"
+ />
+ }
+ </div>
+ </div>
+ )
+ }
+
+ renderMiscForm() {
+ const { temporaryTile } = this.props
+ return (
+ <div>
+ <Slider
+ title='Opacity'
+ name='opacity'
+ value={temporaryTile.settings.opacity}
+ onChange={this.handleSettingsSelect.bind(this)}
+ min={0.0}
+ max={1.0}
+ step={0.01}
+ />
+ <Slider
+ title='Scale'
+ name='scale'
+ value={temporaryTile.settings.scale}
+ onChange={this.handleSettingsSelect.bind(this)}
+ min={0.01}
+ max={10.0}
+ step={0.01}
+ />
+ <Slider
+ title='Rotation'
+ name='rotation'
+ value={temporaryTile.settings.rotation}
+ onChange={this.handleSettingsSelect.bind(this)}
+ min={-180.0}
+ max={180.0}
+ step={1}
+ type='int'
+ />
+ </div>
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ graph: state.graph,
+ page: state.page,
+ tile: state.tile,
+ temporaryTile: state.tile.temporaryTile,
+})
+
+const mapDispatchToProps = dispatch => ({
+ tileActions: bindActionCreators({ ...tileActions }, dispatch),
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(TileForm)
diff --git a/frontend/app/views/page/components/tile.handle.js b/frontend/app/views/page/components/tile.handle.js
new file mode 100644
index 0000000..e91f0b1
--- /dev/null
+++ b/frontend/app/views/page/components/tile.handle.js
@@ -0,0 +1,139 @@
+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
+ }
+ 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/components/tile.list.js b/frontend/app/views/page/components/tile.list.js
new file mode 100644
index 0000000..c455489
--- /dev/null
+++ b/frontend/app/views/page/components/tile.list.js
@@ -0,0 +1,142 @@
+import React, { Component } from 'react'
+// import { Link } from 'react-router-dom'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+import { ReactSortable } from "react-sortablejs"
+
+// import actions from 'app/actions'
+import * as tileActions from '../../tile/tile.actions'
+import * as pageActions from '../../page/page.actions'
+
+const DOUBLE_CLICK_THRESHOLD = 250
+
+class TileList extends Component {
+ state = {
+ tiles: [],
+ lastTargetId: 0,
+ lastTimeStamp: 0,
+ }
+
+ // store doubleclick state as a class property because ReactSortable calls setState promiscuously
+ didDoubleClick = false
+
+ componentDidMount(prevProps) {
+ const { tiles } = this.props.page.show.res
+ const { pages } = this.props.graph.show.res
+ const pageTitles = pages.reduce((a,b) => {
+ a[b.id] = b.title
+ return a
+ }, {})
+ this.setState({ tiles: tiles.slice(0).reverse(), pageTitles })
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (this.didDoubleClick) return
+ const { tiles } = this.state
+ if (prevState.tiles.length && !pageActions.isSameTileOrder(tiles, prevState.tiles)) {
+ this.props.pageActions.setTileSortOrder(tiles.slice(0).reverse())
+ }
+ // since we store the full tiles here (reversed!), they might change from under us
+ // potentially later refactor to only use a sort order / lookup
+ else if (prevProps.page.show.res.tiles !== this.props.page.show.res.tiles) {
+ const tileLookup = this.props.page.show.res.tiles.reduce((a,b) => {
+ a[b.id] = b
+ return a
+ }, {})
+ const newTiles = this.state.tiles.map(tile => {
+ return tileLookup[tile.id]
+ })
+ this.setState({ tiles: newTiles })
+ }
+ }
+
+ handleChoose(e) {
+ const { lastTargetId, lastTimeStamp } = this.state
+ if (lastTimeStamp
+ && parseInt(e.item.dataset.id) === lastTargetId
+ && (e.timeStamp - lastTimeStamp) < DOUBLE_CLICK_THRESHOLD
+ ) {
+ // console.log('selected', lastTargetId)
+ this.didDoubleClick = true
+ this.props.pageActions.showEditTileForm(lastTargetId)
+ } else {
+ this.setState({
+ lastTargetId: parseInt(e.item.dataset.id),
+ lastTimeStamp: e.timeStamp,
+ })
+ }
+ }
+
+ handleUpdate(newTiles) {
+ if (this.didDoubleClick) return
+ this.setState({ tiles: newTiles })
+ }
+
+ render() {
+ const { tiles, pageTitles } = this.state
+ return (
+ <div className='box tileList'>
+ <ReactSortable
+ list={tiles}
+ setList={newTiles => this.handleUpdate(newTiles)}
+ onChoose={e => this.handleChoose(e)}
+ >
+ {tiles.map(tile => (
+ tile.type === 'image'
+ ? <TileListImage key={tile.id} tile={tile} />
+ : tile.type === 'text'
+ ? <TileListText key={tile.id} tile={tile} />
+ : tile.type === 'link'
+ ? <TileListLink key={tile.id} tile={tile} pageTitles={pageTitles} />
+ : <TileListMisc key={tile.id} tile={tile} />
+ ))}
+ </ReactSortable>
+ </div>
+ )
+ }
+}
+
+const TileListImage = ({ tile }) => (
+ <div className='row' data-id={tile.id}>
+ <div className='thumb' style={{ backgroundImage: 'url(' + tile.settings.url + ')' }} />
+ </div>
+)
+
+const TileListText = ({ tile }) => (
+ <div className='row' data-id={tile.id}>
+ <span className='snippet'>{(tile.settings.content || "").substr(0, 100)}</span>
+ </div>
+)
+
+const TileListLink = ({ tile, pageTitles }) => (
+ <div className='row link' data-id={tile.id}>
+ <span className='snippet'>
+ {'Link: '}
+ {tile.target_page_id === -1
+ ? 'External'
+ : !tile.target_page_id
+ ? 'No link specified!'
+ : tile.target_page_id in pageTitles
+ ? pageTitles[tile.target_page_id]
+ : 'Error, broken link!'}
+ </span>
+ </div>
+)
+
+const TileListMisc = ({ tile }) => (
+ <div className='row' data-id={tile.id}>
+ <span className='snippet'>{"Tile: "}{tile.type}</span>
+ </div>
+)
+
+const mapStateToProps = state => ({
+ graph: state.graph,
+ page: state.page,
+})
+
+const mapDispatchToProps = dispatch => ({
+ tileActions: bindActionCreators({ ...tileActions }, dispatch),
+ pageActions: bindActionCreators({ ...pageActions }, dispatch),
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(TileList)
diff --git a/frontend/app/views/page/components/tile.new.js b/frontend/app/views/page/components/tile.new.js
new file mode 100644
index 0000000..fb609a5
--- /dev/null
+++ b/frontend/app/views/page/components/tile.new.js
@@ -0,0 +1,55 @@
+import React, { Component } from 'react'
+// import { Link } from 'react-router-dom'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+
+import { history } from 'app/store'
+import actions from 'app/actions'
+import * as tileActions from '../../tile/tile.actions'
+import * as pageActions from '../../page/page.actions'
+
+import TileForm from '../components/tile.form'
+
+class TileNew extends Component {
+ handleSubmit(data) {
+ console.log(data)
+ actions.tile.create(data)
+ .then(res => {
+ console.log(res)
+ // const graph = this.props.graph.show.res
+ // if (res.res && res.res.id) {
+ // history.push('/' + graph.path + '/' + res.res.path)
+ // }
+ this.props.pageActions.hideAddTileForm()
+ this.props.tileActions.clearTemporaryTile()
+ })
+ .catch(err => {
+ console.error('error')
+ })
+ }
+
+ render() {
+ return (
+ <TileForm
+ isNew
+ graph={this.props.graph.show.res}
+ page={this.props.page.show.res}
+ initialData={null}
+ sortOrder={this.props.page.show.res.tiles.length}
+ onSubmit={this.handleSubmit.bind(this)}
+ />
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ graph: state.graph,
+ page: state.page,
+})
+
+const mapDispatchToProps = dispatch => ({
+ tileActions: bindActionCreators({ ...tileActions }, dispatch),
+ pageActions: bindActionCreators({ ...pageActions }, dispatch),
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(TileNew)
diff --git a/frontend/app/views/page/cursors.css b/frontend/app/views/page/cursors.css
new file mode 100644
index 0000000..56fb088
--- /dev/null
+++ b/frontend/app/views/page/cursors.css
@@ -0,0 +1,21 @@
+/* cursors */
+/* keep in separate file so they don't conflict. a copy of this lives in site.css */
+
+.tile.hand_up {
+ cursor: url(/static/img/hand_up.png) 40 10, pointer;
+}
+.tile.hand_right {
+ cursor: url(/static/img/hand_right.png) 90 40, pointer;
+}
+.tile.hand_down {
+ cursor: url(/static/img/hand_down.png) 60 90, pointer;
+}
+.tile.hand_left {
+ cursor: url(/static/img/hand_left.png) 10 60, pointer;
+}
+
+.tile.link {
+ cursor: pointer;
+ border: 1px solid #31f;
+ background-color: rgba(48,16,255,0.1);
+} \ No newline at end of file
diff --git a/frontend/app/views/page/page.actions.js b/frontend/app/views/page/page.actions.js
new file mode 100644
index 0000000..d2bbbe2
--- /dev/null
+++ b/frontend/app/views/page/page.actions.js
@@ -0,0 +1,110 @@
+import * as types from 'app/types'
+import { store } from 'app/store'
+import actions from 'app/actions'
+import { default as debounce } from 'lodash.debounce'
+import { post } from 'app/utils'
+
+const url = {
+ sortTiles: (id) => '/api/v1/page/' + id + '/sort',
+}
+
+// Add tile form
+
+export const showAddTileForm = () => dispatch => {
+ dispatch({ type: types.page.show_add_tile_form })
+}
+
+export const hideAddTileForm = () => dispatch => {
+ dispatch({ type: types.page.hide_add_tile_form })
+}
+
+export const toggleAddTileForm = () => dispatch => {
+ dispatch({ type: types.page.toggle_add_tile_form })
+}
+
+// Edit tile form
+
+export const showEditTileForm = tile_id => dispatch => {
+ dispatch({ type: types.page.show_edit_tile_form, tile_id })
+}
+
+export const hideEditTileForm = () => dispatch => {
+ dispatch({ type: types.page.hide_edit_tile_form })
+}
+
+export const toggleEditTileForm = () => dispatch => {
+ dispatch({ type: types.page.toggle_edit_tile_form })
+}
+
+// Tile list
+
+export const showTileList = () => dispatch => {
+ dispatch({ type: types.page.show_tile_list })
+}
+
+export const hideTileList = () => dispatch => {
+ dispatch({ type: types.page.hide_tile_list })
+}
+
+export const toggleTileList = () => dispatch => {
+ dispatch({ type: types.page.toggle_tile_list })
+}
+
+// Update local page tile state when we change it
+
+export const updatePageTile = tile => dispatch => {
+ dispatch({ type: types.page.update_page_tile, tile })
+}
+
+// Fetch graph/page when loading a new URL
+
+export const showGraphAndPageIfUnloaded = ({ graph_name, page_name }) => dispatch => (
+ new Promise((resolve, reject) => {
+ showGraphIfUnloaded({ graph_name })(dispatch)
+ .then(graph => (
+ actions.page.show('name/' + graph_name + '/' + page_name)
+ .then(resolve)
+ .catch(reject)
+ ))
+ .catch(reject)
+ })
+)
+
+export const showGraphIfUnloaded = ({ graph_name }) => dispatch => (
+ new Promise((resolve, reject) => {
+ const { res: graph } = store.getState().graph.show
+ if (graph && graph.path === graph_name) {
+ return resolve(graph)
+ }
+ actions.graph.show('name/' + graph_name)
+ .then(resolve)
+ .catch(reject)
+ })
+)
+
+// Sorting tiles in the tile list
+
+export const setTileSortOrder = (tiles) => dispatch => {
+ let oldTiles = store.getState().page.show.res.tiles
+ if (!isSameTileOrder(tiles, oldTiles)) {
+ updateTileSortOrder(tiles, dispatch)
+ }
+ dispatch({ type: types.page.set_tile_sort_order, tiles })
+}
+
+export const isSameTileOrder = (newTiles, oldTiles) => {
+ for (let i = 0; i < newTiles.length; i++) {
+ if (newTiles[i].id !== oldTiles[i].id) {
+ return false
+ }
+ }
+ return true
+}
+
+export const updateTileSortOrder = debounce((tiles, dispatch) => {
+ const { page_id } = tiles[0]
+ const order = tiles.map(tile => (tile.id))
+ console.log(page_id, order)
+ post(dispatch, types.page, 'sort', url.sortTiles(page_id), order)
+}, 1000)
+
diff --git a/frontend/app/views/page/page.container.js b/frontend/app/views/page/page.container.js
new file mode 100644
index 0000000..dc85f5e
--- /dev/null
+++ b/frontend/app/views/page/page.container.js
@@ -0,0 +1,95 @@
+import React, { Component } from 'react'
+import { Route } from 'react-router-dom'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+
+import './page.css'
+import './cursors.css'
+
+import actions from 'app/actions'
+import { Loader } from 'app/common'
+
+import * as graphActions from '../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 PageHeader from './components/page.header'
+import PageEditor from './components/page.editor'
+
+class PageContainer extends Component {
+ componentDidMount() {
+ if (this.shouldShowPage()) this.load()
+ }
+ componentDidUpdate(prevProps) {
+ if (this.shouldLoadPage(prevProps)) this.load()
+ }
+ shouldShowPage() {
+ const { graph_name, page_name } = this.props.match.params
+ // console.log(graph_name, page_name)
+ return (graph_name && page_name && graph_name !== 'index')
+ }
+ shouldLoadPage(prevProps) {
+ const { page, location } = this.props
+ const { key } = location
+ if (key === prevProps.location.key) return false
+ if (!this.shouldShowPage()) return false
+ return (page.show.name === prevProps.page.show.name)
+ }
+ load() {
+ actions.site.setSiteTitle("loading " + this.props.match.params.page_name + "...")
+ this.props.pageActions.showGraphAndPageIfUnloaded(this.props.match.params)
+ .then(data => {
+ actions.site.setSiteTitle(data.res.title)
+ if (!data.res.tiles.length) {
+ this.props.pageActions.showAddTileForm()
+ } else {
+ this.props.pageActions.hideAddTileForm()
+ }
+ })
+ }
+ render() {
+ if (!this.shouldShowPage()) return null
+ if (!this.props.page.show.res || this.props.page.show.loading) {
+ return (
+ <div>
+ <PageHeader />
+ <div className='body'>
+ <div className='page loading'>
+ <Loader />
+ </div>
+ </div>
+ </div>
+ )
+ }
+ return (
+ <div>
+ <PageHeader />
+ <div className='body'>
+ <PageEditor />
+ <div className='sidebar'>
+ {this.props.graph.editor.editingPage && <PageEdit />}
+ {this.props.page.editor.addingTile && <TileNew />}
+ {this.props.page.editor.editingTile && <TileEdit />}
+ {this.props.page.editor.tileList && <TileList />}
+ </div>
+ </div>
+ </div>
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ graph: state.graph,
+ page: state.page,
+})
+
+const mapDispatchToProps = dispatch => ({
+ graphActions: bindActionCreators({ ...graphActions }, dispatch),
+ pageActions: bindActionCreators({ ...pageActions }, dispatch),
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(PageContainer)
diff --git a/frontend/app/views/page/page.css b/frontend/app/views/page/page.css
new file mode 100644
index 0000000..4559543
--- /dev/null
+++ b/frontend/app/views/page/page.css
@@ -0,0 +1,167 @@
+.page.loading {
+ padding: 1rem;
+}
+
+.page {
+ width: 100%;
+ height: 100%;
+}
+
+/* tiles */
+
+.tile {
+ position: absolute;
+}
+.tile.image {
+ display: block;
+}
+.tile.image.is_tiled {
+ width: 100%;
+ height: 100%;
+}
+.tile.text {
+ display: block;
+ white-space: break-spaces;
+ padding: 0.25rem;
+ user-select: none;
+ cursor: arrow;
+}
+.tile.link {
+ display: block;
+}
+
+/* tile orientations */
+
+.tile.top_left { top: 0; left: 0; }
+.tile.center_left { top: 50%; left: 0; }
+.tile.bottom_left { bottom: 0; left: 0; }
+.tile.top_center { top: 0; left: 50%; }
+.tile.center_center { top: 50%; left: 50%; }
+.tile.bottom_center { bottom: 0; left: 50%; }
+.tile.top_right { top: 0; right: 0; }
+.tile.center_right { top: 50%; right: 0; }
+.tile.bottom_right { bottom: 0; right: 0; }
+
+/* sortable tile list */
+
+.tileList .row {
+ justify-content: flex-start;
+ align-items: center;
+ min-height: 2.5rem;
+ margin-bottom: 0.5rem;
+ box-shadow: 4px 4px 6px rgba(0,0,0,0.5);
+}
+.tileList .row:last-child {
+ margin-bottom: 0;
+}
+.tileList .row:nth-child(odd) {
+ background: rgba(0,0,0,0.2);
+}
+.tileList .row:nth-child(even) {
+ background: rgba(255,255,255,0.2);
+}
+.tileList span {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ width: 100%;
+ white-space: nowrap;
+ padding: 0.25rem;
+ cursor: default;
+}
+.tileList .thumb {
+ width: 100%;
+ height: 2.5rem;
+ background-position: center center;
+ background-size: cover;
+ background-repeat: no-repeat;
+}
+.tileList .row.sortable-chosen {
+ background-color: #000;
+}
+.tileList .row.sortable-ghost {
+}
+.tileList .row.sortable-drag {
+ opacity: 0.6;
+}
+.tileList .row.link {
+ border: 1px solid #31f;
+ background: rgba(48,16,255,0.3);
+ box-shadow: inset 0 0 16px rgba(0,0,0,0.5);
+ padding-left: 0.375rem;
+}
+
+/* tile form */
+
+.row.imageUrl label {
+ width: 13rem;
+}
+.row.imageUrl .thumb {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 1.5rem;
+ width: 1.9475rem;
+ margin-right: 0.5rem;
+}
+.row.imageUrl img {
+ max-width: 100%;
+ max-height: 100%;
+}
+.box .row.buttons {
+ justify-content: space-between;
+}
+.box .row.pair {
+ justify-content: space-between;
+}
+.box .pair label:last-child {
+ margin-right: 0;
+}
+
+/* tile font form */
+
+.box .font {
+ justify-content: space-between;
+}
+.box .font input[type=number] {
+ width: 3rem;
+}
+.box .font .select {
+ width: 6rem;
+ padding: 0.25rem;
+ margin-right: 0;
+}
+.box .font label:last-child .select {
+ width: 3.75rem;
+}
+.box .font label {
+ flex-direction: row;
+ width: 6.5rem;
+ margin-right: 0.5rem;
+ min-width: auto;
+}
+.box .font label span {
+ display: none;
+}
+.box form .font label {
+}
+
+/* tile color form */
+
+.box form label.color span {
+ min-width: 4rem;
+ width: 4rem;
+}
+.box label.color {
+ display: flex;
+ flex-direction: row;
+}
+.box label.color input[type='color'] {
+ width: 2rem;
+ margin-right: 0.5rem;
+ padding: 0;
+}
+.box label.color input[type='text'] {
+ width: 6rem;
+ max-width: 6rem;
+}
diff --git a/frontend/app/views/page/page.reducer.js b/frontend/app/views/page/page.reducer.js
new file mode 100644
index 0000000..c2d231a
--- /dev/null
+++ b/frontend/app/views/page/page.reducer.js
@@ -0,0 +1,202 @@
+import * as types from 'app/types'
+// import { session, getDefault, getDefaultInt } from 'app/session'
+
+import { crudState, crudReducer } from 'app/api/crud.reducer'
+
+const initialState = crudState('page', {
+ editor: {
+ addingTile: false,
+ editingTile: false,
+ currentEditTileId: 0,
+ tileList: false,
+ },
+ options: {
+ }
+})
+
+const reducer = crudReducer('page')
+
+export default function pageReducer(state = initialState, action) {
+ // console.log(action.type, action)
+ state = reducer(state, action)
+ switch (action.type) {
+ case types.tile.create:
+ return {
+ ...state,
+ show: {
+ ...state.show,
+ res: {
+ ...state.show.res,
+ tiles: state.show.res.tiles.concat(action.data.res),
+ }
+ }
+ }
+
+ case types.page.update:
+ if (state.show.res && state.show.res.id === action.data.res.id) {
+ return {
+ ...state,
+ show: {
+ ...state.show,
+ res: {
+ ...action.data.res,
+ tiles: state.show.res.tiles,
+ }
+ }
+ }
+ }
+ return {
+ ...state,
+ show: {
+ ...state.show,
+ res: {
+ ...action.data.res,
+ }
+ }
+ }
+
+ case types.page.update_page_tile:
+ console.log(action.tile)
+ return {
+ ...state,
+ show: {
+ ...state.show,
+ res: {
+ ...state.show.res,
+ tiles: state.show.res.tiles.map(tile => {
+ if (tile.id === action.tile.id) {
+ return { ...action.tile }
+ } else {
+ return tile
+ }
+ }),
+ }
+ }
+ }
+
+ case types.tile.destroy:
+ return {
+ ...state,
+ show: {
+ ...state.show,
+ res: {
+ ...state.show.res,
+ tiles: state.show.res.tiles.filter(tile => tile.id !== action.data.id)
+ }
+ }
+ }
+
+ // add tile UI
+ case types.page.show_add_tile_form:
+ return {
+ ...state,
+ editor: {
+ ...state.editor,
+ addingTile: true,
+ editingTile: false,
+ tileList: false,
+ }
+ }
+
+ case types.page.hide_add_tile_form:
+ return {
+ ...state,
+ editor: {
+ ...state.editor,
+ addingTile: false,
+ }
+ }
+
+ case types.page.toggle_add_tile_form:
+ return {
+ ...state,
+ editor: {
+ ...state.editor,
+ addingTile: !state.editor.addingTile,
+ editingTile: false,
+ tileList: false,
+ }
+ }
+
+ // edit tile UI
+ case types.page.show_edit_tile_form:
+ return {
+ ...state,
+ editor: {
+ ...state.editor,
+ addingTile: false,
+ editingTile: true,
+ currentEditTileId: action.tile_id,
+ tileList: false,
+ }
+ }
+
+ case types.page.hide_edit_tile_form:
+ return {
+ ...state,
+ editor: {
+ ...state.editor,
+ editingTile: false,
+ }
+ }
+
+ // tile list UI
+ case types.page.show_tile_list:
+ return {
+ ...state,
+ editor: {
+ ...state.editor,
+ addingTile: false,
+ editingTile: false,
+ tileList: true,
+ }
+ }
+
+ case types.page.hide_tile_list:
+ return {
+ ...state,
+ editor: {
+ ...state.editor,
+ tileList: false,
+ }
+ }
+
+ case types.page.toggle_tile_list:
+ return {
+ ...state,
+ editor: {
+ ...state.editor,
+ addingTile: false,
+ editingTile: false,
+ tileList: !state.editor.tileList,
+ }
+ }
+
+ case types.graph.toggle_edit_page_form:
+ return {
+ ...state,
+ editor: {
+ ...state.editor,
+ addingTile: false,
+ editingTile: false,
+ tileList: false,
+ }
+ }
+
+ case types.page.set_tile_sort_order:
+ return {
+ ...state,
+ show: {
+ ...state.show,
+ res: {
+ ...state.res,
+ tiles: action.tiles,
+ }
+ }
+ }
+
+
+ default:
+ return state
+ }
+}
diff --git a/frontend/app/views/site/site.actions.js b/frontend/app/views/site/site.actions.js
new file mode 100644
index 0000000..9c66933
--- /dev/null
+++ b/frontend/app/views/site/site.actions.js
@@ -0,0 +1,6 @@
+import * as types from 'app/types'
+
+export const setSiteTitle = title => dispatch => {
+ document.querySelector('title').innerText = title
+ dispatch({ type: types.site.set_site_title, payload: title })
+}
diff --git a/frontend/app/views/site/site.reducer.js b/frontend/app/views/site/site.reducer.js
new file mode 100644
index 0000000..b40d6f1
--- /dev/null
+++ b/frontend/app/views/site/site.reducer.js
@@ -0,0 +1,19 @@
+import * as types from 'app/types'
+
+const initialState = {
+ 'siteTitle': 'swimmer',
+}
+
+export default function graphReducer(state = initialState, action) {
+ // console.log(action.type, action)
+ switch (action.type) {
+ case types.site.set_site_title:
+ return {
+ ...state,
+ siteTitle: action.payload,
+ }
+
+ default:
+ return state
+ }
+}
diff --git a/frontend/app/views/tile/tile.actions.js b/frontend/app/views/tile/tile.actions.js
new file mode 100644
index 0000000..e3be717
--- /dev/null
+++ b/frontend/app/views/tile/tile.actions.js
@@ -0,0 +1,9 @@
+import * as types from 'app/types'
+
+export const updateTemporaryTile = data => dispatch => {
+ dispatch({ type: types.tile.update_temporary_tile, data })
+}
+
+export const clearTemporaryTile = () => dispatch => {
+ dispatch({ type: types.tile.clear_temporary_tile })
+}
diff --git a/frontend/app/views/tile/tile.reducer.js b/frontend/app/views/tile/tile.reducer.js
new file mode 100644
index 0000000..d327a0c
--- /dev/null
+++ b/frontend/app/views/tile/tile.reducer.js
@@ -0,0 +1,31 @@
+import * as types from 'app/types'
+
+import { crudState, crudReducer } from 'app/api/crud.reducer'
+
+const initialState = crudState('tile', {
+ temporaryTile: null,
+ options: {},
+})
+
+const reducer = crudReducer('tile')
+
+export default function tileReducer(state = initialState, action) {
+ // console.log(action.type, action)
+ state = reducer(state, action)
+ switch (action.type) {
+ case types.tile.update_temporary_tile:
+ return {
+ ...state,
+ temporaryTile: action.data
+ }
+
+ case types.tile.clear_temporary_tile:
+ return {
+ ...state,
+ temporaryTile: null
+ }
+
+ default:
+ return state
+ }
+}
diff --git a/frontend/app/views/upload/components/upload.form.js b/frontend/app/views/upload/components/upload.form.js
new file mode 100644
index 0000000..e35bfaa
--- /dev/null
+++ b/frontend/app/views/upload/components/upload.form.js
@@ -0,0 +1,16 @@
+import React, { Component } from 'react'
+import { Link } from 'react-router-dom'
+
+import { MenuButton, FileInput } from 'app/common'
+
+export default class UploadForm extends Component {
+ render() {
+ return (
+ <div className='uploadForm'>
+ <MenuButton name="upload" label={false}>
+ <FileInput onChange={this.props.uploadActions.upload} />
+ </MenuButton>
+ </div>
+ )
+ }
+}
diff --git a/frontend/app/views/upload/components/upload.index.js b/frontend/app/views/upload/components/upload.index.js
new file mode 100644
index 0000000..00cedc2
--- /dev/null
+++ b/frontend/app/views/upload/components/upload.index.js
@@ -0,0 +1,104 @@
+import React, { Component } from 'react'
+import { Link } from 'react-router-dom'
+
+import { uploadUri, formatDateTime } from 'app/utils'
+import { SmallMenuButton, Loader } from 'app/common'
+import actions from 'app/actions'
+
+import UploadIndexOptions from './upload.indexOptions'
+import UploadMenu from './upload.menu'
+
+// const { result, collectionLookup } = this.props
+
+export default class UploadIndex extends Component {
+ componentDidMount() {
+ this.fetch(false)
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.upload.options.sort !== prevProps.upload.options.sort) {
+ this.fetch(false)
+ }
+ }
+
+ fetch(load_more) {
+ const { options, index } = this.props.upload
+ const { order: index_order } = index
+ const [ sort, order ] = options.sort.split('-')
+ actions.upload.index({
+ sort, order, limit: 50, offset: load_more ? index_order.length : 0,
+ }, load_more)
+ }
+
+ render() {
+ const { searchOptions, uploadActions } = this.props
+ const { options } = this.props.upload
+ const { loading, lookup, order } = this.props.upload.index
+ if (loading) {
+ return (
+ <section>
+ <UploadIndexOptions />
+ <div className="row">
+ {order && !!order.length &&
+ <div className={'results ' + searchOptions.thumbnailSize}>
+ {order.map(id => <UploadItem key={id} data={lookup[id]} />)}
+ </div>
+ }
+ </div>
+ <Loader />
+ </section>
+ )
+ }
+ if (!lookup || !order.length) {
+ return (
+ <section>
+ <UploadIndexOptions />
+ <div className="row">
+ <UploadMenu uploadActions={uploadActions} />
+ <p className='gray'>
+ {"No uploads"}
+ </p>
+ </div>
+ </section>
+ )
+ }
+ return (
+ <section>
+ <UploadIndexOptions />
+ <div className="row">
+ <UploadMenu uploadActions={uploadActions} />
+ <div className={'results ' + searchOptions.thumbnailSize}>
+ {order.map(id => <UploadItem key={id} data={lookup[id]} />)}
+ </div>
+ </div>
+ {order.length >= 50 && <button className='loadMore' onClick={() => this.fetch(true)}>Load More</button>}
+ </section>
+ )
+ }
+}
+
+const UploadItem = ({ data }) => {
+ // console.log(data)
+ const imageUri = uploadUri(data)
+ return (
+ <div className='cell'>
+ <div className='img'>
+ <Link to={"/upload/" + data.id + "/show/"}>
+ <img src={imageUri} alt={"Uploaded image"} />
+ </Link>
+ </div>
+ <div className='meta center'>
+ <div className='row'>
+ <SmallMenuButton name="search" href={"/search/upload/" + data.id + "/"} />
+ </div>
+ <div>
+ {data.username}
+ </div>
+ <div>
+ {formatDateTime(data.created_at)}
+ </div>
+ </div>
+ </div>
+ )
+}
+
diff --git a/frontend/app/views/upload/components/upload.indexOptions.js b/frontend/app/views/upload/components/upload.indexOptions.js
new file mode 100644
index 0000000..df266ef
--- /dev/null
+++ b/frontend/app/views/upload/components/upload.indexOptions.js
@@ -0,0 +1,62 @@
+import React, { Component } from 'react'
+import { Link } from 'react-router-dom'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+
+import actions from 'app/actions'
+
+import { Select, Checkbox } from 'app/common'
+
+const thumbnailOptions = [
+ { name: 'th', label: 'Thumbnails', },
+ { name: 'sm', label: 'Small', },
+ { name: 'md', label: 'Medium', },
+ { name: 'lg', label: 'Large', },
+ { name: 'orig', label: 'Original', },
+]
+
+const sortOptions = [
+ { name: 'id-asc', label: 'Most recent' },
+ { name: 'id-desc', label: 'Oldest first' },
+ { name: 'username-asc', label: 'Username (A-Z)' },
+ { name: 'username-desc', label: 'Username (Z-A)' },
+ // { name: '-asc', label: '' },
+ // { name: '-desc', label: '' },
+ // { name: '-asc', label: '' },
+ // { name: '-desc', label: '' },
+ // { name: '-asc', label: '' },
+ // { name: '-desc', label: '' },
+]
+
+class IndexOptions extends Component {
+ render() {
+ const { options, searchOptions } = this.props
+ return (
+ <div className='row menubar'>
+ <div />
+ <Select
+ name={'sort'}
+ options={sortOptions}
+ selected={options.sort}
+ onChange={actions.upload.updateOption}
+ />
+ <Select
+ name={'thumbnailSize'}
+ options={thumbnailOptions}
+ selected={searchOptions.thumbnailSize}
+ onChange={actions.search.updateOption}
+ />
+ </div>
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ options: state.upload.options,
+ searchOptions: state.search.options,
+})
+
+const mapDispatchToProps = dispatch => ({
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(IndexOptions)
diff --git a/frontend/app/views/upload/components/upload.menu.js b/frontend/app/views/upload/components/upload.menu.js
new file mode 100644
index 0000000..485d06f
--- /dev/null
+++ b/frontend/app/views/upload/components/upload.menu.js
@@ -0,0 +1,18 @@
+import React, { Component } from 'react'
+import { Link } from 'react-router-dom'
+
+import { MenuButton, FileInput } from 'app/common'
+
+import actions from 'app/actions'
+
+export default class UploadMenu extends Component {
+ render() {
+ return (
+ <div className='menuButtons'>
+ <MenuButton name="upload">
+ <FileInput onChange={this.props.uploadActions.upload} />
+ </MenuButton>
+ </div>
+ )
+ }
+}
diff --git a/frontend/app/views/upload/components/upload.show.js b/frontend/app/views/upload/components/upload.show.js
new file mode 100644
index 0000000..f63bc5f
--- /dev/null
+++ b/frontend/app/views/upload/components/upload.show.js
@@ -0,0 +1,70 @@
+import React, { Component } from 'react'
+import { Link } from 'react-router-dom'
+import { connect } from 'react-redux'
+
+import actions from 'app/actions'
+import { formatDate, formatTime, formatAge, uploadUri } from 'app/utils'
+import { history } from 'app/store'
+import { Loader, MenuButton } from 'app/common'
+
+class UploadShow extends Component {
+ componentDidMount() {
+ actions.upload.show(this.props.match.params.id)
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.match.params.id !== this.props.match.params.id) {
+ actions.upload.show(this.props.match.params.id)
+ }
+ }
+
+ handleDestroy() {
+ const { res: data } = this.props.upload.show
+ if (confirm("Really delete this upload?")) {
+ actions.upload.destroy(data).then(() => {
+ history.push('/upload/')
+ })
+ }
+ }
+
+ render() {
+ const { show, destroy } = this.props.upload
+ if (show.loading || destroy.loading) {
+ return <Loader />
+ }
+ if (!show.loading && !show.res || show.not_found) {
+ return <div className='gray'>Upload {this.props.match.params.id} not found</div>
+ }
+ const { res: data } = show
+ return (
+ <section className="row uploadShow">
+ <div className="menuButtons">
+ <MenuButton name="delete" onClick={this.handleDestroy.bind(this)} />
+ <MenuButton name="search" href={'/search/upload/' + data.id + '/'} />
+ </div>
+ <div>
+ <img src={uploadUri(data)} />
+ <div className='byline'>
+ {'Uploaded by '}
+ {data.username}
+ {' on '}
+ {formatDate(data.created_at)}
+ {' at '}
+ {formatTime(data.created_at)}
+ {'. '}
+ </div>
+ </div>
+ </section>
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ upload: state.upload,
+})
+
+const mapDispatchToProps = dispatch => ({
+ // searchActions: bindActionCreators({ ...searchActions }, dispatch),
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(UploadShow)
diff --git a/frontend/app/views/upload/upload.actions.js b/frontend/app/views/upload/upload.actions.js
new file mode 100644
index 0000000..8739bf7
--- /dev/null
+++ b/frontend/app/views/upload/upload.actions.js
@@ -0,0 +1,14 @@
+import actions from 'app/actions'
+import { session } from 'app/session'
+
+export const upload = file => dispatch => {
+ const formData = {
+ 'image': file,
+ 'username': session('username'),
+ }
+ // console.log(formData)
+ return actions.upload.upload(formData).then(data => {
+ // console.log(data.res)
+ return data.res
+ })
+}
diff --git a/frontend/app/views/upload/upload.container.js b/frontend/app/views/upload/upload.container.js
new file mode 100644
index 0000000..608f01a
--- /dev/null
+++ b/frontend/app/views/upload/upload.container.js
@@ -0,0 +1,35 @@
+import React, { Component } from 'react'
+import { Route, Link } from 'react-router-dom'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+
+import './upload.css'
+
+import * as uploadActions from './upload.actions'
+
+import UploadIndex from './components/upload.index'
+import UploadShow from './components/upload.show'
+
+class Container extends Component {
+ render() {
+ return (
+ <div className='row upload'>
+ <div>
+ <Route exact path='/upload/:id/show/' component={UploadShow} />
+ <UploadIndex {...this.props} />
+ </div>
+ </div>
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ upload: state.upload,
+ searchOptions: state.search.options,
+})
+
+const mapDispatchToProps = dispatch => ({
+ uploadActions: bindActionCreators({ ...uploadActions }, dispatch),
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(Container)
diff --git a/frontend/app/views/upload/upload.css b/frontend/app/views/upload/upload.css
new file mode 100644
index 0000000..8d77754
--- /dev/null
+++ b/frontend/app/views/upload/upload.css
@@ -0,0 +1,10 @@
+.uploadShow img {
+ max-width: 30rem;
+ max-height: 20rem;
+}
+.upload {
+ height: 100%;
+}
+.upload > div:last-child {
+ flex: 1;
+} \ No newline at end of file
diff --git a/frontend/app/views/upload/upload.reducer.js b/frontend/app/views/upload/upload.reducer.js
new file mode 100644
index 0000000..1f39f6e
--- /dev/null
+++ b/frontend/app/views/upload/upload.reducer.js
@@ -0,0 +1,21 @@
+import * as types from 'app/types'
+import { session, getDefault, getDefaultInt } from 'app/session'
+
+import { crudState, crudReducer } from 'app/api/crud.reducer'
+
+const initialState = crudState('upload', {
+ options: {
+ sort: getDefault('upload.sort', 'id-desc'),
+ }
+})
+
+const reducer = crudReducer('upload')
+
+export default function uploadReducer(state = initialState, action) {
+ // console.log(action.type, action)
+ state = reducer(state, action)
+ switch (action.type) {
+ default:
+ return state
+ }
+}