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,
NotFoundPage,
SettingsPage,
TrackEditor,
TrackPage,
TracksPage,
UploadPage,
@ -40,7 +41,7 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
<Link to="/">Home</Link>
</li>
<li>
<Link to="/feed">Tracks</Link>
<Link to="/tracks">Tracks</Link>
</li>
<li>
<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>
<HomePage />
</Route>
<Route path="/feed" exact>
<Route path="/tracks" exact>
<TracksPage />
</Route>
<Route path="/feed/my" exact>
<TracksPage privateFeed />
<Route path="/my/tracks" exact>
<TracksPage privateTracks />
</Route>
<Route path={`/tracks/:slug`} exact>
<TrackPage />
</Route>
<Route path={`/tracks/:slug/edit`} exact>
<TrackEditor />
</Route>
<Route path="/redirect" exact>
<LoginRedirectPage />
</Route>

View file

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

View file

@ -1,9 +1,9 @@
import _ from 'lodash'
import React from 'react'
import {Link} from 'react-router-dom'
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 {of, from} from 'rxjs'
import {map, switchMap} from 'rxjs/operators'
import {fromLonLat} from 'ol/proj'
import {Duration} from 'luxon'
@ -30,10 +30,9 @@ function WelcomeMap() {
function Stats() {
const stats = useObservable(
pipe(
distinctUntilChanged(_.isEqual),
() => of(null).pipe(
switchMap(() => api.fetch('/stats'))
)
),
)
return (
@ -82,7 +81,9 @@ function MostRecentTrack() {
<h2>Most recent track</h2>
<Loader active={track === null} />
{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 ? (
<Item.Group>
<TrackListItem track={track} />

View file

@ -6,10 +6,8 @@ import {useForm} from 'react-hook-form'
import {setLogin} from 'reducers/login'
import {Page} from 'components'
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 {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 {connect} from 'react-redux'
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 {pluck, distinctUntilChanged, map, switchMap, startWith} from 'rxjs/operators'
import {pluck, distinctUntilChanged, map, switchMap, startWith, catchError} from 'rxjs/operators'
import {useObservable} from 'rxjs-hooks'
import Markdown from 'react-markdown'
@ -26,6 +26,7 @@ const TrackPage = connect((state) => ({login: state.login}))(function TrackPage(
const {slug} = useParams()
const [reloadComments, reloadComments$] = useTriggerSubject()
const history = useHistory()
const data: {
track: null | Track
@ -36,13 +37,31 @@ const TrackPage = connect((state) => ({login: state.login}))(function TrackPage(
const slug$ = args$.pipe(pluck(0), distinctUntilChanged())
const track$ = slug$.pipe(
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')
)
const trackData$ = slug$.pipe(
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'),
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(
switchMap(() => slug$),
map((slug) => `/tracks/${slug}/comments`),
switchMap((url) => api.get(url)),
switchMap((url) =>
from(api.get(url)).pipe(
catchError(() => {
history.replace('/tracks')
})
)
),
pluck('comments'),
startWith(null) // show track infos before comments are loaded
)
@ -63,17 +88,23 @@ const TrackPage = connect((state) => ({login: state.login}))(function TrackPage(
[slug]
)
const onSubmitComment = React.useCallback(async ({body}) => {
await api.post(`/tracks/${slug}/comments`, {
body: {comment: {body}},
})
reloadComments()
}, [slug, reloadComments])
const onSubmitComment = React.useCallback(
async ({body}) => {
await api.post(`/tracks/${slug}/comments`, {
body: {comment: {body}},
})
reloadComments()
},
[slug, reloadComments]
)
const onDeleteComment = React.useCallback(async (id) => {
await api.delete(`/tracks/${slug}/comments/${id}`)
reloadComments()
}, [slug, reloadComments])
const onDeleteComment = React.useCallback(
async (id) => {
await api.delete(`/tracks/${slug}/comments/${id}`)
reloadComments()
},
[slug, reloadComments]
)
const isAuthor = login?.username === data?.track?.author?.username

View file

@ -1,6 +1,6 @@
import React from 'react'
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 {Link, useHistory, useRouteMatch} from 'react-router-dom'
import {of, from, concat} from 'rxjs'
@ -16,8 +16,8 @@ function TracksPageTabs() {
const history = useHistory()
const panes = React.useMemo(
() => [
{menuItem: 'Public tracks', url: '/feed'},
{menuItem: 'My tracks', url: '/feed/my'},
{menuItem: 'Public tracks', url: '/tracks'},
{menuItem: 'My tracks', url: '/my/tracks'},
],
[]
)
@ -29,13 +29,13 @@ function TracksPageTabs() {
[history, panes]
)
const isFeedPage = useRouteMatch('/feed/my')
const activeIndex = isFeedPage ? 1 : 0
const isOwnTracksPage = useRouteMatch('/my/tracks')
const activeIndex = isOwnTracksPage ? 1 : 0
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 pageSize = 10
@ -46,8 +46,8 @@ function TrackList({privateFeed}: {privateFeed: boolean}) {
} | null = useObservable(
(_$, inputs$) =>
inputs$.pipe(
map(([page, privateFeed]) => {
const url = '/tracks' + (privateFeed ? '/feed' : '')
map(([page, privateTracks]) => {
const url = '/tracks' + (privateTracks ? '/feed' : '')
const query = {limit: pageSize, offset: pageSize * (page - 1)}
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}))))
),
null,
[page, privateFeed]
[page, privateTracks]
)
const {tracks, tracksCount} = data || {tracks: [], tracksCount: 0}
@ -73,12 +73,16 @@ function TrackList({privateFeed}: {privateFeed: boolean}) {
/>
)}
{tracks && (
{tracks && tracks.length ? (
<Item.Group divided>
{tracks.map((track: Track) => (
<TrackListItem key={track.slug} {...{track, privateFeed}} />
<TrackListItem key={track.slug} {...{track, privateTracks}} />
))}
</Item.Group>
) : (
<Message>
No track uploaded yet. <Link to="/upload">Be the first!</Link>
</Message>
)}
</div>
)
@ -92,7 +96,7 @@ function maxLength(t, max) {
}
}
export function TrackListItem({track, privateFeed = false}) {
export function TrackListItem({track, privateTracks = false}) {
return (
<Item key={track.slug}>
<Item.Image size="tiny" src={track.author.image} />
@ -106,7 +110,7 @@ export function TrackListItem({track, privateFeed = false}) {
<Item.Description>
<StripMarkdown>{maxLength(track.description, 200)}</StripMarkdown>
</Item.Description>
{privateFeed && (
{privateTracks && (
<Item.Extra>
{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 (
<Page>
{login ? <TracksPageTabs /> : null}
<TrackList {...{privateFeed}} />
<TrackList {...{privateTracks}} />
</Page>
)
})

View file

@ -3,6 +3,7 @@ export {default as LoginRedirectPage} from './LoginRedirectPage'
export {default as LogoutPage} from './LogoutPage'
export {default as NotFoundPage} from './NotFoundPage'
export {default as SettingsPage} from './SettingsPage'
export {default as TrackEditor} from './TrackEditor'
export {default as TrackPage} from './TrackPage'
export {default as TracksPage} from './TracksPage'
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)
}
}