Track page
This commit is contained in:
parent
c1186e4074
commit
ec2d5bcf77
162
package-lock.json
generated
162
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
144
src/App.js
144
src/App.js
|
@ -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>
|
||||
|
|
58
src/components/LoginForm.js
Normal file
58
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
|
62
src/components/Map/index.js
Normal file
62
src/components/Map/index.js
Normal 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;
|
|
@ -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
118
src/pages/HomePage.js
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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
226
src/pages/TrackPage.tsx
Normal 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
115
src/pages/TracksPage.tsx
Normal 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
|
|
@ -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'
|
||||
|
|
|
@ -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
45
src/types.ts
Normal 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
|
||||
}
|
||||
|
Loading…
Reference in a new issue