Reorder Trackpage
This commit is contained in:
parent
fe7d7ce274
commit
ab6cc6f6d0
|
@ -22,7 +22,7 @@ function CommentForm({onSubmit}) {
|
||||||
|
|
||||||
export default function TrackComments({comments, onSubmit, onDelete, login, hideLoader}) {
|
export default function TrackComments({comments, onSubmit, onDelete, login, hideLoader}) {
|
||||||
return (
|
return (
|
||||||
<Segment basic>
|
<>
|
||||||
<Comment.Group>
|
<Comment.Group>
|
||||||
<Header as="h2" dividing>
|
<Header as="h2" dividing>
|
||||||
Comments
|
Comments
|
||||||
|
@ -63,6 +63,6 @@ export default function TrackComments({comments, onSubmit, onDelete, login, hide
|
||||||
|
|
||||||
{login && comments != null && <CommentForm onSubmit={onSubmit} />}
|
{login && comments != null && <CommentForm onSubmit={onSubmit} />}
|
||||||
</Comment.Group>
|
</Comment.Group>
|
||||||
</Segment>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,7 @@ function formatDuration(seconds) {
|
||||||
|
|
||||||
export default function TrackDetails({track, isAuthor}) {
|
export default function TrackDetails({track, isAuthor}) {
|
||||||
return (
|
return (
|
||||||
<List>
|
<List horizontal relaxed>
|
||||||
{track.public != null && isAuthor && (
|
{track.public != null && isAuthor && (
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<List.Header>Visibility</List.Header>
|
<List.Header>Visibility</List.Header>
|
||||||
|
|
|
@ -12,7 +12,6 @@
|
||||||
top: 16px;
|
top: 16px;
|
||||||
right: 16px;
|
right: 16px;
|
||||||
max-height: calc(100% - 32px);
|
max-height: calc(100% - 32px);
|
||||||
overflow: auto;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,43 +1,91 @@
|
||||||
import React from 'react'
|
import React from "react";
|
||||||
import {connect} from 'react-redux'
|
import { connect } from "react-redux";
|
||||||
import {List, Dropdown, Checkbox, Segment, Dimmer, Grid, Loader, Header, Message, Confirm} from 'semantic-ui-react'
|
import {
|
||||||
import {useParams, useHistory} from 'react-router-dom'
|
List,
|
||||||
import {concat, combineLatest, of, from, Subject} from 'rxjs'
|
Dropdown,
|
||||||
import {pluck, distinctUntilChanged, map, switchMap, startWith, catchError} from 'rxjs/operators'
|
Checkbox,
|
||||||
import {useObservable} from 'rxjs-hooks'
|
Segment,
|
||||||
import Markdown from 'react-markdown'
|
Dimmer,
|
||||||
|
Grid,
|
||||||
|
Loader,
|
||||||
|
Header,
|
||||||
|
Message,
|
||||||
|
Confirm,
|
||||||
|
Container,
|
||||||
|
} from "semantic-ui-react";
|
||||||
|
import { useParams, useHistory } from "react-router-dom";
|
||||||
|
import { concat, combineLatest, of, from, Subject } from "rxjs";
|
||||||
|
import {
|
||||||
|
pluck,
|
||||||
|
distinctUntilChanged,
|
||||||
|
map,
|
||||||
|
switchMap,
|
||||||
|
startWith,
|
||||||
|
catchError,
|
||||||
|
} from "rxjs/operators";
|
||||||
|
import { useObservable } from "rxjs-hooks";
|
||||||
|
import Markdown from "react-markdown";
|
||||||
|
|
||||||
import api from 'api'
|
import api from "api";
|
||||||
import {Page} from 'components'
|
import { Page } from "components";
|
||||||
import type {Track, TrackData, TrackComment} from 'types'
|
import type { Track, TrackData, TrackComment } from "types";
|
||||||
import {trackLayer, trackLayerRaw} from '../../mapstyles'
|
import { trackLayer, trackLayerRaw } from "../../mapstyles";
|
||||||
|
|
||||||
import TrackActions from './TrackActions'
|
import TrackActions from "./TrackActions";
|
||||||
import TrackComments from './TrackComments'
|
import TrackComments from "./TrackComments";
|
||||||
import TrackDetails from './TrackDetails'
|
import TrackDetails from "./TrackDetails";
|
||||||
import TrackMap from './TrackMap'
|
import TrackMap from "./TrackMap";
|
||||||
|
|
||||||
import styles from './TrackPage.module.less'
|
import styles from "./TrackPage.module.less";
|
||||||
|
|
||||||
function useTriggerSubject() {
|
function useTriggerSubject() {
|
||||||
const subject$ = React.useMemo(() => new Subject(), [])
|
const subject$ = React.useMemo(() => new Subject(), []);
|
||||||
const trigger = React.useCallback(() => subject$.next(null), [subject$])
|
const trigger = React.useCallback(() => subject$.next(null), [subject$]);
|
||||||
return [trigger, subject$]
|
return [trigger, subject$];
|
||||||
}
|
}
|
||||||
|
|
||||||
function TrackMapSettings({showTrack, setShowTrack, pointsMode, setPointsMode, side, setSide}) {
|
function TrackMapSettings({
|
||||||
|
showTrack,
|
||||||
|
setShowTrack,
|
||||||
|
pointsMode,
|
||||||
|
setPointsMode,
|
||||||
|
side,
|
||||||
|
setSide,
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header as="h4">Map settings</Header>
|
<Header as="h4">Map settings</Header>
|
||||||
<List>
|
<List>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<Checkbox checked={showTrack} onChange={(e, d) => setShowTrack(d.checked)} /> Show track
|
<Checkbox
|
||||||
<div style={{marginTop: 8}}>
|
checked={showTrack}
|
||||||
<span style={{borderTop: '3px dashed ' + trackLayerRaw.paint['line-color'], height: 0, width: 24, display: 'inline-block', verticalAlign: 'middle', marginRight: 4}} />
|
onChange={(e, d) => setShowTrack(d.checked)}
|
||||||
|
/>{" "}
|
||||||
|
Show track
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
borderTop: "3px dashed " + trackLayerRaw.paint["line-color"],
|
||||||
|
height: 0,
|
||||||
|
width: 24,
|
||||||
|
display: "inline-block",
|
||||||
|
verticalAlign: "middle",
|
||||||
|
marginRight: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
GPS track
|
GPS track
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span style={{borderTop: '6px solid ' + trackLayerRaw.paint['line-color'], height: 6, width: 24, display: 'inline-block', verticalAlign: 'middle', marginRight: 4}} />
|
<span
|
||||||
|
style={{
|
||||||
|
borderTop: "6px solid " + trackLayerRaw.paint["line-color"],
|
||||||
|
height: 6,
|
||||||
|
width: 24,
|
||||||
|
display: "inline-block",
|
||||||
|
verticalAlign: "middle",
|
||||||
|
marginRight: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
Snapped to road
|
Snapped to road
|
||||||
</div>
|
</div>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
|
@ -48,9 +96,17 @@ function TrackMapSettings({showTrack, setShowTrack, pointsMode, setPointsMode, s
|
||||||
value={pointsMode}
|
value={pointsMode}
|
||||||
onChange={(e, d) => setPointsMode(d.value)}
|
onChange={(e, d) => setPointsMode(d.value)}
|
||||||
options={[
|
options={[
|
||||||
{key: 'none', value: 'none', text: 'None'},
|
{ key: "none", value: "none", text: "None" },
|
||||||
{key: 'overtakingEvents', value: 'overtakingEvents', text: 'Confirmed'},
|
{
|
||||||
{key: 'measurements', value: 'measurements', text: 'All measurements'},
|
key: "overtakingEvents",
|
||||||
|
value: "overtakingEvents",
|
||||||
|
text: "Confirmed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "measurements",
|
||||||
|
value: "measurements",
|
||||||
|
text: "All measurements",
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
|
@ -61,204 +117,242 @@ function TrackMapSettings({showTrack, setShowTrack, pointsMode, setPointsMode, s
|
||||||
value={side}
|
value={side}
|
||||||
onChange={(e, d) => setSide(d.value)}
|
onChange={(e, d) => setSide(d.value)}
|
||||||
options={[
|
options={[
|
||||||
{key: 'overtaker', value: 'overtaker', text: 'Overtaker (Left)'},
|
{
|
||||||
{key: 'stationary', value: 'stationary', text: 'Stationary (Right)'},
|
key: "overtaker",
|
||||||
|
value: "overtaker",
|
||||||
|
text: "Overtaker (Left)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "stationary",
|
||||||
|
value: "stationary",
|
||||||
|
text: "Stationary (Right)",
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
</List>
|
</List>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const TrackPage = connect((state) => ({login: state.login}))(function TrackPage({login}) {
|
const TrackPage = connect((state) => ({ login: state.login }))(
|
||||||
const {slug} = useParams()
|
function TrackPage({ login }) {
|
||||||
|
const { slug } = useParams();
|
||||||
|
|
||||||
const [reloadComments, reloadComments$] = useTriggerSubject()
|
const [reloadComments, reloadComments$] = useTriggerSubject();
|
||||||
const history = useHistory()
|
const history = useHistory();
|
||||||
|
|
||||||
const data: {
|
const data: {
|
||||||
track: null | Track
|
track: null | Track;
|
||||||
trackData: null | TrackData
|
trackData: null | TrackData;
|
||||||
comments: null | TrackComment[]
|
comments: null | TrackComment[];
|
||||||
} | null = useObservable(
|
} | null = useObservable(
|
||||||
(_$, args$) => {
|
(_$, args$) => {
|
||||||
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) =>
|
switchMap((url) =>
|
||||||
concat(
|
concat(
|
||||||
of(null),
|
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(undefined),
|
||||||
|
from(api.get(url)).pipe(
|
||||||
|
catchError(() => {
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
startWith(undefined) // show track infos before track data is loaded
|
||||||
|
);
|
||||||
|
|
||||||
|
const comments$ = concat(of(null), reloadComments$).pipe(
|
||||||
|
switchMap(() => slug$),
|
||||||
|
map((slug) => `/tracks/${slug}/comments`),
|
||||||
|
switchMap((url) =>
|
||||||
from(api.get(url)).pipe(
|
from(api.get(url)).pipe(
|
||||||
catchError(() => {
|
catchError(() => {
|
||||||
history.replace('/tracks')
|
return of(null);
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
)
|
),
|
||||||
),
|
pluck("comments"),
|
||||||
pluck('track')
|
startWith(null) // show track infos before comments are loaded
|
||||||
)
|
);
|
||||||
|
|
||||||
const trackData$ = slug$.pipe(
|
return combineLatest([track$, trackData$, comments$]).pipe(
|
||||||
map((slug) => `/tracks/${slug}/data`),
|
map(([track, trackData, comments]) => ({
|
||||||
switchMap((url) =>
|
track,
|
||||||
concat(
|
trackData,
|
||||||
of(undefined),
|
comments,
|
||||||
from(api.get(url)).pipe(
|
}))
|
||||||
catchError(() => {
|
);
|
||||||
return of(null)
|
},
|
||||||
})
|
null,
|
||||||
)
|
[slug]
|
||||||
)
|
);
|
||||||
),
|
|
||||||
startWith(undefined) // show track infos before track data is loaded
|
|
||||||
)
|
|
||||||
|
|
||||||
const comments$ = concat(of(null), reloadComments$).pipe(
|
const onSubmitComment = React.useCallback(
|
||||||
switchMap(() => slug$),
|
async ({ body }) => {
|
||||||
map((slug) => `/tracks/${slug}/comments`),
|
await api.post(`/tracks/${slug}/comments`, {
|
||||||
switchMap((url) =>
|
body: { comment: { body } },
|
||||||
from(api.get(url)).pipe(
|
});
|
||||||
catchError(() => {
|
reloadComments();
|
||||||
return of(null)
|
},
|
||||||
})
|
[slug, reloadComments]
|
||||||
)
|
);
|
||||||
),
|
|
||||||
pluck('comments'),
|
|
||||||
startWith(null) // show track infos before comments are loaded
|
|
||||||
)
|
|
||||||
|
|
||||||
return combineLatest([track$, trackData$, comments$]).pipe(
|
const onDeleteComment = React.useCallback(
|
||||||
map(([track, trackData, comments]) => ({track, trackData, comments}))
|
async (id) => {
|
||||||
)
|
await api.delete(`/tracks/${slug}/comments/${id}`);
|
||||||
},
|
reloadComments();
|
||||||
null,
|
},
|
||||||
[slug]
|
[slug, reloadComments]
|
||||||
)
|
);
|
||||||
|
|
||||||
const onSubmitComment = React.useCallback(
|
const [downloadError, setDownloadError] = React.useState(null);
|
||||||
async ({body}) => {
|
const hideDownloadError = React.useCallback(
|
||||||
await api.post(`/tracks/${slug}/comments`, {
|
() => setDownloadError(null),
|
||||||
body: {comment: {body}},
|
[setDownloadError]
|
||||||
})
|
);
|
||||||
reloadComments()
|
const onDownload = React.useCallback(
|
||||||
},
|
async (filename) => {
|
||||||
[slug, reloadComments]
|
try {
|
||||||
)
|
await api.downloadFile(`/tracks/${slug}/download/${filename}`);
|
||||||
|
} catch (err) {
|
||||||
const onDeleteComment = React.useCallback(
|
if (/Failed to fetch/.test(String(err))) {
|
||||||
async (id) => {
|
setDownloadError(
|
||||||
await api.delete(`/tracks/${slug}/comments/${id}`)
|
"The track probably has not been imported correctly or recently enough. Please ask your administrator for assistance."
|
||||||
reloadComments()
|
);
|
||||||
},
|
} else {
|
||||||
[slug, reloadComments]
|
setDownloadError(String(err));
|
||||||
)
|
}
|
||||||
|
|
||||||
const [downloadError, setDownloadError] = React.useState(null)
|
|
||||||
const hideDownloadError = React.useCallback(() => setDownloadError(null), [setDownloadError])
|
|
||||||
const onDownload = React.useCallback(
|
|
||||||
async (filename) => {
|
|
||||||
try {
|
|
||||||
await api.downloadFile(`/tracks/${slug}/download/${filename}`)
|
|
||||||
} catch (err) {
|
|
||||||
if (/Failed to fetch/.test(String(err))) {
|
|
||||||
setDownloadError(
|
|
||||||
'The track probably has not been imported correctly or recently enough. Please ask your administrator for assistance.'
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
setDownloadError(String(err))
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
[slug]
|
||||||
[slug]
|
);
|
||||||
)
|
|
||||||
|
|
||||||
const isAuthor = login?.username === data?.track?.author?.username
|
const isAuthor = login?.username === data?.track?.author?.username;
|
||||||
|
|
||||||
const {track, trackData, comments} = data || {}
|
const { track, trackData, comments } = data || {};
|
||||||
|
|
||||||
const loading = track == null || trackData === undefined
|
const loading = track == null || trackData === undefined;
|
||||||
const processing = ['processing', 'queued', 'created'].includes(track?.processingStatus)
|
const processing = ["processing", "queued", "created"].includes(
|
||||||
const error = track?.processingStatus === 'error'
|
track?.processingStatus
|
||||||
|
);
|
||||||
|
const error = track?.processingStatus === "error";
|
||||||
|
|
||||||
const [showTrack, setShowTrack] = React.useState(true)
|
const [showTrack, setShowTrack] = React.useState(true);
|
||||||
const [pointsMode, setPointsMode] = React.useState('overtakingEvents') // none|overtakingEvents|measurements
|
const [pointsMode, setPointsMode] = React.useState("overtakingEvents"); // none|overtakingEvents|measurements
|
||||||
const [side, setSide] = React.useState('overtaker') // overtaker|stationary
|
const [side, setSide] = React.useState("overtaker"); // overtaker|stationary
|
||||||
|
|
||||||
const title = track ? track.title || 'Unnamed track' : null
|
const title = track ? track.title || "Unnamed track" : null;
|
||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
title={title}
|
title={title}
|
||||||
stage={
|
stage={
|
||||||
<div className={styles.stage}>
|
<>
|
||||||
<Loader active={loading} />
|
<Container>
|
||||||
<Dimmer.Dimmable blurring dimmed={loading}>
|
|
||||||
<TrackMap {...{track, trackData, pointsMode, side, showTrack}} style={{height: '80vh'}} />
|
|
||||||
</Dimmer.Dimmable>
|
|
||||||
|
|
||||||
<div className={styles.details}>
|
|
||||||
{processing && (
|
|
||||||
<Message warning>
|
|
||||||
<Message.Content>Track data is still being processed, please reload page in a while.</Message.Content>
|
|
||||||
</Message>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Message error>
|
|
||||||
<Message.Content>
|
|
||||||
The processing of this track failed, please ask your site administrator for help in debugging the
|
|
||||||
issue.
|
|
||||||
</Message.Content>
|
|
||||||
</Message>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Segment>
|
|
||||||
{track && (
|
{track && (
|
||||||
|
<Segment basic>
|
||||||
|
<div style={{display: 'flex', alignItems: 'baseline', marginBlockStart: 32, marginBlockEnd: 16}}>
|
||||||
|
<Header as="h1">{title}</Header>
|
||||||
|
<div style={{marginLeft: 'auto'}}>
|
||||||
|
<TrackActions {...{ isAuthor, onDownload, slug }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{marginBlockEnd: 16}}>
|
||||||
|
<TrackDetails {...{ track, isAuthor }} />
|
||||||
|
</div>
|
||||||
|
</Segment>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
<div className={styles.stage}>
|
||||||
|
<Loader active={loading} />
|
||||||
|
<Dimmer.Dimmable blurring dimmed={loading}>
|
||||||
|
<TrackMap
|
||||||
|
{...{ track, trackData, pointsMode, side, showTrack }}
|
||||||
|
style={{ height: "80vh" }}
|
||||||
|
/>
|
||||||
|
</Dimmer.Dimmable>
|
||||||
|
|
||||||
|
<div className={styles.details}>
|
||||||
|
<Segment>
|
||||||
|
<TrackMapSettings
|
||||||
|
{...{
|
||||||
|
showTrack,
|
||||||
|
setShowTrack,
|
||||||
|
pointsMode,
|
||||||
|
setPointsMode,
|
||||||
|
side,
|
||||||
|
setSide,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Segment>
|
||||||
|
|
||||||
|
{processing && (
|
||||||
|
<Message warning>
|
||||||
|
<Message.Content>
|
||||||
|
Track data is still being processed, please reload page in
|
||||||
|
a while.
|
||||||
|
</Message.Content>
|
||||||
|
</Message>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Message error>
|
||||||
|
<Message.Content>
|
||||||
|
The processing of this track failed, please ask your site
|
||||||
|
administrator for help in debugging the issue.
|
||||||
|
</Message.Content>
|
||||||
|
</Message>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Container>
|
||||||
|
{track?.description && (
|
||||||
<>
|
<>
|
||||||
<Header as="h1">{title}</Header>
|
<Header as="h2" dividing>
|
||||||
<TrackDetails {...{track, isAuthor}} />
|
Description
|
||||||
<TrackActions {...{isAuthor, onDownload, slug}} />
|
</Header>
|
||||||
|
<Markdown>{track.description}</Markdown>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Segment>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Confirm
|
|
||||||
open={downloadError != null}
|
|
||||||
cancelButton={false}
|
|
||||||
onConfirm={hideDownloadError}
|
|
||||||
header="Download failed"
|
|
||||||
content={String(downloadError)}
|
|
||||||
/>
|
|
||||||
<Grid stackable>
|
|
||||||
<Grid.Row>
|
|
||||||
<Grid.Column width={12}>
|
|
||||||
{track?.description && (
|
|
||||||
<Segment basic>
|
|
||||||
<Header as="h2" dividing>
|
|
||||||
Description
|
|
||||||
</Header>
|
|
||||||
<Markdown>{track.description}</Markdown>
|
|
||||||
</Segment>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TrackComments
|
<TrackComments
|
||||||
{...{hideLoader: loading, comments, login}}
|
{...{ hideLoader: loading, comments, login }}
|
||||||
onSubmit={onSubmitComment}
|
onSubmit={onSubmitComment}
|
||||||
onDelete={onDeleteComment}
|
onDelete={onDeleteComment}
|
||||||
/>
|
/>
|
||||||
</Grid.Column>
|
</Container>
|
||||||
<Grid.Column width={4}>
|
</>
|
||||||
<TrackMapSettings {...{showTrack, setShowTrack, pointsMode, setPointsMode, side, setSide}} />
|
}
|
||||||
</Grid.Column>
|
>
|
||||||
</Grid.Row>
|
<Confirm
|
||||||
</Grid>
|
open={downloadError != null}
|
||||||
|
cancelButton={false}
|
||||||
|
onConfirm={hideDownloadError}
|
||||||
|
header="Download failed"
|
||||||
|
content={String(downloadError)}
|
||||||
|
/>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
{/* <pre>{JSON.stringify(data, null, 2)}</pre> */}
|
export default TrackPage;
|
||||||
</Page>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export default TrackPage
|
|
||||||
|
|
Loading…
Reference in a new issue