Merge react frontend and api to create a monorepo
This commit is contained in:
commit
955966e56e
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
|
@ -0,0 +1,24 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.eslintcache
|
70
frontend/README.md
Normal file
70
frontend/README.md
Normal file
|
@ -0,0 +1,70 @@
|
|||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `yarn start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.\
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `yarn test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `yarn build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `yarn eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||
|
||||
### `yarn build` fails to minify
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
16905
frontend/package-lock.json
generated
Normal file
16905
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
63
frontend/package.json
Normal file
63
frontend/package.json
Normal file
|
@ -0,0 +1,63 @@
|
|||
{
|
||||
"name": "react-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^5.11.4",
|
||||
"@testing-library/react": "^11.1.0",
|
||||
"@testing-library/user-event": "^12.1.10",
|
||||
"@types/jest": "^26.0.20",
|
||||
"@types/node": "^14.14.25",
|
||||
"@types/react": "^17.0.1",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"luxon": "^1.25.0",
|
||||
"node-sass": "^4.14.1",
|
||||
"ol": "^6.5.0",
|
||||
"proj4": "^2.7.0",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-markdown": "^5.0.3",
|
||||
"react-redux": "^7.2.2",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-scripts": "4.0.1",
|
||||
"redux": "^4.0.5",
|
||||
"redux-localstorage": "^0.4.1",
|
||||
"rxjs": "^6.6.3",
|
||||
"rxjs-hooks": "^0.6.2",
|
||||
"semantic-ui-css": "^2.4.1",
|
||||
"semantic-ui-react": "^2.0.2",
|
||||
"typescript": "^4.1.4",
|
||||
"web-vitals": "^0.2.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "PORT=3001 react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"proxy": "http://localhost:3000",
|
||||
"port": 3001,
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.14.168",
|
||||
"@types/react-redux": "^7.1.16",
|
||||
"@types/react-router-dom": "^5.1.7"
|
||||
}
|
||||
}
|
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.8 KiB |
43
frontend/public/index.html
Normal file
43
frontend/public/index.html
Normal file
|
@ -0,0 +1,43 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>React App</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
BIN
frontend/public/logo192.png
Normal file
BIN
frontend/public/logo192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.2 KiB |
BIN
frontend/public/logo512.png
Normal file
BIN
frontend/public/logo512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.4 KiB |
25
frontend/public/manifest.json
Normal file
25
frontend/public/manifest.json
Normal file
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
3
frontend/public/robots.txt
Normal file
3
frontend/public/robots.txt
Normal file
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
107
frontend/src/App.js
Normal file
107
frontend/src/App.js
Normal file
|
@ -0,0 +1,107 @@
|
|||
import React from 'react'
|
||||
import {connect} from 'react-redux'
|
||||
import {Icon, Button} from 'semantic-ui-react'
|
||||
import {BrowserRouter as Router, Switch, Route, Link} from 'react-router-dom'
|
||||
import _ from 'lodash'
|
||||
|
||||
import styles from './App.module.scss'
|
||||
import api from './api'
|
||||
|
||||
import {LoginPage, LogoutPage, NotFoundPage, TracksPage, TrackPage, HomePage, UploadPage} from './pages'
|
||||
|
||||
const App = connect((state) => ({login: state.login}))(function App({login}) {
|
||||
// update the API header on each render, the App is rerendered when the login changes
|
||||
if (login) {
|
||||
api.setAuthorizationHeader('Token ' + login.token)
|
||||
} else {
|
||||
api.setAuthorizationHeader(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<div className={styles.App}>
|
||||
<header className={styles.headline}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.pageTitle}>OpenBikeSensor</div>
|
||||
<nav className={styles.menu}>
|
||||
<ul>
|
||||
{login && (
|
||||
<li>
|
||||
<Link to="/upload">
|
||||
<Button compact color="green">
|
||||
<Icon name="cloud upload" />
|
||||
Upload
|
||||
</Button>
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<Link to="/">Home</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/feed">Feed</Link>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://openbikesensor.org/" target="_blank">
|
||||
About
|
||||
</a>
|
||||
</li>
|
||||
{login ? (
|
||||
<>
|
||||
<li>
|
||||
<Link to="/settings">Settings</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Button as={Link} to="/logout" compact>
|
||||
Logout
|
||||
</Button>
|
||||
</li>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<li>
|
||||
<Button as={Link} to="/login">
|
||||
Login
|
||||
</Button>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Switch>
|
||||
<Route path="/" exact>
|
||||
<HomePage />
|
||||
</Route>
|
||||
<Route path="/feed" exact>
|
||||
<TracksPage />
|
||||
</Route>
|
||||
<Route path="/feed/my" exact>
|
||||
<TracksPage privateFeed />
|
||||
</Route>
|
||||
<Route path={`/tracks/:slug`} exact>
|
||||
<TrackPage />
|
||||
</Route>
|
||||
<Route path="/login" exact>
|
||||
<LoginPage />
|
||||
</Route>
|
||||
<Route path="/logout" exact>
|
||||
<LogoutPage />
|
||||
</Route>
|
||||
{login && (
|
||||
<Route path="/upload" exact>
|
||||
<UploadPage />
|
||||
</Route>
|
||||
)}
|
||||
<Route>
|
||||
<NotFoundPage />
|
||||
</Route>
|
||||
</Switch>
|
||||
</div>
|
||||
</Router>
|
||||
)
|
||||
})
|
||||
|
||||
export default App
|
59
frontend/src/App.module.scss
Normal file
59
frontend/src/App.module.scss
Normal file
|
@ -0,0 +1,59 @@
|
|||
@import 'styles.scss';
|
||||
|
||||
.App {
|
||||
padding-top: 60px;
|
||||
}
|
||||
|
||||
.header {
|
||||
@include container;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.headline {
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
position: fixed;
|
||||
// border-bottom: 1px solid #E0E0E4;
|
||||
background: white;
|
||||
z-index: 100;
|
||||
box-shadow: 0 0 10px -6px black;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-family: 'Roboto Slab';
|
||||
font-weight: 600;
|
||||
font-size: 18pt;
|
||||
}
|
||||
|
||||
.menu {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
flex: 1 0 auto;
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: baseline;
|
||||
|
||||
li {
|
||||
padding: 1rem;
|
||||
display: block;
|
||||
|
||||
a {
|
||||
color: #877;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
53
frontend/src/api.js
Normal file
53
frontend/src/api.js
Normal file
|
@ -0,0 +1,53 @@
|
|||
import {stringifyParams} from 'query'
|
||||
|
||||
class API {
|
||||
setAuthorizationHeader(authorization) {
|
||||
this.authorization = authorization
|
||||
}
|
||||
|
||||
async fetch(url, options = {}) {
|
||||
const response = await window.fetch('/api' + url, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.headers || {}),
|
||||
Authorization: this.authorization,
|
||||
},
|
||||
})
|
||||
|
||||
if (response.status === 200) {
|
||||
return await response.json()
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async post(url, {body: body_, ...options}) {
|
||||
let body = body_
|
||||
let headers = {...(options.headers || {})}
|
||||
|
||||
if (!(typeof body === 'string' || body instanceof FormData)) {
|
||||
body = JSON.stringify(body)
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
return await this.fetch(url, {
|
||||
...options,
|
||||
body,
|
||||
method: 'post',
|
||||
headers
|
||||
})
|
||||
}
|
||||
|
||||
async get(url, {query, ...options} = {}) {
|
||||
const queryString = query ? stringifyParams(query) : null
|
||||
return await this.fetch(url + (queryString ? '?' + queryString : ''), {method: 'get', ...options})
|
||||
}
|
||||
|
||||
async delete(url, options = {}) {
|
||||
return await this.get(url, {...options, method: 'delete'})
|
||||
}
|
||||
}
|
||||
|
||||
const api = new API()
|
||||
|
||||
export default api
|
206
frontend/src/components/FileDrop.tsx
Normal file
206
frontend/src/components/FileDrop.tsx
Normal file
|
@ -0,0 +1,206 @@
|
|||
// Source: https://github.com/sarink/react-file-drop/blob/master/file-drop/src/FileDrop.tsx
|
||||
// Original License: MIT
|
||||
// Adjusted to render prop instead of rendering div directly.
|
||||
|
||||
import PropTypes from 'prop-types'
|
||||
import React, {DragEvent as ReactDragEvent, DragEventHandler as ReactDragEventHandler, ReactEventHandler} from 'react'
|
||||
|
||||
export type DropEffects = 'copy' | 'move' | 'link' | 'none'
|
||||
|
||||
export interface FileDropProps {
|
||||
frame?: Exclude<HTMLElementTagNameMap[keyof HTMLElementTagNameMap], HTMLElement> | HTMLDocument
|
||||
onFrameDragEnter?: (event: DragEvent) => void
|
||||
onFrameDragLeave?: (event: DragEvent) => void
|
||||
onFrameDrop?: (event: DragEvent) => void
|
||||
onDragOver?: ReactDragEventHandler<HTMLDivElement>
|
||||
onDragLeave?: ReactDragEventHandler<HTMLDivElement>
|
||||
onDrop?: (files: FileList | null, event: ReactDragEvent<HTMLDivElement>) => any
|
||||
onTargetClick?: ReactEventHandler<HTMLDivElement>
|
||||
dropEffect?: DropEffects
|
||||
children: (props: ChildrenProps) => React.ReactNode
|
||||
}
|
||||
|
||||
export interface FileDropState {
|
||||
draggingOverFrame: boolean
|
||||
draggingOverTarget: boolean
|
||||
}
|
||||
|
||||
export interface ChildrenProps extends FileDropState {
|
||||
onDragOver?: ReactDragEventHandler<HTMLDivElement>
|
||||
onDragLeave: ReactDragEventHandler<HTMLDivElement>
|
||||
onDrop: ReactDragEventHandler<HTMLDivElement>
|
||||
onClick: ReactEventHandler<HTMLDivElement>
|
||||
}
|
||||
|
||||
export default class FileDrop extends React.PureComponent<FileDropProps, FileDropState> {
|
||||
static isIE = () =>
|
||||
typeof window !== 'undefined' &&
|
||||
(window.navigator.userAgent.indexOf('MSIE') !== -1 || window.navigator.appVersion.indexOf('Trident/') > 0)
|
||||
|
||||
static eventHasFiles = (event: DragEvent | ReactDragEvent<HTMLElement>) => {
|
||||
// In most browsers this is an array, but in IE11 it's an Object :(
|
||||
let hasFiles = false
|
||||
if (event.dataTransfer) {
|
||||
const types = event.dataTransfer.types
|
||||
for (const keyOrIndex in types) {
|
||||
if (types[keyOrIndex] === 'Files') {
|
||||
hasFiles = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return hasFiles
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
onDragOver: PropTypes.func,
|
||||
onDragLeave: PropTypes.func,
|
||||
onDrop: PropTypes.func,
|
||||
onTargetClick: PropTypes.func,
|
||||
dropEffect: PropTypes.oneOf(['copy', 'move', 'link', 'none']),
|
||||
frame: (props: FileDropProps, propName: keyof FileDropProps, componentName: string) => {
|
||||
const prop = props[propName]
|
||||
if (prop == null) {
|
||||
return new Error('Warning: Required prop `' + propName + '` was not specified in `' + componentName + '`')
|
||||
}
|
||||
if (prop !== document && !(prop instanceof HTMLElement)) {
|
||||
return new Error('Warning: Prop `' + propName + '` must be one of the following: document, HTMLElement!')
|
||||
}
|
||||
},
|
||||
onFrameDragEnter: PropTypes.func,
|
||||
onFrameDragLeave: PropTypes.func,
|
||||
onFrameDrop: PropTypes.func,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
dropEffect: 'copy' as DropEffects,
|
||||
frame: typeof window === 'undefined' ? undefined : window.document,
|
||||
}
|
||||
|
||||
constructor(props: FileDropProps) {
|
||||
super(props)
|
||||
this.frameDragCounter = 0
|
||||
this.state = {draggingOverFrame: false, draggingOverTarget: false}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.startFrameListeners(this.props.frame)
|
||||
this.resetDragging()
|
||||
window.addEventListener('dragover', this.handleWindowDragOverOrDrop)
|
||||
window.addEventListener('drop', this.handleWindowDragOverOrDrop)
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps: FileDropProps) {
|
||||
if (nextProps.frame !== this.props.frame) {
|
||||
this.resetDragging()
|
||||
this.stopFrameListeners(this.props.frame)
|
||||
this.startFrameListeners(nextProps.frame)
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.stopFrameListeners(this.props.frame)
|
||||
window.removeEventListener('dragover', this.handleWindowDragOverOrDrop)
|
||||
window.removeEventListener('drop', this.handleWindowDragOverOrDrop)
|
||||
}
|
||||
|
||||
frameDragCounter: number
|
||||
|
||||
resetDragging = () => {
|
||||
this.frameDragCounter = 0
|
||||
this.setState({draggingOverFrame: false, draggingOverTarget: false})
|
||||
}
|
||||
|
||||
handleWindowDragOverOrDrop = (event: DragEvent) => {
|
||||
// This prevents the browser from trying to load whatever file the user dropped on the window
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
handleFrameDrag = (event: DragEvent) => {
|
||||
// Only allow dragging of files
|
||||
if (!FileDrop.eventHasFiles(event)) return
|
||||
|
||||
// We are listening for events on the 'frame', so every time the user drags over any element in the frame's tree,
|
||||
// the event bubbles up to the frame. By keeping count of how many "dragenters" we get, we can tell if they are still
|
||||
// "draggingOverFrame" (b/c you get one "dragenter" initially, and one "dragenter"/one "dragleave" for every bubble)
|
||||
// This is far better than a "dragover" handler, which would be calling `setState` continuously.
|
||||
this.frameDragCounter += event.type === 'dragenter' ? 1 : -1
|
||||
|
||||
if (this.frameDragCounter === 1) {
|
||||
this.setState({draggingOverFrame: true})
|
||||
if (this.props.onFrameDragEnter) this.props.onFrameDragEnter(event)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.frameDragCounter === 0) {
|
||||
this.setState({draggingOverFrame: false})
|
||||
if (this.props.onFrameDragLeave) this.props.onFrameDragLeave(event)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
handleFrameDrop = (event: DragEvent) => {
|
||||
if (!this.state.draggingOverTarget) {
|
||||
this.resetDragging()
|
||||
if (this.props.onFrameDrop) this.props.onFrameDrop(event)
|
||||
}
|
||||
}
|
||||
|
||||
handleDragOver: ReactDragEventHandler<HTMLDivElement> = (event) => {
|
||||
if (FileDrop.eventHasFiles(event)) {
|
||||
this.setState({draggingOverTarget: true})
|
||||
if (!FileDrop.isIE() && this.props.dropEffect) event.dataTransfer.dropEffect = this.props.dropEffect
|
||||
if (this.props.onDragOver) this.props.onDragOver(event)
|
||||
}
|
||||
}
|
||||
|
||||
handleDragLeave: ReactDragEventHandler<HTMLDivElement> = (event) => {
|
||||
this.setState({draggingOverTarget: false})
|
||||
if (this.props.onDragLeave) this.props.onDragLeave(event)
|
||||
}
|
||||
|
||||
handleDrop: ReactDragEventHandler<HTMLDivElement> = (event) => {
|
||||
if (this.props.onDrop && FileDrop.eventHasFiles(event)) {
|
||||
const files = event.dataTransfer ? event.dataTransfer.files : null
|
||||
this.props.onDrop(files, event)
|
||||
}
|
||||
this.resetDragging()
|
||||
}
|
||||
|
||||
handleTargetClick: ReactEventHandler<HTMLDivElement> = (event) => {
|
||||
if (this.props.onTargetClick) {
|
||||
this.props.onTargetClick(event)
|
||||
}
|
||||
this.resetDragging()
|
||||
}
|
||||
|
||||
stopFrameListeners = (frame: FileDropProps['frame']) => {
|
||||
if (frame) {
|
||||
frame.removeEventListener('dragenter', this.handleFrameDrag)
|
||||
frame.removeEventListener('dragleave', this.handleFrameDrag)
|
||||
frame.removeEventListener('drop', this.handleFrameDrop)
|
||||
}
|
||||
}
|
||||
|
||||
startFrameListeners = (frame: FileDropProps['frame']) => {
|
||||
if (frame) {
|
||||
frame.addEventListener('dragenter', this.handleFrameDrag)
|
||||
frame.addEventListener('dragleave', this.handleFrameDrag)
|
||||
frame.addEventListener('drop', this.handleFrameDrop)
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {children} = this.props
|
||||
const {draggingOverTarget, draggingOverFrame} = this.state
|
||||
|
||||
return children({
|
||||
draggingOverFrame,
|
||||
draggingOverTarget,
|
||||
onDragOver: this.handleDragOver,
|
||||
onDragLeave: this.handleDragLeave,
|
||||
onDrop: this.handleDrop,
|
||||
onClick: this.handleTargetClick,
|
||||
})
|
||||
}
|
||||
}
|
20
frontend/src/components/FormattedDate.tsx
Normal file
20
frontend/src/components/FormattedDate.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import {DateTime} from 'luxon'
|
||||
|
||||
export default function FormattedDate({date, relative = false}) {
|
||||
if (date == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const dateTime =
|
||||
typeof date === 'string' ? DateTime.fromISO(date) : date instanceof Date ? DateTime.fromJSDate(date) : date
|
||||
|
||||
let str
|
||||
|
||||
if (relative) {
|
||||
str = dateTime.toRelative()
|
||||
} else {
|
||||
str = dateTime.toLocaleString(DateTime.DATETIME_MED)
|
||||
}
|
||||
|
||||
return <span title={dateTime.toISO()}>{str}</span>
|
||||
}
|
58
frontend/src/components/LoginForm.js
Normal file
58
frontend/src/components/LoginForm.js
Normal file
|
@ -0,0 +1,58 @@
|
|||
import React from 'react'
|
||||
import {connect} from 'react-redux'
|
||||
import {Form, Button} from 'semantic-ui-react'
|
||||
|
||||
import {login as loginAction} from '../reducers/login'
|
||||
|
||||
async function fetchLogin(email, password) {
|
||||
const response = await window.fetch('/api/users/login', {
|
||||
body: JSON.stringify({user: {email, password}}),
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.user) {
|
||||
return result.user
|
||||
} else {
|
||||
throw new Error('invalid credentials')
|
||||
}
|
||||
}
|
||||
|
||||
const LoginForm = connect(
|
||||
(state) => ({loggedIn: Boolean(state.login)}),
|
||||
(dispatch) => ({
|
||||
dispatchLogin: (user) => dispatch(loginAction(user)),
|
||||
})
|
||||
)(function LoginForm({loggedIn, dispatchLogin, className}) {
|
||||
const [email, setEmail] = React.useState('')
|
||||
const [password, setPassword] = React.useState('')
|
||||
const onChangeEmail = React.useCallback((e) => setEmail(e.target.value), [])
|
||||
const onChangePassword = React.useCallback((e) => setPassword(e.target.value), [])
|
||||
|
||||
const onSubmit = React.useCallback(() => fetchLogin(email, password).then(dispatchLogin), [
|
||||
email,
|
||||
password,
|
||||
dispatchLogin,
|
||||
])
|
||||
|
||||
return loggedIn ? null : (
|
||||
<Form className={className} onSubmit={onSubmit}>
|
||||
<Form.Field>
|
||||
<label>e-Mail</label>
|
||||
<input value={email} onChange={onChangeEmail} />
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>Password</label>
|
||||
<input type="password" value={password} onChange={onChangePassword} />
|
||||
</Form.Field>
|
||||
<Button type="submit">Submit</Button>
|
||||
</Form>
|
||||
)
|
||||
})
|
||||
|
||||
export default LoginForm
|
132
frontend/src/components/Map/index.js
Normal file
132
frontend/src/components/Map/index.js
Normal file
|
@ -0,0 +1,132 @@
|
|||
import React from 'react'
|
||||
|
||||
import OlMap from 'ol/Map'
|
||||
import OlView from 'ol/View'
|
||||
import OlTileLayer from 'ol/layer/Tile'
|
||||
import OlVectorLayer from 'ol/layer/Vector'
|
||||
import OlGroupLayer from 'ol/layer/Group'
|
||||
import OSM from 'ol/source/OSM'
|
||||
import proj4 from 'proj4'
|
||||
import {register} from 'ol/proj/proj4'
|
||||
|
||||
// Import styles for open layers + addons
|
||||
import 'ol/ol.css'
|
||||
|
||||
// Prepare projection
|
||||
proj4.defs(
|
||||
'projLayer1',
|
||||
'+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs'
|
||||
)
|
||||
register(proj4)
|
||||
|
||||
const MapContext = React.createContext()
|
||||
const MapLayerContext = React.createContext()
|
||||
|
||||
export function Map({children, ...props}) {
|
||||
const ref = React.useRef()
|
||||
|
||||
const [map, setMap] = React.useState(null)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const map = new OlMap({target: ref.current})
|
||||
|
||||
setMap(map)
|
||||
|
||||
return () => {
|
||||
map.setTarget(null)
|
||||
setMap(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={ref} {...props}>
|
||||
{map && (
|
||||
<MapContext.Provider value={map}>
|
||||
<MapLayerContext.Provider value={map.getLayers()}>{children}</MapLayerContext.Provider>
|
||||
</MapContext.Provider>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Layer({layerClass, getDefaultOptions, children, ...props}) {
|
||||
const context = React.useContext(MapLayerContext)
|
||||
|
||||
const layer = React.useMemo(
|
||||
() =>
|
||||
new layerClass({
|
||||
...(getDefaultOptions ? getDefaultOptions() : {}),
|
||||
...props,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
for (const [k, v] of Object.entries(props)) {
|
||||
layer.set(k, v)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
context?.push(layer)
|
||||
return () => context?.remove(layer)
|
||||
}, [layer, context])
|
||||
|
||||
if (typeof layer.getLayers === 'function') {
|
||||
return <MapLayerContext.Provider value={layer.getLayers()}>{children}</MapLayerContext.Provider>
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function TileLayer(props) {
|
||||
return <Layer layerClass={OlTileLayer} getDefaultOptions={() => ({source: new OSM()})} {...props} />
|
||||
}
|
||||
|
||||
export function VectorLayer(props) {
|
||||
return <Layer layerClass={OlVectorLayer} {...props} />
|
||||
}
|
||||
|
||||
export function GroupLayer(props) {
|
||||
return <Layer layerClass={OlGroupLayer} {...props} />
|
||||
}
|
||||
|
||||
function FitView({extent}) {
|
||||
const map = React.useContext(MapContext)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (extent && map) {
|
||||
map.getView().fit(extent)
|
||||
}
|
||||
}, [extent, map])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function View({...options}) {
|
||||
const map = React.useContext(MapContext)
|
||||
|
||||
const view = React.useMemo(
|
||||
() =>
|
||||
new OlView({
|
||||
...options,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (view && map) {
|
||||
map.setView(view)
|
||||
}
|
||||
}, [view, map])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
Map.FitView = FitView
|
||||
Map.GroupLayer = GroupLayer
|
||||
Map.TileLayer = TileLayer
|
||||
Map.VectorLayer = VectorLayer
|
||||
Map.View = View
|
||||
|
||||
export default Map
|
6
frontend/src/components/Page/Page.module.scss
Normal file
6
frontend/src/components/Page/Page.module.scss
Normal file
|
@ -0,0 +1,6 @@
|
|||
@import '../../styles.scss';
|
||||
|
||||
.page {
|
||||
@include container;
|
||||
margin-top: 32px;
|
||||
}
|
6
frontend/src/components/Page/index.js
Normal file
6
frontend/src/components/Page/index.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
import React from 'react'
|
||||
import styles from './Page.module.scss'
|
||||
|
||||
export default function Page({children}) {
|
||||
return <main className={styles.page}>{children}</main>
|
||||
}
|
39
frontend/src/components/StripMarkdown.tsx
Normal file
39
frontend/src/components/StripMarkdown.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
import React from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
|
||||
const _noop = ({children}) => <>{children}</>
|
||||
const _space = () => <> </>
|
||||
const _spaced = ({children}) => (
|
||||
<>
|
||||
{children.map((child, i) => (
|
||||
<React.Fragment key={i}>{child} </React.Fragment>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
const stripMarkdownNodes = {
|
||||
root: _noop,
|
||||
text: _noop,
|
||||
break: _space,
|
||||
paragraph: _spaced,
|
||||
emphasis: _noop,
|
||||
strong: _noop,
|
||||
thematicBreak: _space,
|
||||
blockquote: _noop,
|
||||
link: _noop,
|
||||
list: _spaced,
|
||||
listItem: _noop,
|
||||
definition: _noop,
|
||||
heading: _noop,
|
||||
inlineCode: _noop,
|
||||
code: _noop,
|
||||
}
|
||||
const stripTypes = Array.from(Object.keys(stripMarkdownNodes))
|
||||
|
||||
export default function StripMarkdown({children}) {
|
||||
return (
|
||||
<Markdown allowedTypes={stripTypes} renderers={stripMarkdownNodes}>
|
||||
{children}
|
||||
</Markdown>
|
||||
)
|
||||
}
|
6
frontend/src/components/index.js
Normal file
6
frontend/src/components/index.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
export {default as FileDrop} from './FileDrop'
|
||||
export {default as FormattedDate} from './FormattedDate'
|
||||
export {default as LoginForm} from './LoginForm'
|
||||
export {default as Map} from './Map'
|
||||
export {default as Page} from './Page'
|
||||
export {default as StripMarkdown} from './StripMarkdown'
|
11
frontend/src/index.css
Normal file
11
frontend/src/index.css
Normal file
|
@ -0,0 +1,11 @@
|
|||
body {
|
||||
margin: 0;
|
||||
font-family: 'Noto Sans', 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Oxygen', 'Ubuntu', 'Cantarell',
|
||||
'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Noto Sans Mono', source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
}
|
26
frontend/src/index.js
Normal file
26
frontend/src/index.js
Normal file
|
@ -0,0 +1,26 @@
|
|||
import React from 'react'
|
||||
import {Settings} from 'luxon'
|
||||
import ReactDOM from 'react-dom'
|
||||
import 'semantic-ui-css/semantic.min.css'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
import {Provider} from 'react-redux'
|
||||
import {compose, createStore} from 'redux'
|
||||
import persistState from 'redux-localstorage'
|
||||
|
||||
import rootReducer from './reducers'
|
||||
|
||||
const enhancer = compose(persistState(['login']))
|
||||
|
||||
const store = createStore(rootReducer, undefined, enhancer)
|
||||
|
||||
// TODO: remove
|
||||
Settings.defaultLocale = 'de-DE'
|
||||
|
||||
ReactDOM.render(
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>,
|
||||
document.getElementById('root')
|
||||
)
|
129
frontend/src/pages/HomePage.js
Normal file
129
frontend/src/pages/HomePage.js
Normal file
|
@ -0,0 +1,129 @@
|
|||
import _ from 'lodash'
|
||||
import React from 'react'
|
||||
import {connect} from 'react-redux'
|
||||
import {Message, Grid, Loader, Statistic, Segment, Header, Item} from 'semantic-ui-react'
|
||||
import {useObservable} from 'rxjs-hooks'
|
||||
import {of, pipe, from} from 'rxjs'
|
||||
import {map, switchMap, distinctUntilChanged} from 'rxjs/operators'
|
||||
import {Duration} from 'luxon'
|
||||
|
||||
import api from '../api'
|
||||
import {Map, Page, LoginForm} from '../components'
|
||||
|
||||
import {TrackListItem} from './TracksPage'
|
||||
|
||||
function formatDuration(seconds) {
|
||||
return Duration.fromMillis((seconds ?? 0) * 1000)
|
||||
.as('hours')
|
||||
.toFixed(1)
|
||||
}
|
||||
|
||||
function WelcomeMap() {
|
||||
return (
|
||||
<Map style={{height: '24rem'}}>
|
||||
<Map.TileLayer />
|
||||
</Map>
|
||||
)
|
||||
}
|
||||
|
||||
function Stats() {
|
||||
const stats = useObservable(
|
||||
pipe(
|
||||
distinctUntilChanged(_.isEqual),
|
||||
switchMap(() => api.fetch('/stats'))
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header as="h2">Statistics</Header>
|
||||
|
||||
<Segment>
|
||||
<Loader active={stats == null} />
|
||||
|
||||
<Statistic.Group widths={4} size="tiny">
|
||||
<Statistic>
|
||||
<Statistic.Value>{Number(stats?.publicTrackLength / 1000).toFixed(1)}</Statistic.Value>
|
||||
<Statistic.Label>km track length</Statistic.Label>
|
||||
</Statistic>
|
||||
<Statistic>
|
||||
<Statistic.Value>{formatDuration(stats?.trackDuration)}</Statistic.Value>
|
||||
<Statistic.Label>hrs recorded</Statistic.Label>
|
||||
</Statistic>
|
||||
<Statistic>
|
||||
<Statistic.Value>{stats?.numEvents}</Statistic.Value>
|
||||
<Statistic.Label>events</Statistic.Label>
|
||||
</Statistic>
|
||||
<Statistic>
|
||||
<Statistic.Value>{stats?.userCount}</Statistic.Value>
|
||||
<Statistic.Label>members</Statistic.Label>
|
||||
</Statistic>
|
||||
</Statistic.Group>
|
||||
</Segment>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const LoginState = connect((state) => ({login: state.login}))(function LoginState({login}) {
|
||||
return login ? (
|
||||
<>
|
||||
<Header as="h2">Logged in as {login.username} </Header>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Header as="h2">Login</Header>
|
||||
<LoginForm />
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
function MostRecentTrack() {
|
||||
const track: Track | null = useObservable(
|
||||
() =>
|
||||
of(null).pipe(
|
||||
switchMap(() => from(api.fetch('/tracks?limit=1'))),
|
||||
map(({tracks}) => tracks[0])
|
||||
),
|
||||
null,
|
||||
[]
|
||||
)
|
||||
|
||||
console.log(track)
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Most recent track</h2>
|
||||
<Loader active={track === null} />
|
||||
{track === undefined ? (
|
||||
<Message>No track uploaded yet. Be the first!</Message>
|
||||
) : track ? (
|
||||
<Item.Group>
|
||||
<TrackListItem track={track} />
|
||||
</Item.Group>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<Page>
|
||||
<Grid>
|
||||
<Grid.Row>
|
||||
<Grid.Column width={16}>
|
||||
<WelcomeMap />
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
<Grid.Row>
|
||||
<Grid.Column width={10}>
|
||||
<Stats />
|
||||
<MostRecentTrack />
|
||||
</Grid.Column>
|
||||
<Grid.Column width={6}>
|
||||
<LoginState />
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
</Grid>
|
||||
</Page>
|
||||
)
|
||||
}
|
19
frontend/src/pages/LoginPage.js
Normal file
19
frontend/src/pages/LoginPage.js
Normal file
|
@ -0,0 +1,19 @@
|
|||
import React from 'react'
|
||||
import {connect} from 'react-redux'
|
||||
import {Redirect} from 'react-router-dom'
|
||||
|
||||
import styles from './LoginPage.module.scss'
|
||||
import {Page, LoginForm} from '../components'
|
||||
|
||||
const LoginPage = connect((state) => ({loggedIn: Boolean(state.login)}))(function LoginPage({loggedIn}) {
|
||||
return loggedIn ? (
|
||||
<Redirect to="/" />
|
||||
) : (
|
||||
<Page>
|
||||
<h2>Login</h2>
|
||||
<LoginForm className={styles.loginForm} />
|
||||
</Page>
|
||||
)
|
||||
})
|
||||
|
||||
export default LoginPage
|
4
frontend/src/pages/LoginPage.module.scss
Normal file
4
frontend/src/pages/LoginPage.module.scss
Normal file
|
@ -0,0 +1,4 @@
|
|||
.loginForm.loginForm {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
20
frontend/src/pages/LogoutPage.js
Normal file
20
frontend/src/pages/LogoutPage.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
import React from 'react'
|
||||
import {connect} from 'react-redux'
|
||||
import {Redirect} from 'react-router-dom'
|
||||
|
||||
import {logout as logoutAction} from '../reducers/login'
|
||||
|
||||
const LogoutPage = connect(
|
||||
(state) => ({loggedIn: Boolean(state.login)}),
|
||||
(dispatch) => ({
|
||||
dispatchLogout: () => dispatch(logoutAction()),
|
||||
})
|
||||
)(function LogoutPage({loggedIn, dispatchLogout}) {
|
||||
React.useEffect(() => {
|
||||
dispatchLogout()
|
||||
})
|
||||
|
||||
return loggedIn ? null : <Redirect to="/" />
|
||||
})
|
||||
|
||||
export default LogoutPage
|
16
frontend/src/pages/NotFoundPage.js
Normal file
16
frontend/src/pages/NotFoundPage.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
import React from 'react'
|
||||
import {Button} from 'semantic-ui-react'
|
||||
import {useHistory} from 'react-router-dom'
|
||||
|
||||
import {Page} from '../components'
|
||||
|
||||
export default function NotFoundPage() {
|
||||
const history = useHistory()
|
||||
return (
|
||||
<Page>
|
||||
<h2>Page not found</h2>
|
||||
<p>You know what that means...</p>
|
||||
<Button onClick={history.goBack.bind(history)}>Go back</Button>
|
||||
</Page>
|
||||
)
|
||||
}
|
13
frontend/src/pages/TrackPage/TrackActions.tsx
Normal file
13
frontend/src/pages/TrackPage/TrackActions.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import React from 'react'
|
||||
import {Link} from 'react-router-dom'
|
||||
import {Button} from 'semantic-ui-react'
|
||||
|
||||
export default function TrackActions({slug}) {
|
||||
return (
|
||||
<Button.Group vertical>
|
||||
<Link to={`/tracks/${slug}/edit`}>
|
||||
<Button primary>Edit track</Button>
|
||||
</Link>
|
||||
</Button.Group>
|
||||
)
|
||||
}
|
66
frontend/src/pages/TrackPage/TrackComments.tsx
Normal file
66
frontend/src/pages/TrackPage/TrackComments.tsx
Normal file
|
@ -0,0 +1,66 @@
|
|||
import React from 'react'
|
||||
import {Segment, Form, Button, Loader, Header, Comment} from 'semantic-ui-react'
|
||||
import Markdown from 'react-markdown'
|
||||
|
||||
import {FormattedDate} from 'components'
|
||||
|
||||
function CommentForm({onSubmit}) {
|
||||
const [body, setBody] = React.useState('')
|
||||
|
||||
const onSubmitComment = React.useCallback(() => {
|
||||
onSubmit({body})
|
||||
setBody('')
|
||||
}, [onSubmit, body])
|
||||
|
||||
return (
|
||||
<Form reply onSubmit={onSubmitComment}>
|
||||
<Form.TextArea rows={4} value={body} onChange={(e) => setBody(e.target.value)} />
|
||||
<Button content="Post comment" labelPosition="left" icon="edit" primary />
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TrackComments({comments, onSubmit, onDelete, login, hideLoader}) {
|
||||
return (
|
||||
<Segment basic>
|
||||
<Comment.Group>
|
||||
<Header as="h2" dividing>
|
||||
Comments
|
||||
</Header>
|
||||
|
||||
<Loader active={!hideLoader && comments == null} inline />
|
||||
|
||||
{comments?.map((comment: TrackComment) => (
|
||||
<Comment key={comment.id}>
|
||||
<Comment.Avatar src={comment.author.image} />
|
||||
<Comment.Content>
|
||||
<Comment.Author as="a">{comment.author.username}</Comment.Author>
|
||||
<Comment.Metadata>
|
||||
<div>
|
||||
<FormattedDate date={comment.createdAt} relative />
|
||||
</div>
|
||||
</Comment.Metadata>
|
||||
<Comment.Text>
|
||||
<Markdown>{comment.body}</Markdown>
|
||||
</Comment.Text>
|
||||
{login?.username === comment.author.username && (
|
||||
<Comment.Actions>
|
||||
<Comment.Action
|
||||
onClick={(e) => {
|
||||
onDelete(comment.id)
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Comment.Action>
|
||||
</Comment.Actions>
|
||||
)}
|
||||
</Comment.Content>
|
||||
</Comment>
|
||||
))}
|
||||
|
||||
{login && comments != null && <CommentForm onSubmit={onSubmit} />}
|
||||
</Comment.Group>
|
||||
</Segment>
|
||||
)
|
||||
}
|
73
frontend/src/pages/TrackPage/TrackDetails.tsx
Normal file
73
frontend/src/pages/TrackPage/TrackDetails.tsx
Normal file
|
@ -0,0 +1,73 @@
|
|||
import React from 'react'
|
||||
import {List, Loader} from 'semantic-ui-react'
|
||||
import {Duration} from 'luxon'
|
||||
|
||||
import {FormattedDate} from 'components'
|
||||
|
||||
function formatDuration(seconds) {
|
||||
return Duration.fromMillis((seconds ?? 0) * 1000).toFormat("h'h' mm'm'")
|
||||
}
|
||||
|
||||
export default function TrackDetails({track, isAuthor, trackData}) {
|
||||
return (
|
||||
<List>
|
||||
{track.visible != null && isAuthor && (
|
||||
<List.Item>
|
||||
<List.Header>Visibility</List.Header>
|
||||
{track.visible ? 'Public' : 'Private'}
|
||||
</List.Item>
|
||||
)}
|
||||
|
||||
{track.originalFileName != null && (
|
||||
<List.Item>
|
||||
<List.Header>Original Filename</List.Header>
|
||||
<code>{track.originalFileName}</code>
|
||||
</List.Item>
|
||||
)}
|
||||
|
||||
{track.uploadedByUserAgent != null && (
|
||||
<List.Item>
|
||||
<List.Header>Uploaded with</List.Header>
|
||||
{track.uploadedByUserAgent}
|
||||
</List.Item>
|
||||
)}
|
||||
|
||||
{track.duration == null && (
|
||||
<List.Item>
|
||||
<List.Header>Duration</List.Header>
|
||||
{formatDuration(track.duration || 1402)}
|
||||
</List.Item>
|
||||
)}
|
||||
|
||||
{track.createdAt != null && (
|
||||
<List.Item>
|
||||
<List.Header>Uploaded on</List.Header>
|
||||
<FormattedDate date={track.createdAt} />
|
||||
</List.Item>
|
||||
)}
|
||||
|
||||
<Loader active={track != null && trackData == null} inline="centered" style={{marginTop: 16, marginBottom: 16}} />
|
||||
|
||||
{trackData?.recordedAt != null && (
|
||||
<List.Item>
|
||||
<List.Header>Recorded on</List.Header>
|
||||
<FormattedDate date={trackData.recordedAt} />
|
||||
</List.Item>
|
||||
)}
|
||||
|
||||
{trackData?.numEvents != null && (
|
||||
<List.Item>
|
||||
<List.Header>Confirmed events</List.Header>
|
||||
{trackData.numEvents}
|
||||
</List.Item>
|
||||
)}
|
||||
|
||||
{trackData?.trackLength != null && (
|
||||
<List.Item>
|
||||
<List.Header>Length</List.Header>
|
||||
{(trackData.trackLength / 1000).toFixed(2)} km
|
||||
</List.Item>
|
||||
)}
|
||||
</List>
|
||||
)
|
||||
}
|
174
frontend/src/pages/TrackPage/TrackMap.tsx
Normal file
174
frontend/src/pages/TrackPage/TrackMap.tsx
Normal file
|
@ -0,0 +1,174 @@
|
|||
import React from 'react'
|
||||
import {Vector as VectorSource} from 'ol/source'
|
||||
import {Geometry, LineString, Point} from 'ol/geom'
|
||||
import Feature from 'ol/Feature'
|
||||
import {fromLonLat} from 'ol/proj'
|
||||
import {Fill, Stroke, Style, Text, Circle} from 'ol/style'
|
||||
|
||||
import {Map} from 'components'
|
||||
import type {TrackData, TrackPoint} from 'types'
|
||||
|
||||
const isValidTrackPoint = (point: TrackPoint): boolean =>
|
||||
point.latitude != null && point.longitude != null && (point.latitude !== 0 || point.longitude !== 0)
|
||||
|
||||
const WARN_DISTANCE = 200
|
||||
const MIN_DISTANCE = 150
|
||||
|
||||
const evaluateDistanceColor = function (distance) {
|
||||
if (distance < MIN_DISTANCE) {
|
||||
return 'red'
|
||||
} else if (distance < WARN_DISTANCE) {
|
||||
return 'orange'
|
||||
} else {
|
||||
return 'green'
|
||||
}
|
||||
}
|
||||
|
||||
const evaluateDistanceForFillColor = function (distance) {
|
||||
const redFill = new Fill({color: 'rgba(255, 0, 0, 0.2)'})
|
||||
const orangeFill = new Fill({color: 'rgba(245,134,0,0.2)'})
|
||||
const greenFill = new Fill({color: 'rgba(50, 205, 50, 0.2)'})
|
||||
|
||||
switch (evaluateDistanceColor(distance)) {
|
||||
case 'red':
|
||||
return redFill
|
||||
case 'orange':
|
||||
return orangeFill
|
||||
case 'green':
|
||||
return greenFill
|
||||
}
|
||||
}
|
||||
|
||||
const evaluateDistanceForStrokeColor = function (distance) {
|
||||
const redStroke = new Stroke({color: 'rgb(255, 0, 0)'})
|
||||
const orangeStroke = new Stroke({color: 'rgb(245,134,0)'})
|
||||
const greenStroke = new Stroke({color: 'rgb(50, 205, 50)'})
|
||||
|
||||
switch (evaluateDistanceColor(distance)) {
|
||||
case 'red':
|
||||
return redStroke
|
||||
case 'orange':
|
||||
return orangeStroke
|
||||
case 'green':
|
||||
return greenStroke
|
||||
}
|
||||
}
|
||||
|
||||
const createTextStyle = function (distance, resolution) {
|
||||
return new Text({
|
||||
textAlign: 'center',
|
||||
textBaseline: 'middle',
|
||||
font: 'normal 18px/1 Arial',
|
||||
text: resolution < 6 ? '' + distance : '',
|
||||
fill: new Fill({color: evaluateDistanceColor(distance)}),
|
||||
stroke: new Stroke({color: 'white', width: 2}),
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
})
|
||||
}
|
||||
|
||||
function pointStyleFunction(feature, resolution) {
|
||||
let distance = feature.get('distance')
|
||||
let radius = 200 / resolution
|
||||
|
||||
return new Style({
|
||||
image: new Circle({
|
||||
radius: radius < 20 ? radius : 20,
|
||||
fill: evaluateDistanceForFillColor(distance),
|
||||
stroke: evaluateDistanceForStrokeColor(distance),
|
||||
}),
|
||||
text: createTextStyle(distance, resolution),
|
||||
})
|
||||
}
|
||||
|
||||
function PointLayer({features, title, visible}) {
|
||||
return <Map.VectorLayer {...{title, visible}} style={pointStyleFunction} source={new VectorSource({features})} />
|
||||
}
|
||||
|
||||
export default function TrackMap({trackData, show, ...props}: {trackData: TrackData}) {
|
||||
const {
|
||||
trackVectorSource,
|
||||
trackPointsD1,
|
||||
trackPointsD2,
|
||||
trackPointsUntaggedD1,
|
||||
trackPointsUntaggedD2,
|
||||
viewExtent,
|
||||
} = React.useMemo(() => {
|
||||
const trackPointsD1: Feature<Geometry>[] = []
|
||||
const trackPointsD2: Feature<Geometry>[] = []
|
||||
const trackPointsUntaggedD1: Feature<Geometry>[] = []
|
||||
const trackPointsUntaggedD2: Feature<Geometry>[] = []
|
||||
const points: Coordinate[] = []
|
||||
const filteredPoints: TrackPoint[] = trackData?.points.filter(isValidTrackPoint) ?? []
|
||||
|
||||
for (const dataPoint of filteredPoints) {
|
||||
const {longitude, latitude, flag, d1, d2} = dataPoint
|
||||
|
||||
const p = fromLonLat([longitude, latitude])
|
||||
points.push(p)
|
||||
|
||||
const geometry = new Point(p)
|
||||
|
||||
if (flag && d1) {
|
||||
trackPointsD1.push(new Feature({distance: d1, geometry}))
|
||||
}
|
||||
|
||||
if (flag && d2) {
|
||||
trackPointsD2.push(new Feature({distance: d2, geometry}))
|
||||
}
|
||||
|
||||
if (!flag && d1) {
|
||||
trackPointsUntaggedD1.push(new Feature({distance: d1, geometry}))
|
||||
}
|
||||
|
||||
if (!flag && d2) {
|
||||
trackPointsUntaggedD2.push(new Feature({distance: d2, geometry}))
|
||||
}
|
||||
}
|
||||
|
||||
//Simplify to 1 point per 2 meter
|
||||
const trackVectorSource = new VectorSource({
|
||||
features: [new Feature(new LineString(points).simplify(2))],
|
||||
})
|
||||
|
||||
const viewExtent = points.length ? trackVectorSource.getExtent() : null
|
||||
return {trackVectorSource, trackPointsD1, trackPointsD2, trackPointsUntaggedD1, trackPointsUntaggedD2, viewExtent}
|
||||
}, [trackData?.points])
|
||||
|
||||
const trackLayerStyle = React.useMemo(
|
||||
() =>
|
||||
new Style({
|
||||
stroke: new Stroke({
|
||||
width: 3,
|
||||
color: 'rgb(30,144,255)',
|
||||
}),
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<Map {...props}>
|
||||
<Map.TileLayer />
|
||||
<Map.VectorLayer
|
||||
visible
|
||||
updateWhileAnimating={false}
|
||||
updateWhileInteracting={false}
|
||||
source={trackVectorSource}
|
||||
style={trackLayerStyle}
|
||||
/>
|
||||
|
||||
<Map.GroupLayer title="Tagged Points" visible>
|
||||
<PointLayer features={trackPointsD1} title="Left" visible={show.left} />
|
||||
<PointLayer features={trackPointsD2} title="Right" visible={show.right} />
|
||||
</Map.GroupLayer>
|
||||
|
||||
<Map.GroupLayer title="Untagged Points" fold="close" visible>
|
||||
<PointLayer features={trackPointsUntaggedD1} title="Left Untagged" visible={show.leftUnconfirmed} />
|
||||
<PointLayer features={trackPointsUntaggedD2} title="Right Untagged" visible={show.rightUnconfirmed} />
|
||||
</Map.GroupLayer>
|
||||
|
||||
<Map.View maxZoom={22} zoom={15} center={fromLonLat([9.1797, 48.7784])} />
|
||||
<Map.FitView extent={viewExtent} />
|
||||
</Map>
|
||||
)
|
||||
}
|
171
frontend/src/pages/TrackPage/index.tsx
Normal file
171
frontend/src/pages/TrackPage/index.tsx
Normal file
|
@ -0,0 +1,171 @@
|
|||
import React from 'react'
|
||||
import {connect} from 'react-redux'
|
||||
import {Table, Checkbox, Segment, Dimmer, Grid, Loader, Header} from 'semantic-ui-react'
|
||||
import {useParams} from 'react-router-dom'
|
||||
import {concat, combineLatest, of, from, Subject} from 'rxjs'
|
||||
import {pluck, distinctUntilChanged, map, switchMap, startWith, sample} from 'rxjs/operators'
|
||||
import {useObservable} from 'rxjs-hooks'
|
||||
import Markdown from 'react-markdown'
|
||||
|
||||
import api from 'api'
|
||||
import {Page} from 'components'
|
||||
import type {Track, TrackData, TrackComment} from 'types'
|
||||
|
||||
import TrackActions from './TrackActions'
|
||||
import TrackComments from './TrackComments'
|
||||
import TrackDetails from './TrackDetails'
|
||||
import TrackMap from './TrackMap'
|
||||
|
||||
function useTriggerSubject() {
|
||||
const subject$ = React.useMemo(() => new Subject(), [])
|
||||
const trigger = React.useCallback(() => subject$.next(null), [])
|
||||
return [trigger, subject$]
|
||||
}
|
||||
|
||||
const TrackPage = connect((state) => ({login: state.login}))(function TrackPage({login}) {
|
||||
const {slug} = useParams()
|
||||
|
||||
const [reloadComments, reloadComments$] = useTriggerSubject()
|
||||
|
||||
const data: {
|
||||
track: null | Track
|
||||
trackData: null | TrackData
|
||||
comments: null | TrackComment[]
|
||||
} | null = useObservable(
|
||||
(_$, args$) => {
|
||||
const slug$ = args$.pipe(pluck(0), distinctUntilChanged())
|
||||
const track$ = slug$.pipe(
|
||||
map((slug) => `/tracks/${slug}`),
|
||||
switchMap((url) => concat(of(null), from(api.get(url)))),
|
||||
pluck('track')
|
||||
)
|
||||
|
||||
const trackData$ = slug$.pipe(
|
||||
map((slug) => `/tracks/${slug}/data`),
|
||||
switchMap((url) => concat(of(null), from(api.get(url)))),
|
||||
pluck('trackData'),
|
||||
startWith(null) // show track infos before track data is loaded
|
||||
)
|
||||
|
||||
const comments$ = concat(of(null), reloadComments$).pipe(
|
||||
switchMap(() => slug$),
|
||||
map((slug) => `/tracks/${slug}/comments`),
|
||||
switchMap((url) => api.get(url)),
|
||||
pluck('comments'),
|
||||
startWith(null) // show track infos before comments are loaded
|
||||
)
|
||||
|
||||
return combineLatest([track$, trackData$, comments$]).pipe(
|
||||
map(([track, trackData, comments]) => ({track, trackData, comments}))
|
||||
)
|
||||
},
|
||||
null,
|
||||
[slug]
|
||||
)
|
||||
|
||||
const onSubmitComment = React.useCallback(async ({body}) => {
|
||||
await api.post(`/tracks/${slug}/comments`, {
|
||||
body: {comment: {body}},
|
||||
})
|
||||
reloadComments()
|
||||
}, [])
|
||||
|
||||
const onDeleteComment = React.useCallback(async (id) => {
|
||||
await api.delete(`/tracks/${slug}/comments/${id}`)
|
||||
reloadComments()
|
||||
}, [])
|
||||
|
||||
const isAuthor = login?.username === data?.track?.author?.username
|
||||
|
||||
const {track, trackData, comments} = data || {}
|
||||
|
||||
const loading = track == null || trackData == null
|
||||
|
||||
const [left, setLeft] = React.useState(true)
|
||||
const [right, setRight] = React.useState(false)
|
||||
const [leftUnconfirmed, setLeftUnconfirmed] = React.useState(false)
|
||||
const [rightUnconfirmed, setRightUnconfirmed] = React.useState(false)
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Grid stackable>
|
||||
<Grid.Row>
|
||||
<Grid.Column width={12}>
|
||||
<div style={{position: 'relative'}}>
|
||||
<Loader active={loading} />
|
||||
<Dimmer.Dimmable blurring dimmed={loading}>
|
||||
<TrackMap
|
||||
{...{track, trackData, show: {left, right, leftUnconfirmed, rightUnconfirmed}}}
|
||||
style={{height: '60vh', minHeight: 400}}
|
||||
/>
|
||||
</Dimmer.Dimmable>
|
||||
</div>
|
||||
</Grid.Column>
|
||||
<Grid.Column width={4}>
|
||||
<Segment>
|
||||
{track && (
|
||||
<>
|
||||
<Header as="h1">{track.title}</Header>
|
||||
<TrackDetails {...{track, trackData, isAuthor}} />
|
||||
{isAuthor && <TrackActions {...{slug}} />}
|
||||
</>
|
||||
)}
|
||||
</Segment>
|
||||
|
||||
<Header as="h4">Map settings</Header>
|
||||
|
||||
<Table compact>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Left</Table.HeaderCell>
|
||||
<Table.HeaderCell textAlign="center">Show distance of</Table.HeaderCell>
|
||||
<Table.HeaderCell textAlign="right">Right</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<Checkbox checked={left} onChange={(e, d) => setLeft(d.checked)} />{' '}
|
||||
</Table.Cell>
|
||||
<Table.Cell textAlign="center">Events</Table.Cell>
|
||||
<Table.Cell textAlign="right">
|
||||
<Checkbox checked={right} onChange={(e, d) => setRight(d.checked)} />{' '}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<Checkbox checked={leftUnconfirmed} onChange={(e, d) => setLeftUnconfirmed(d.checked)} />{' '}
|
||||
</Table.Cell>
|
||||
<Table.Cell textAlign="center">Other points</Table.Cell>
|
||||
<Table.Cell textAlign="right">
|
||||
<Checkbox checked={rightUnconfirmed} onChange={(e, d) => setRightUnconfirmed(d.checked)} />{' '}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
</Grid>
|
||||
|
||||
{track?.description && (
|
||||
<Segment basic>
|
||||
<Header as="h2" dividing>
|
||||
Description
|
||||
</Header>
|
||||
<Markdown>{track.description}</Markdown>
|
||||
</Segment>
|
||||
)}
|
||||
|
||||
<TrackComments
|
||||
{...{hideLoader: loading, comments, login}}
|
||||
onSubmit={onSubmitComment}
|
||||
onDelete={onDeleteComment}
|
||||
/>
|
||||
|
||||
{/* <pre>{JSON.stringify(data, null, 2)}</pre> */}
|
||||
</Page>
|
||||
)
|
||||
})
|
||||
|
||||
export default TrackPage
|
137
frontend/src/pages/TracksPage.tsx
Normal file
137
frontend/src/pages/TracksPage.tsx
Normal file
|
@ -0,0 +1,137 @@
|
|||
import React from 'react'
|
||||
import {connect} from 'react-redux'
|
||||
import {Item, Tab, Loader, Pagination, Icon} from 'semantic-ui-react'
|
||||
import {useObservable} from 'rxjs-hooks'
|
||||
import {Link, useHistory, useRouteMatch} from 'react-router-dom'
|
||||
import {of, from, concat} from 'rxjs'
|
||||
import {map, switchMap, distinctUntilChanged} from 'rxjs/operators'
|
||||
import _ from 'lodash'
|
||||
|
||||
import type {Track} from 'types'
|
||||
import {Page, StripMarkdown} from 'components'
|
||||
import api from 'api'
|
||||
import {useQueryParam} from 'query'
|
||||
|
||||
function TracksPageTabs() {
|
||||
const history = useHistory()
|
||||
const panes = React.useMemo(
|
||||
() => [
|
||||
{menuItem: 'Global Feed', url: '/feed'},
|
||||
{menuItem: 'Your Feed', url: '/feed/my'},
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
const onTabChange = React.useCallback(
|
||||
(e, data) => {
|
||||
history.push(panes[data.activeIndex].url)
|
||||
},
|
||||
[history, panes]
|
||||
)
|
||||
|
||||
const isFeedPage = useRouteMatch('/feed/my')
|
||||
const activeIndex = isFeedPage ? 1 : 0
|
||||
|
||||
return <Tab menu={{secondary: true, pointing: true}} {...{panes, onTabChange, activeIndex}} />
|
||||
}
|
||||
|
||||
function TrackList({privateFeed}: {privateFeed: boolean}) {
|
||||
const [page, setPage] = useQueryParam<number>('page', 1, Number)
|
||||
console.log('page', page)
|
||||
|
||||
const pageSize = 10
|
||||
|
||||
const data: {
|
||||
tracks: Track[]
|
||||
tracksCount: number
|
||||
} | null = useObservable(
|
||||
(_$, inputs$) =>
|
||||
inputs$.pipe(
|
||||
map(([page, privateFeed]) => {
|
||||
const url = '/tracks' + (privateFeed ? '/feed' : '')
|
||||
const query = {limit: pageSize, offset: pageSize * (page - 1)}
|
||||
return {url, query}
|
||||
}),
|
||||
distinctUntilChanged(_.isEqual),
|
||||
switchMap((request) => concat(of(null), from(api.get(request.url, {query: request.query}))))
|
||||
),
|
||||
null,
|
||||
[page, privateFeed]
|
||||
)
|
||||
|
||||
const {tracks, tracksCount} = data || {tracks: [], tracksCount: 0}
|
||||
const loading = !data
|
||||
const totalPages = tracksCount / pageSize
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Loader content="Loading" active={loading} />
|
||||
{!loading && totalPages > 1 && (
|
||||
<Pagination
|
||||
activePage={page}
|
||||
onPageChange={(e, data) => setPage(data.activePage as number)}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tracks && (
|
||||
<Item.Group divided>
|
||||
{tracks.map((track: Track) => (
|
||||
<TrackListItem key={track.slug} {...{track, privateFeed}} />
|
||||
))}
|
||||
</Item.Group>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function maxLength(t, max) {
|
||||
if (t.length > max) {
|
||||
return t.substring(0, max) + ' ...'
|
||||
} else {
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
export function TrackListItem({track, privateFeed = false}) {
|
||||
return (
|
||||
<Item key={track.slug}>
|
||||
<Item.Image size="tiny" src={track.author.image} />
|
||||
<Item.Content>
|
||||
<Item.Header as={Link} to={`/tracks/${track.slug}`}>
|
||||
{track.title}
|
||||
</Item.Header>
|
||||
<Item.Meta>
|
||||
Created by {track.author.username} on {track.createdAt}
|
||||
</Item.Meta>
|
||||
<Item.Description>
|
||||
<StripMarkdown>{maxLength(track.description, 200)}</StripMarkdown>
|
||||
</Item.Description>
|
||||
{privateFeed && (
|
||||
<Item.Extra>
|
||||
{track.visible ? (
|
||||
<>
|
||||
<Icon color="blue" name="eye" fitted /> Public
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="eye slash" fitted /> Private
|
||||
</>
|
||||
)}
|
||||
</Item.Extra>
|
||||
)}
|
||||
</Item.Content>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
const TracksPage = connect((state) => ({login: (state as any).login}))(function TracksPage({login, privateFeed}) {
|
||||
return (
|
||||
<Page>
|
||||
{login ? <TracksPageTabs /> : null}
|
||||
<TrackList {...{privateFeed}} />
|
||||
</Page>
|
||||
)
|
||||
})
|
||||
|
||||
export default TracksPage
|
236
frontend/src/pages/UploadPage.tsx
Normal file
236
frontend/src/pages/UploadPage.tsx
Normal file
|
@ -0,0 +1,236 @@
|
|||
import _ from 'lodash'
|
||||
import React from 'react'
|
||||
import {List, Loader, Table, Icon, Segment, Header, Button} from 'semantic-ui-react'
|
||||
import {Link} from 'react-router-dom'
|
||||
|
||||
import {FileDrop, Page} from 'components'
|
||||
import type {Track} from 'types'
|
||||
import api from 'api'
|
||||
|
||||
function isSameFile(a: File, b: File) {
|
||||
return a.name === b.name && a.size === b.size
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number) {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} bytes`
|
||||
}
|
||||
|
||||
bytes /= 1024
|
||||
|
||||
if (bytes < 1024) {
|
||||
return `${bytes.toFixed(1)} KiB`
|
||||
}
|
||||
|
||||
bytes /= 1024
|
||||
|
||||
if (bytes < 1024) {
|
||||
return `${bytes.toFixed(1)} MiB`
|
||||
}
|
||||
|
||||
bytes /= 1024
|
||||
return `${bytes.toFixed(1)} GiB`
|
||||
}
|
||||
|
||||
type FileUploadResult =
|
||||
| {
|
||||
track: Track
|
||||
}
|
||||
| {
|
||||
errors: Record<string, string>
|
||||
}
|
||||
|
||||
function FileUploadStatus({
|
||||
id,
|
||||
file,
|
||||
onComplete,
|
||||
}: {
|
||||
id: string
|
||||
file: File
|
||||
onComplete: (result: FileUploadResult) => void
|
||||
}) {
|
||||
const [progress, setProgress] = React.useState(0)
|
||||
|
||||
React.useEffect(() => {
|
||||
const formData = new FormData()
|
||||
formData.append('body', file)
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
|
||||
const onProgress = (e) => {
|
||||
console.log('progress', e)
|
||||
const progress = (e.loaded || 0) / (e.total || 1)
|
||||
setProgress(progress)
|
||||
}
|
||||
|
||||
const onLoad = (e) => {
|
||||
console.log('loaded', e)
|
||||
onComplete(id, xhr.response)
|
||||
}
|
||||
|
||||
xhr.responseType = 'json'
|
||||
xhr.onload = onLoad
|
||||
xhr.upload.onprogress = onProgress
|
||||
xhr.open('POST', '/api/tracks')
|
||||
xhr.setRequestHeader('Authorization', api.authorization)
|
||||
xhr.send(formData)
|
||||
|
||||
return () => xhr.abort()
|
||||
}, [file])
|
||||
|
||||
return (
|
||||
<span>
|
||||
<Loader inline size="mini" active />
|
||||
{' '}
|
||||
{progress < 1 ? (progress * 100).toFixed(0) + ' %' : 'Processing...'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
type FileEntry = {
|
||||
id: string
|
||||
file?: File | null
|
||||
size: number
|
||||
name: string
|
||||
result?: FileUploadResult
|
||||
}
|
||||
|
||||
export default function UploadPage() {
|
||||
const labelRef = React.useRef()
|
||||
const [labelRefState, setLabelRefState] = React.useState()
|
||||
|
||||
const [files, setFiles] = React.useState<FileEntry[]>([])
|
||||
|
||||
const onCompleteFileUpload = React.useCallback(
|
||||
(id, result) => {
|
||||
setFiles((files) => files.map((file) => (file.id === id ? {...file, result, file: null} : file)))
|
||||
},
|
||||
[setFiles]
|
||||
)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
setLabelRefState(labelRef.current)
|
||||
}, [labelRef.current])
|
||||
|
||||
function onSelectFiles(fileList) {
|
||||
console.log('UPLOAD', fileList)
|
||||
|
||||
const newFiles = Array.from(fileList).map((file) => ({
|
||||
id: 'file-' + String(Math.floor(Math.random() * 1000000)),
|
||||
file,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
}))
|
||||
setFiles(files.filter((a) => !newFiles.some((b) => isSameFile(a, b))).concat(newFiles))
|
||||
}
|
||||
|
||||
function onChangeField(e) {
|
||||
if (e.target.files && e.target.files.length) {
|
||||
onSelectFiles(e.target.files)
|
||||
}
|
||||
e.target.value = '' // reset the form field for uploading again
|
||||
}
|
||||
|
||||
async function onDeleteTrack(slug: string) {
|
||||
await api.delete(`/tracks/${slug}`)
|
||||
setFiles((files) => files.filter((t) => t.result?.track?.slug !== slug))
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
{files.length ? (
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Filename</Table.HeaderCell>
|
||||
<Table.HeaderCell>Size</Table.HeaderCell>
|
||||
<Table.HeaderCell>Status</Table.HeaderCell>
|
||||
<Table.HeaderCell></Table.HeaderCell>
|
||||
<Table.HeaderCell></Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{files.map(({id, name, size, file, result}) => (
|
||||
<Table.Row key={id}>
|
||||
<Table.Cell>
|
||||
<Icon name="file" />
|
||||
{name}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{formatFileSize(size)}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{result ? (
|
||||
<>
|
||||
<Icon name="check" /> Uploaded
|
||||
</>
|
||||
) : (
|
||||
<FileUploadStatus {...{id, file}} onComplete={onCompleteFileUpload} />
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{/* <pre>{JSON.stringify(result || null, null, 2)}</pre> */}
|
||||
{result?.errors ? (
|
||||
<List>
|
||||
{_.sortBy(Object.entries(result.errors))
|
||||
.filter(([field, message]) => typeof message === 'string')
|
||||
.map(([field, message]) => (
|
||||
<List.Item key={field}>
|
||||
<List.Icon name="warning sign" color="red" />
|
||||
<List.Content>{message}</List.Content>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
) : null}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{result?.track ? (
|
||||
<>
|
||||
<Link to={`/tracks/${result.track.slug}`}>
|
||||
<Button size="small" icon="arrow right" />
|
||||
</Link>
|
||||
<Button size="small" icon="trash" onClick={() => onDeleteTrack(result.track.slug)} />
|
||||
</>
|
||||
) : null}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
) : null}
|
||||
|
||||
<input
|
||||
type="file"
|
||||
id="upload-field"
|
||||
style={{width: 0, height: 0, position: 'fixed', left: -1000, top: -1000, opacity: 0.001}}
|
||||
multiple
|
||||
accept=".csv"
|
||||
onChange={onChangeField}
|
||||
/>
|
||||
<label htmlFor="upload-field" ref={labelRef}>
|
||||
{labelRefState && (
|
||||
<FileDrop onDrop={onSelectFiles} frame={labelRefState}>
|
||||
{({draggingOverFrame, draggingOverTarget, onDragOver, onDragLeave, onDrop, onClick}) => (
|
||||
<Segment
|
||||
placeholder
|
||||
{...{onDragOver, onDragLeave, onDrop}}
|
||||
style={{
|
||||
background: draggingOverTarget || draggingOverFrame ? '#E0E0EE' : null,
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
>
|
||||
<Header icon>
|
||||
<Icon name="cloud upload" />
|
||||
Drop files here or click to select them for upload
|
||||
</Header>
|
||||
|
||||
<Button primary as="span">
|
||||
Upload files
|
||||
</Button>
|
||||
</Segment>
|
||||
)}
|
||||
</FileDrop>
|
||||
)}
|
||||
</label>
|
||||
</Page>
|
||||
)
|
||||
}
|
7
frontend/src/pages/index.js
Normal file
7
frontend/src/pages/index.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
export {default as HomePage} from './HomePage'
|
||||
export {default as LoginPage} from './LoginPage'
|
||||
export {default as LogoutPage} from './LogoutPage'
|
||||
export {default as NotFoundPage} from './NotFoundPage'
|
||||
export {default as TrackPage} from './TrackPage'
|
||||
export {default as TracksPage} from './TracksPage'
|
||||
export {default as UploadPage} from './UploadPage'
|
82
frontend/src/query.ts
Normal file
82
frontend/src/query.ts
Normal file
|
@ -0,0 +1,82 @@
|
|||
import {useMemo} from 'react'
|
||||
import {useHistory, useLocation} from 'react-router-dom'
|
||||
|
||||
type QueryValue = string | number
|
||||
type QueryParams = {[key: string]: QueryValue}
|
||||
|
||||
export function parseValue(value: string): null | QueryValue {
|
||||
// empty or `-` values should be represented as `null`
|
||||
if (value === '-' || value === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
// `isNaN` understands numeric strings as numbers, but also detects empty
|
||||
// strings and `null` as such. We only want to parse strings that are
|
||||
// numeric and not empty, therefore this check is a bit more complicated.
|
||||
if (typeof value === 'string' && value !== '' && !isNaN(Number(value))) {
|
||||
return parseFloat(value)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export function parseQuery(search: string): QueryParams {
|
||||
const result: QueryParams = {}
|
||||
|
||||
const params = new URLSearchParams(search)
|
||||
for (const entry of params.entries()) {
|
||||
const [key, value_] = entry
|
||||
const v = parseValue(value_)
|
||||
if (v != null) {
|
||||
result[key] = v
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export const stringifyParams = (params: Record<string, any>) => {
|
||||
if (!params) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const usp = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
usp.append(key, typeof value === 'object' ? JSON.stringify(value) : value)
|
||||
}
|
||||
return usp.toString()
|
||||
}
|
||||
|
||||
export function useQueryParam<T extends QueryValue>(
|
||||
name: string,
|
||||
defaultValue: T | null = null,
|
||||
convert: (t: T | null) => T | null = (x) => x
|
||||
): [T, (newValue: T) => void] {
|
||||
const history = useHistory()
|
||||
const _triggerReload = useLocation()
|
||||
const {[name]: value = defaultValue} = (parseQuery(history.location.search) as unknown) as {
|
||||
[name: string]: T
|
||||
}
|
||||
const setter = useMemo(
|
||||
() => (newValue: T) => {
|
||||
// We're re-parsing the query here, because it might have been
|
||||
// changed simulatenously with this call, and the
|
||||
// history.location.search will already be updated, but react might
|
||||
// not have rerendered yet. Yes, this is access to some global
|
||||
// state, but that is okay, since there is just one browser history
|
||||
// at any time.
|
||||
const {[name]: _oldValue, ...queryParams} = parseQuery(history.location.search)
|
||||
|
||||
const newQueryParams = {
|
||||
...queryParams,
|
||||
...(newValue == null || (newValue as any) === defaultValue ? {} : {[name]: newValue}),
|
||||
}
|
||||
|
||||
const queryString = stringifyParams(newQueryParams)
|
||||
history.replace({...history.location, search: '?' + queryString})
|
||||
},
|
||||
[name, history, defaultValue]
|
||||
)
|
||||
|
||||
const result: T = (convert(value) ?? defaultValue) as any
|
||||
return [result, setter]
|
||||
}
|
1
frontend/src/react-app-env.d.ts
vendored
Normal file
1
frontend/src/react-app-env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="react-scripts" />
|
5
frontend/src/reducers/index.js
Normal file
5
frontend/src/reducers/index.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
import {combineReducers} from 'redux'
|
||||
|
||||
import login from './login'
|
||||
|
||||
export default combineReducers({login})
|
20
frontend/src/reducers/login.js
Normal file
20
frontend/src/reducers/login.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
const initialState = null
|
||||
|
||||
export function login(user) {
|
||||
return {type: 'LOGIN.LOGIN', payload: {user}}
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return {type: 'LOGIN.LOGOUT'}
|
||||
}
|
||||
|
||||
export default function loginReducer(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case 'LOGIN.LOGIN':
|
||||
return action.payload.user
|
||||
case 'LOGIN.LOGOUT':
|
||||
return null
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
4
frontend/src/styles.scss
Normal file
4
frontend/src/styles.scss
Normal file
|
@ -0,0 +1,4 @@
|
|||
@mixin container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
43
frontend/src/types.ts
Normal file
43
frontend/src/types.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
export type UserProfile = {
|
||||
username: string
|
||||
image: string
|
||||
bio?: string | null
|
||||
}
|
||||
|
||||
export type Track = {
|
||||
slug: string
|
||||
author: UserProfile
|
||||
title: string
|
||||
description?: string
|
||||
createdAt: string
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
export type TrackData = {
|
||||
slug: string
|
||||
numEvents?: number | null
|
||||
recordedAt?: String | null
|
||||
recordedUntil?: String | null
|
||||
trackLength?: number | null
|
||||
points: TrackPoint[]
|
||||
}
|
||||
|
||||
export type TrackPoint = {
|
||||
date: string | null
|
||||
time: string | null
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
course: number | null
|
||||
speed: number | null
|
||||
d1: number | null
|
||||
d2: number | null
|
||||
flag: number | null
|
||||
private: number | null
|
||||
}
|
||||
|
||||
export type TrackComment = {
|
||||
id: string
|
||||
body: string
|
||||
createdAt: string
|
||||
author: UserProfile
|
||||
}
|
28
frontend/tsconfig.json
Normal file
28
frontend/tsconfig.json
Normal file
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"downlevelIteration": true,
|
||||
"baseUrl": "src"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
Loading…
Reference in a new issue