frontend: Track editor and redirects on errors

This commit is contained in:
Paul Bienkowski 2021-02-26 21:57:36 +01:00
parent eef5deca70
commit 7ab7e4918e
9 changed files with 248 additions and 44 deletions

View file

@ -11,6 +11,7 @@ import {
LogoutPage, LogoutPage,
NotFoundPage, NotFoundPage,
SettingsPage, SettingsPage,
TrackEditor,
TrackPage, TrackPage,
TracksPage, TracksPage,
UploadPage, UploadPage,
@ -40,7 +41,7 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
<Link to="/">Home</Link> <Link to="/">Home</Link>
</li> </li>
<li> <li>
<Link to="/feed">Tracks</Link> <Link to="/tracks">Tracks</Link>
</li> </li>
<li> <li>
<a href="https://openbikesensor.org/" target="_blank" rel="noreferrer"> <a href="https://openbikesensor.org/" target="_blank" rel="noreferrer">
@ -76,15 +77,18 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
<Route path="/" exact> <Route path="/" exact>
<HomePage /> <HomePage />
</Route> </Route>
<Route path="/feed" exact> <Route path="/tracks" exact>
<TracksPage /> <TracksPage />
</Route> </Route>
<Route path="/feed/my" exact> <Route path="/my/tracks" exact>
<TracksPage privateFeed /> <TracksPage privateTracks />
</Route> </Route>
<Route path={`/tracks/:slug`} exact> <Route path={`/tracks/:slug`} exact>
<TrackPage /> <TrackPage />
</Route> </Route>
<Route path={`/tracks/:slug/edit`} exact>
<TrackEditor />
</Route>
<Route path="/redirect" exact> <Route path="/redirect" exact>
<LoginRedirectPage /> <LoginRedirectPage />
</Route> </Route>

View file

@ -198,6 +198,8 @@ class API {
if (response.status === 200) { if (response.status === 200) {
return json return json
} else if (response.status === 204) {
return null
} else { } else {
throw new RequestError('Error code ' + response.status, json?.errors) throw new RequestError('Error code ' + response.status, json?.errors)
} }

View file

@ -1,9 +1,9 @@
import _ from 'lodash'
import React from 'react' import React from 'react'
import {Link} from 'react-router-dom'
import {Message, Grid, Loader, Statistic, Segment, Header, Item} from 'semantic-ui-react' import {Message, Grid, Loader, Statistic, Segment, Header, Item} from 'semantic-ui-react'
import {useObservable} from 'rxjs-hooks' import {useObservable} from 'rxjs-hooks'
import {of, pipe, from} from 'rxjs' import {of, from} from 'rxjs'
import {map, switchMap, distinctUntilChanged} from 'rxjs/operators' import {map, switchMap} from 'rxjs/operators'
import {fromLonLat} from 'ol/proj' import {fromLonLat} from 'ol/proj'
import {Duration} from 'luxon' import {Duration} from 'luxon'
@ -30,10 +30,9 @@ function WelcomeMap() {
function Stats() { function Stats() {
const stats = useObservable( const stats = useObservable(
pipe( () => of(null).pipe(
distinctUntilChanged(_.isEqual),
switchMap(() => api.fetch('/stats')) switchMap(() => api.fetch('/stats'))
) ),
) )
return ( return (
@ -82,7 +81,9 @@ function MostRecentTrack() {
<h2>Most recent track</h2> <h2>Most recent track</h2>
<Loader active={track === null} /> <Loader active={track === null} />
{track === undefined ? ( {track === undefined ? (
<Message>No track uploaded yet. Be the first!</Message> <Message>
No track uploaded yet. <Link to="/upload">Be the first!</Link>
</Message>
) : track ? ( ) : track ? (
<Item.Group> <Item.Group>
<TrackListItem track={track} /> <TrackListItem track={track} />

View file

@ -6,10 +6,8 @@ import {useForm} from 'react-hook-form'
import {setLogin} from 'reducers/login' import {setLogin} from 'reducers/login'
import {Page} from 'components' import {Page} from 'components'
import api from 'api' import api from 'api'
import {findInput} from 'utils'
function findInput(register) {
return (element) => register(element ? element.querySelector('input, textarea, select') : null)
}
const SettingsPage = connect((state) => ({login: state.login}), {setLogin})(function SettingsPage({login, setLogin}) { const SettingsPage = connect((state) => ({login: state.login}), {setLogin})(function SettingsPage({login, setLogin}) {
const {register, handleSubmit} = useForm() const {register, handleSubmit} = useForm()

View file

@ -0,0 +1,154 @@
import React from 'react'
import _ from 'lodash'
import {connect} from 'react-redux'
import {Confirm, Grid, Button, Icon, Popup, Form, Ref, TextArea, Checkbox} from 'semantic-ui-react'
import {useHistory, useParams} from 'react-router-dom'
import {concat, of, from} from 'rxjs'
import {pluck, distinctUntilChanged, map, switchMap} from 'rxjs/operators'
import {useObservable} from 'rxjs-hooks'
import {findInput} from 'utils'
import {useForm, Controller} from 'react-hook-form'
import api from 'api'
import {Page} from 'components'
import type {Track} from 'types'
const TrackEditor = connect((state) => ({login: state.login}))(function TrackEditor({login}) {
const [busy, setBusy] = React.useState(false)
const {register, control, handleSubmit} = useForm()
const {slug} = useParams()
const history = useHistory()
const track: null | Track = useObservable(
(_$, args$) => {
const slug$ = args$.pipe(pluck(0), distinctUntilChanged())
return slug$.pipe(
map((slug) => `/tracks/${slug}`),
switchMap((url) => concat(of(null), from(api.get(url)))),
pluck('track')
)
},
null,
[slug]
)
const loading = busy || track == null
const isAuthor = login?.username === track?.author?.username
// Navigate to track detials if we are not the author
React.useEffect(() => {
if (!login || (track && !isAuthor)) {
history.replace(`/tracks/${slug}`)
}
}, [slug, login, track, isAuthor, history])
const onSubmit = React.useMemo(
() =>
handleSubmit(async (values) => {
setBusy(true)
try {
await api.put(`/tracks/${slug}`, {body: {track: _.pickBy(values, (v) => typeof v !== 'undefined')}})
history.push(`/tracks/${slug}`)
} finally {
setBusy(false)
}
}),
[slug, handleSubmit, history]
)
const [confirmDelete, setConfirmDelete] = React.useState(false)
const onDelete = React.useCallback(async () => {
setBusy(true)
try {
await api.delete(`/tracks/${slug}`)
history.push('/tracks')
} finally {
setConfirmDelete(false)
setBusy(false)
}
}, [setBusy, setConfirmDelete, slug, history])
return (
<Page>
<Grid centered relaxed divided>
<Grid.Row>
<Grid.Column width={8}>
<h2>Edit track</h2>
<Form loading={loading} key={track?.slug} onSubmit={onSubmit}>
<Ref innerRef={findInput(register)}>
<Form.Input label="Title" name="title" defaultValue={track?.title} style={{fontSize: '120%'}} />
</Ref>
<Form.Field>
<label>Description</label>
<Ref innerRef={register}>
<TextArea name="description" rows={4} defaultValue={track?.description} />
</Ref>
</Form.Field>
<Form.Field>
<label>Visibility</label>
<Controller
name="visible"
control={control}
defaultValue={track?.visible}
render={(props) => (
<Checkbox
name="visible"
label="Make track visible in public track list"
checked={props.value}
onChange={(_, {checked}) => props.onChange(checked)}
/>
)}
/>
<Popup
wide="very"
content={
<>
<p>
Checking this box allows all users to see your full track. For your own privacy and security,
make sure to only publish tracks in this way that do not let others deduce where you live, work,
or frequently stay. Your recording device might have useful privacy settings to not record
geolocation data near those places.
</p>
<p>
In the future, this site will allow you to redact privacy sensitive data in tracks, both
manually and automatically. Until then, you will have to rely on the features of your recording
device, or manually redact your files before upload.
</p>
<p>
After checking this box, your data essentially becomes public. You understand that we cannot
control who potentially downloads this data and and keeps a copy, even if you delete it from
your account or anonymize it later.
</p>
<p>
<b>Use at your own risk.</b>
</p>
</>
}
trigger={<Icon name="warning sign" style={{marginLeft: 8}} color="orange" />}
/>
</Form.Field>
<Button type="submit">Save</Button>
</Form>
</Grid.Column>
<Grid.Column width={4}>
<h2>Danger zone</h2>
<p>
You can remove this track from your account and the portal if you like. However, if at any point you have
published this track, we cannot guarantee that there are no versions of it in the public data repository,
or any copy thereof.
</p>
<Button color="red" onClick={() => setConfirmDelete(true)}>Delete</Button>
<Confirm open={confirmDelete} onCancel={() => setConfirmDelete(false)} onConfirm={onDelete} />
</Grid.Column>
</Grid.Row>
</Grid>
</Page>
)
})
export default TrackEditor

View file

@ -1,9 +1,9 @@
import React from 'react' import React from 'react'
import {connect} from 'react-redux' import {connect} from 'react-redux'
import {Table, Checkbox, Segment, Dimmer, Grid, Loader, Header} from 'semantic-ui-react' import {Table, Checkbox, Segment, Dimmer, Grid, Loader, Header} from 'semantic-ui-react'
import {useParams} from 'react-router-dom' import {useParams, useHistory} from 'react-router-dom'
import {concat, combineLatest, of, from, Subject} from 'rxjs' import {concat, combineLatest, of, from, Subject} from 'rxjs'
import {pluck, distinctUntilChanged, map, switchMap, startWith} from 'rxjs/operators' import {pluck, distinctUntilChanged, map, switchMap, startWith, catchError} from 'rxjs/operators'
import {useObservable} from 'rxjs-hooks' import {useObservable} from 'rxjs-hooks'
import Markdown from 'react-markdown' import Markdown from 'react-markdown'
@ -26,6 +26,7 @@ const TrackPage = connect((state) => ({login: state.login}))(function TrackPage(
const {slug} = useParams() const {slug} = useParams()
const [reloadComments, reloadComments$] = useTriggerSubject() const [reloadComments, reloadComments$] = useTriggerSubject()
const history = useHistory()
const data: { const data: {
track: null | Track track: null | Track
@ -36,13 +37,31 @@ const TrackPage = connect((state) => ({login: state.login}))(function TrackPage(
const slug$ = args$.pipe(pluck(0), distinctUntilChanged()) const slug$ = args$.pipe(pluck(0), distinctUntilChanged())
const track$ = slug$.pipe( const track$ = slug$.pipe(
map((slug) => `/tracks/${slug}`), map((slug) => `/tracks/${slug}`),
switchMap((url) => concat(of(null), from(api.get(url)))), switchMap((url) =>
concat(
of(null),
from(api.get(url)).pipe(
catchError(() => {
history.replace('/tracks')
})
)
)
),
pluck('track') pluck('track')
) )
const trackData$ = slug$.pipe( const trackData$ = slug$.pipe(
map((slug) => `/tracks/${slug}/data`), map((slug) => `/tracks/${slug}/data`),
switchMap((url) => concat(of(null), from(api.get(url)))), switchMap((url) =>
concat(
of(null),
from(api.get(url)).pipe(
catchError(() => {
history.replace('/tracks')
})
)
)
),
pluck('trackData'), pluck('trackData'),
startWith(null) // show track infos before track data is loaded startWith(null) // show track infos before track data is loaded
) )
@ -50,7 +69,13 @@ const TrackPage = connect((state) => ({login: state.login}))(function TrackPage(
const comments$ = concat(of(null), reloadComments$).pipe( const comments$ = concat(of(null), reloadComments$).pipe(
switchMap(() => slug$), switchMap(() => slug$),
map((slug) => `/tracks/${slug}/comments`), map((slug) => `/tracks/${slug}/comments`),
switchMap((url) => api.get(url)), switchMap((url) =>
from(api.get(url)).pipe(
catchError(() => {
history.replace('/tracks')
})
)
),
pluck('comments'), pluck('comments'),
startWith(null) // show track infos before comments are loaded startWith(null) // show track infos before comments are loaded
) )
@ -63,17 +88,23 @@ const TrackPage = connect((state) => ({login: state.login}))(function TrackPage(
[slug] [slug]
) )
const onSubmitComment = React.useCallback(async ({body}) => { const onSubmitComment = React.useCallback(
async ({body}) => {
await api.post(`/tracks/${slug}/comments`, { await api.post(`/tracks/${slug}/comments`, {
body: {comment: {body}}, body: {comment: {body}},
}) })
reloadComments() reloadComments()
}, [slug, reloadComments]) },
[slug, reloadComments]
)
const onDeleteComment = React.useCallback(async (id) => { const onDeleteComment = React.useCallback(
async (id) => {
await api.delete(`/tracks/${slug}/comments/${id}`) await api.delete(`/tracks/${slug}/comments/${id}`)
reloadComments() reloadComments()
}, [slug, reloadComments]) },
[slug, reloadComments]
)
const isAuthor = login?.username === data?.track?.author?.username const isAuthor = login?.username === data?.track?.author?.username

View file

@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import {connect} from 'react-redux' import {connect} from 'react-redux'
import {Item, Tab, Loader, Pagination, Icon} from 'semantic-ui-react' import {Message, Item, Tab, Loader, Pagination, Icon} from 'semantic-ui-react'
import {useObservable} from 'rxjs-hooks' import {useObservable} from 'rxjs-hooks'
import {Link, useHistory, useRouteMatch} from 'react-router-dom' import {Link, useHistory, useRouteMatch} from 'react-router-dom'
import {of, from, concat} from 'rxjs' import {of, from, concat} from 'rxjs'
@ -16,8 +16,8 @@ function TracksPageTabs() {
const history = useHistory() const history = useHistory()
const panes = React.useMemo( const panes = React.useMemo(
() => [ () => [
{menuItem: 'Public tracks', url: '/feed'}, {menuItem: 'Public tracks', url: '/tracks'},
{menuItem: 'My tracks', url: '/feed/my'}, {menuItem: 'My tracks', url: '/my/tracks'},
], ],
[] []
) )
@ -29,13 +29,13 @@ function TracksPageTabs() {
[history, panes] [history, panes]
) )
const isFeedPage = useRouteMatch('/feed/my') const isOwnTracksPage = useRouteMatch('/my/tracks')
const activeIndex = isFeedPage ? 1 : 0 const activeIndex = isOwnTracksPage ? 1 : 0
return <Tab menu={{secondary: true, pointing: true}} {...{panes, onTabChange, activeIndex}} /> return <Tab menu={{secondary: true, pointing: true}} {...{panes, onTabChange, activeIndex}} />
} }
function TrackList({privateFeed}: {privateFeed: boolean}) { function TrackList({privateTracks}: {privateTracks: boolean}) {
const [page, setPage] = useQueryParam<number>('page', 1, Number) const [page, setPage] = useQueryParam<number>('page', 1, Number)
const pageSize = 10 const pageSize = 10
@ -46,8 +46,8 @@ function TrackList({privateFeed}: {privateFeed: boolean}) {
} | null = useObservable( } | null = useObservable(
(_$, inputs$) => (_$, inputs$) =>
inputs$.pipe( inputs$.pipe(
map(([page, privateFeed]) => { map(([page, privateTracks]) => {
const url = '/tracks' + (privateFeed ? '/feed' : '') const url = '/tracks' + (privateTracks ? '/feed' : '')
const query = {limit: pageSize, offset: pageSize * (page - 1)} const query = {limit: pageSize, offset: pageSize * (page - 1)}
return {url, query} return {url, query}
}), }),
@ -55,7 +55,7 @@ function TrackList({privateFeed}: {privateFeed: boolean}) {
switchMap((request) => concat(of(null), from(api.get(request.url, {query: request.query})))) switchMap((request) => concat(of(null), from(api.get(request.url, {query: request.query}))))
), ),
null, null,
[page, privateFeed] [page, privateTracks]
) )
const {tracks, tracksCount} = data || {tracks: [], tracksCount: 0} const {tracks, tracksCount} = data || {tracks: [], tracksCount: 0}
@ -73,12 +73,16 @@ function TrackList({privateFeed}: {privateFeed: boolean}) {
/> />
)} )}
{tracks && ( {tracks && tracks.length ? (
<Item.Group divided> <Item.Group divided>
{tracks.map((track: Track) => ( {tracks.map((track: Track) => (
<TrackListItem key={track.slug} {...{track, privateFeed}} /> <TrackListItem key={track.slug} {...{track, privateTracks}} />
))} ))}
</Item.Group> </Item.Group>
) : (
<Message>
No track uploaded yet. <Link to="/upload">Be the first!</Link>
</Message>
)} )}
</div> </div>
) )
@ -92,7 +96,7 @@ function maxLength(t, max) {
} }
} }
export function TrackListItem({track, privateFeed = false}) { export function TrackListItem({track, privateTracks = false}) {
return ( return (
<Item key={track.slug}> <Item key={track.slug}>
<Item.Image size="tiny" src={track.author.image} /> <Item.Image size="tiny" src={track.author.image} />
@ -106,7 +110,7 @@ export function TrackListItem({track, privateFeed = false}) {
<Item.Description> <Item.Description>
<StripMarkdown>{maxLength(track.description, 200)}</StripMarkdown> <StripMarkdown>{maxLength(track.description, 200)}</StripMarkdown>
</Item.Description> </Item.Description>
{privateFeed && ( {privateTracks && (
<Item.Extra> <Item.Extra>
{track.visible ? ( {track.visible ? (
<> <>
@ -124,11 +128,11 @@ export function TrackListItem({track, privateFeed = false}) {
) )
} }
const TracksPage = connect((state) => ({login: (state as any).login}))(function TracksPage({login, privateFeed}) { const TracksPage = connect((state) => ({login: (state as any).login}))(function TracksPage({login, privateTracks}) {
return ( return (
<Page> <Page>
{login ? <TracksPageTabs /> : null} {login ? <TracksPageTabs /> : null}
<TrackList {...{privateFeed}} /> <TrackList {...{privateTracks}} />
</Page> </Page>
) )
}) })

View file

@ -3,6 +3,7 @@ export {default as LoginRedirectPage} from './LoginRedirectPage'
export {default as LogoutPage} from './LogoutPage' export {default as LogoutPage} from './LogoutPage'
export {default as NotFoundPage} from './NotFoundPage' export {default as NotFoundPage} from './NotFoundPage'
export {default as SettingsPage} from './SettingsPage' export {default as SettingsPage} from './SettingsPage'
export {default as TrackEditor} from './TrackEditor'
export {default as TrackPage} from './TrackPage' export {default as TrackPage} from './TrackPage'
export {default as TracksPage} from './TracksPage' export {default as TracksPage} from './TracksPage'
export {default as UploadPage} from './UploadPage' export {default as UploadPage} from './UploadPage'

9
frontend/src/utils.js Normal file
View file

@ -0,0 +1,9 @@
// Wraps the register callback from useForm into a new ref function, such that
// any child of the provided element that is an input component will be
// registered.
export function findInput(register) {
return (element) => {
const found = element ? element.querySelector('input, textarea, select, checkbox') : null
register(found)
}
}