Track page

This commit is contained in:
Paul Bienkowski 2021-02-14 17:18:57 +01:00
parent c1186e4074
commit ec2d5bcf77
13 changed files with 819 additions and 176 deletions

162
package-lock.json generated
View file

@ -1582,6 +1582,36 @@
"chalk": "^4.0.0"
}
},
"@mapbox/jsonlint-lines-primitives": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
"integrity": "sha1-zlblOfg1UrWNENZy6k1vya3HsjQ="
},
"@mapbox/mapbox-gl-style-spec": {
"version": "13.19.0",
"resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-style-spec/-/mapbox-gl-style-spec-13.19.0.tgz",
"integrity": "sha512-qA9P4WHU4a1iLKM/W2EIxCxcwlxa6isPF6P+jSPaIs4VlZKYO1DMVWNiY03SXu6a+K3dB3GEhRLvEh1f/8VG2w==",
"requires": {
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
"@mapbox/point-geometry": "^0.1.0",
"@mapbox/unitbezier": "^0.0.0",
"csscolorparser": "~1.0.2",
"json-stringify-pretty-compact": "^2.0.0",
"minimist": "^1.2.5",
"rw": "^1.3.3",
"sort-object": "^0.3.2"
}
},
"@mapbox/point-geometry": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz",
"integrity": "sha1-ioP5M1x4YO/6Lu7KJUMyqgru2PI="
},
"@mapbox/unitbezier": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz",
"integrity": "sha1-FWUb1VOme4WB+zmIEMmK2Go0Uk4="
},
"@nodelib/fs.scandir": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz",
@ -1977,6 +2007,16 @@
"integrity": "sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA==",
"dev": true
},
"@types/hoist-non-react-statics": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz",
"integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==",
"dev": true,
"requires": {
"@types/react": "*",
"hoist-non-react-statics": "^3.3.0"
}
},
"@types/html-minifier-terser": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz",
@ -2022,6 +2062,12 @@
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
"integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4="
},
"@types/lodash": {
"version": "4.14.168",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.168.tgz",
"integrity": "sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q==",
"dev": true
},
"@types/minimatch": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
@ -2074,6 +2120,18 @@
"@types/react": "*"
}
},
"@types/react-redux": {
"version": "7.1.16",
"resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.16.tgz",
"integrity": "sha512-f/FKzIrZwZk7YEO9E1yoxIuDNRiDducxkFlkw/GNMGEnK9n4K8wJzlJBghpSuOVDgEUHoDkDF7Gi9lHNQR4siw==",
"dev": true,
"requires": {
"@types/hoist-non-react-statics": "^3.3.0",
"@types/react": "*",
"hoist-non-react-statics": "^3.3.0",
"redux": "^4.0.0"
}
},
"@types/react-router": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.11.tgz",
@ -4409,6 +4467,11 @@
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
"integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s="
},
"csscolorparser": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz",
"integrity": "sha1-s085HupNqPPpgjHizNjfnAQfFxs="
},
"cssdb": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/cssdb/-/cssdb-4.4.0.tgz",
@ -8697,6 +8760,11 @@
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
"integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE="
},
"json-stringify-pretty-compact": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-2.0.0.tgz",
"integrity": "sha512-WRitRfs6BGq4q8gTgOy4ek7iPFXjbra0H3PmDLKm2xnZ+Gh1HUhiKGgCZkSPNULlP7mvfu6FV/mOLhCarspADQ=="
},
"json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
@ -8953,6 +9021,11 @@
"yallist": "^4.0.0"
}
},
"luxon": {
"version": "1.25.0",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-1.25.0.tgz",
"integrity": "sha512-hEgLurSH8kQRjY6i4YLey+mcKVAWXbDNlZRmM6AgWDJ1cY3atl8Ztf5wEY7VBReFbmGnwQPz7KYJblL8B2k0jQ=="
},
"lz-string": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz",
@ -9008,6 +9081,11 @@
"object-visit": "^1.0.0"
}
},
"mapbox-to-css-font": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/mapbox-to-css-font/-/mapbox-to-css-font-2.4.0.tgz",
"integrity": "sha512-v674D0WtpxCXlA6E+sBlG1QJWdUkz/s9qAD91bJSXBGuBL5lL4tJXpoJEftecphCh2SVQCjWMS2vhylc3AIQTg=="
},
"md5.js": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
@ -9976,6 +10054,26 @@
"resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="
},
"ol": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/ol/-/ol-6.5.0.tgz",
"integrity": "sha512-a5ebahrjF5yCPFle1rc0aHzKp/9A4LlUnjh+S3I+x4EgcvcddDhpOX3WDOs0Pg9/wEElrikHSGEvbeej2Hh4Ug==",
"requires": {
"ol-mapbox-style": "^6.1.1",
"pbf": "3.2.1",
"rbush": "^3.0.1"
}
},
"ol-mapbox-style": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/ol-mapbox-style/-/ol-mapbox-style-6.3.1.tgz",
"integrity": "sha512-hZsvPVkk1Y+qmifxRX/gCaZJ5Mo04vWj6lbFhXpHDloQquHD3kTY0q8o3xbg4FehucuG7HyQteKWeFJRh3FMww==",
"requires": {
"@mapbox/mapbox-gl-style-spec": "^13.14.0",
"mapbox-to-css-font": "^2.4.0",
"webfont-matcher": "^1.1.0"
}
},
"on-finished": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
@ -10292,6 +10390,15 @@
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="
},
"pbf": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-3.2.1.tgz",
"integrity": "sha512-ClrV7pNOn7rtmoQVF4TS1vyU0WhYRnP92fzbfF75jAIwpnzdJXf8iTd4CMEqO4yUenH6NDqLiwjqlh6QgZzgLQ==",
"requires": {
"ieee754": "^1.1.12",
"resolve-protobuf-schema": "^2.1.0"
}
},
"pbkdf2": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.1.tgz",
@ -11557,6 +11664,11 @@
"react-is": "^16.8.1"
}
},
"protocol-buffers-schema": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.5.1.tgz",
"integrity": "sha512-YVCvdhxWNDP8/nJDyXLuM+UFsuPk4+1PB7WGPVDzm3HTHbzFLxQYeW2iZpS4mmnXrQJGBzt230t/BbEb7PrQaw=="
},
"proxy-addr": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz",
@ -11670,6 +11782,11 @@
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
},
"quickselect": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="
},
"raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
@ -11718,6 +11835,14 @@
}
}
},
"rbush": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz",
"integrity": "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==",
"requires": {
"quickselect": "^2.0.0"
}
},
"react": {
"version": "17.0.1",
"resolved": "https://registry.npmjs.org/react/-/react-17.0.1.tgz",
@ -12426,6 +12551,14 @@
"resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz",
"integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng=="
},
"resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"requires": {
"protocol-buffers-schema": "^3.3.1"
}
},
"resolve-url": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz",
@ -12689,6 +12822,11 @@
"aproba": "^1.1.1"
}
},
"rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha1-P4Yt+pGrdmsUiF700BEkv9oHT7Q="
},
"rxjs": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.3.tgz",
@ -13524,6 +13662,16 @@
}
}
},
"sort-asc": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.1.0.tgz",
"integrity": "sha1-q3md9h/HPqCVbHnEtTHtHp53J+k="
},
"sort-desc": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/sort-desc/-/sort-desc-0.1.1.tgz",
"integrity": "sha1-GYuMDN6wlcRjNBhh45JdTuNZqe4="
},
"sort-keys": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz",
@ -13532,6 +13680,15 @@
"is-plain-obj": "^1.0.0"
}
},
"sort-object": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/sort-object/-/sort-object-0.3.2.tgz",
"integrity": "sha1-mODRme3kDgfGGoRAPGHWw7KQ+eI=",
"requires": {
"sort-asc": "^0.1.0",
"sort-desc": "^0.1.1"
}
},
"source-list-map": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz",
@ -15178,6 +15335,11 @@
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-0.2.4.tgz",
"integrity": "sha512-6BjspCO9VriYy12z356nL6JBS0GYeEcA457YyRzD+dD6XYCQ75NKhcOHUMHentOE7OcVCIXXDvOm0jKFfQG2Gg=="
},
"webfont-matcher": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/webfont-matcher/-/webfont-matcher-1.1.0.tgz",
"integrity": "sha1-mM6VCXsp4x++czBT4Q5XFkLRxsc="
},
"webidl-conversions": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz",

View file

@ -10,7 +10,9 @@
"@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",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-redux": "^7.2.2",
@ -52,6 +54,8 @@
"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"
}
}

View file

@ -1,127 +1,13 @@
import React from 'react'
import {connect} from 'react-redux'
import {Item, Tab, Button, Loader, Pagination, Icon} from 'semantic-ui-react'
import {useObservable} from 'rxjs-hooks'
import {BrowserRouter as Router, Switch, Route, Link, useParams, useHistory, useRouteMatch} from 'react-router-dom'
import {of, from, concat} from 'rxjs'
import {map, switchMap, distinctUntilChanged, debounceTime} from 'rxjs/operators'
import {Button} from 'semantic-ui-react'
import {BrowserRouter as Router, Switch, Route, Link} from 'react-router-dom'
import _ from 'lodash'
import {Page} from './components'
import styles from './App.module.scss'
import api from './api'
import {LoginPage, LogoutPage, NotFoundPage} from './pages'
import {useQueryParam, stringifyParams} from './query.ts'
function TracksPageTabs() {
const history = useHistory()
const panes = React.useMemo(
() => [
{menuItem: 'Global Feed', url: '/'},
{menuItem: 'Your Feed', url: '/my-tracks'},
],
[]
)
const onTabChange = React.useCallback(
(e, data) => {
history.push(panes[data.activeIndex].url)
},
[history, panes]
)
const isFeedPage = useRouteMatch('/my-tracks')
const activeIndex = isFeedPage ? 1 : 0
return <Tab menu={{secondary: true, pointing: true}} {...{panes, onTabChange, activeIndex}} />
}
function TrackList({path}) {
const [page, setPage] = useQueryParam('page', 1)
const privateFeed = path === '/my-tracks'
const pageSize = 20
const data = useObservable(
(_$, inputs$) =>
inputs$.pipe(
map(([page, privateFeed]) => {
const url = '/tracks' + (privateFeed ? '/feed' : '')
const params = {limit: pageSize, offset: pageSize * (page - 1)}
return {url, params}
}),
debounceTime(100),
distinctUntilChanged(_.isEqual),
switchMap((request) => concat(of(null), from(api.fetch(request.url + '?' + stringifyParams(request.params)))))
),
null,
[page, privateFeed]
)
const {tracks, trackCount} = data || {}
const loading = !data
const totalPages = trackCount / pageSize
return (
<div>
<Loader content="Loading" active={loading} />
{!loading && totalPages > 1 && <Pagination activePage={page} onPageChange={setPage} totalPages={totalPages} />}
{tracks && (
<Item.Group divided>
{tracks.map((track) => (
<Item key={track.slug}>
<Item.Image size="tiny" src={track.author.image} />
<Item.Content>
<Item.Header as="a">{track.title}</Item.Header>
<Item.Meta>
Created by {track.author.username} on {track.createdAt}
</Item.Meta>
<Item.Description>{track.description}</Item.Description>
<Item.Extra>
{track.visible ? (
<>
<Icon color="blue" name="eye" fitted /> Public
</>
) : (
<>
<Icon name="eye slash" fitted /> Private
</>
)}
</Item.Extra>
</Item.Content>
</Item>
))}
</Item.Group>
)}
</div>
)
}
function PublicTracksPage({login}) {
return (
<Page>
{login ? <TracksPageTabs /> : null}
<TrackList path="/" />
</Page>
)
}
function OwnTracksPage({login}) {
return (
<Page>
{login ? <TracksPageTabs /> : null}
<TrackList path="/my-tracks" />
</Page>
)
}
function Track() {
let {slug} = useParams()
return <h3>Track {slug}</h3>
}
import {LoginPage, LogoutPage, NotFoundPage, TracksPage, TrackPage, HomePage} 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
@ -139,10 +25,13 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
<nav className={styles.menu}>
<ul>
<li>
<Link to="/">Feed</Link>
<Link to="/">Home</Link>
</li>
<li>
<Link to="https://openbikesensor.org/">About</Link>
<Link to="/feed">Feed</Link>
</li>
<li>
<a href="https://openbikesensor.org/" target="_blank">About</a>
</li>
{login ? (
<>
@ -170,18 +59,21 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
<Switch>
<Route path="/" exact>
<PublicTracksPage {...{login}} />
<HomePage />
</Route>
<Route path="/my-tracks">
<OwnTracksPage {...{login}} />
<Route path="/feed" exact>
<TracksPage />
</Route>
<Route path={`/track/:slug`}>
<Track />
<Route path="/feed/my" exact>
<TracksPage privateFeed />
</Route>
<Route path="/login">
<Route path={`/tracks/:slug`} exact>
<TrackPage />
</Route>
<Route path="/login" exact>
<LoginPage />
</Route>
<Route path="/logout">
<Route path="/logout" exact>
<LogoutPage />
</Route>
<Route>

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,62 @@
import React from 'react'
import OlMap from 'ol/Map';
import View from 'ol/View';
import OlTileLayer from 'ol/layer/Tile';
import {fromLonLat} from 'ol/proj';
import OSM from 'ol/source/OSM';
import "ol/ol.css";
const MapContext = 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,
view: new View({
maxZoom: 22,
center: fromLonLat([10, 51]),
zoom: 5
})
});
setMap(map)
return () => {
map.setTarget(null)
setMap(null)
}
}, [])
return <>
<div ref={ref} {...props}>
<MapContext.Provider value={map}>
{children}
</MapContext.Provider>
</div>
</>
}
export function TileLayer() {
const map = React.useContext(MapContext)
const layer = React.useMemo(() => new OlTileLayer({
source: new OSM()
}), [])
React.useEffect(() => {
map?.addLayer(layer)
return () => map?.removeLayer(layer)
})
return null
}
Map.TileLayer = TileLayer
export default Map;

View file

@ -1 +1,3 @@
export {default as LoginForm} from './LoginForm'
export {default as Map} from './Map'
export {default as Page} from './Page'

118
src/pages/HomePage.js Normal file
View file

@ -0,0 +1,118 @@
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', backgroundColor: '#FEFEF4'}}>
<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

@ -1,64 +1,19 @@
import React from 'react'
import {connect} from 'react-redux'
import {Form, Button} from 'semantic-ui-react'
import {Redirect} from 'react-router-dom'
import {login as loginAction} from '../reducers/login'
import styles from './LoginPage.module.scss'
import {Page} from '../components'
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')
}
}
import {Page, LoginForm} from '../components'
const LoginPage = connect(
(state) => ({loggedIn: Boolean(state.login)}),
(dispatch) => ({
dispatchLogin: (user) => dispatch(loginAction(user)),
})
)(function LoginPage({loggedIn, dispatchLogin}) {
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,
])
)(function LoginPage({loggedIn}) {
return loggedIn ? (
<Redirect to="/" />
) : (
<Page>
<Form className={styles.loginForm} onSubmit={onSubmit}>
<h2>Login</h2>
<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>
<LoginForm className={styles.loginForm} />
</Page>
)
})

226
src/pages/TrackPage.tsx Normal file
View file

@ -0,0 +1,226 @@
import React from 'react'
import {connect} from 'react-redux'
import {Link} from 'react-router-dom'
import {Segment, Dimmer ,Form, Button, List, Grid, Loader, Header, Comment} from 'semantic-ui-react'
import {useParams} from 'react-router-dom'
import {concat, combineLatest, of, from} from 'rxjs'
import {pluck, distinctUntilChanged, map, switchMap, startWith} from 'rxjs/operators'
import {useObservable} from 'rxjs-hooks'
import {Settings, DateTime, Duration} from 'luxon'
import api from '../api'
import {Map, Page} from '../components'
import type {Track, TrackData, TrackComment} from '../types'
// TODO: remove
Settings.defaultLocale = 'de-DE'
function formatDuration(seconds) {
return Duration.fromMillis((seconds ?? 0) * 1000).toFormat("h'h' mm'm'")
}
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>
}
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>
)
}
function TrackActions({slug}) {
return (
<Button.Group vertical>
<Link to={`/tracks/${slug}/edit`}>
<Button primary>Edit track</Button>
</Link>
</Button.Group>
)
}
function TrackComments({comments, 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>{comment.body}</Comment.Text>
</Comment.Content>
</Comment>
))}
{login && comments != null && <Form reply>
<Form.TextArea rows={4} />
<Button content='Post comment' labelPosition='left' icon='edit' primary />
</Form>}
</Comment.Group>
</Segment>
)
}
const TrackPage = connect((state) => ({login: state.login}))(function TrackPage({login}) {
const {slug} = useParams()
const data: {
track: null | Track
trackData: null | TrackData
comments: null | TrackComments
} | 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.fetch(url)))),
pluck('track')
)
const trackData$ = slug$.pipe(
map((slug) => '/tracks/' + slug + '/data'),
switchMap((url) => concat(of(null), from(api.fetch(url)))),
pluck('trackData'),
startWith(null) // show track infos before track data is loaded
)
const comments$ = slug$.pipe(
map((slug) => '/tracks/' + slug + '/comments'),
switchMap((url) => concat(of(null), from(api.fetch(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 isAuthor = login?.username === data?.track?.author?.username
const {track, trackData, comments} = data || {}
const loading = track == null || trackData == null
return (
<Page>
<Grid stackable>
<Grid.Row>
<Grid.Column width={12}>
<div style={{position: 'relative'}}>
<Loader active={loading} />
<Dimmer.Dimmable blurring dimmed={loading}>
<Map style={{height: '60vh', minHeight: 400}}>
<Map.TileLayer />
</Map>
</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>
</Grid.Column>
</Grid.Row>
</Grid>
<TrackComments {...{hideLoader: loading, comments, login}} />
{/* <pre>{JSON.stringify(data, null, 2)}</pre> */}
</Page>
)
})
export default TrackPage

115
src/pages/TracksPage.tsx Normal file
View file

@ -0,0 +1,115 @@
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, debounceTime} from 'rxjs/operators'
import _ from 'lodash'
import type {Track} from '../types'
import {Page} from '../components'
import api from '../api'
import {useQueryParam, stringifyParams} 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 params = {limit: pageSize, offset: pageSize * (page - 1)}
return {url, params}
}),
distinctUntilChanged(_.isEqual),
switchMap((request) => concat(of(null), from(api.fetch(request.url + '?' + stringifyParams(request.params)))))
),
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>
)
}
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>{track.description}</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

@ -1,3 +1,6 @@
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'

View file

@ -1,5 +1,5 @@
import {useMemo} from 'react'
import {useHistory} from 'react-router-dom'
import {useHistory, useLocation} from 'react-router-dom'
type QueryValue = string | number
type QueryParams = {[key: string]: QueryValue}
@ -52,6 +52,7 @@ export function useQueryParam<T extends QueryValue>(
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
}

45
src/types.ts Normal file
View file

@ -0,0 +1,45 @@
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
}