Merge react frontend and api to create a monorepo

This commit is contained in:
Paul Bienkowski 2021-02-17 21:51:35 +01:00
commit 955966e56e
43 changed files with 19110 additions and 0 deletions

24
frontend/.gitignore vendored Normal file
View 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
View 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 cant go back!**
If you arent 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 youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt 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

File diff suppressed because it is too large Load diff

63
frontend/package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
frontend/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View 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"
}

View file

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

107
frontend/src/App.js Normal file
View 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

View 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
View 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

View 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,
})
}
}

View 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>
}

View 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

View 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

View file

@ -0,0 +1,6 @@
@import '../../styles.scss';
.page {
@include container;
margin-top: 32px;
}

View 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>
}

View 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>
)
}

View 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
View 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
View 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')
)

View 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>
)
}

View 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

View file

@ -0,0 +1,4 @@
.loginForm.loginForm {
max-width: 400px;
margin: 0 auto;
}

View 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

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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

View 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

View 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>
)
}

View 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
View 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
View file

@ -0,0 +1 @@
/// <reference types="react-scripts" />

View file

@ -0,0 +1,5 @@
import {combineReducers} from 'redux'
import login from './login'
export default combineReducers({login})

View 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
View file

@ -0,0 +1,4 @@
@mixin container {
max-width: 1200px;
margin: 0 auto;
}

43
frontend/src/types.ts Normal file
View 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
View 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"
]
}