frontend: Track editor and redirects on errors
This commit is contained in:
parent
eef5deca70
commit
7ab7e4918e
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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()
|
||||
|
|
154
frontend/src/pages/TrackEditor.tsx
Normal file
154
frontend/src/pages/TrackEditor.tsx
Normal 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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
|
@ -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
9
frontend/src/utils.js
Normal 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)
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue