Build awesome "My Tracks" table with filters and sorting
This commit is contained in:
parent
5a78d7eb38
commit
cbab83e6e3
|
@ -259,6 +259,7 @@ class Track(Base):
|
|||
if for_user_id is not None and for_user_id == self.author_id:
|
||||
result["uploadedByUserAgent"] = self.uploaded_by_user_agent
|
||||
result["originalFileName"] = self.original_file_name
|
||||
result["userDeviceId"] = self.user_device_id
|
||||
|
||||
if self.author:
|
||||
result["author"] = self.author.to_dict(for_user_id=for_user_id)
|
||||
|
|
|
@ -23,7 +23,7 @@ def normalize_user_agent(user_agent):
|
|||
return m[0] if m else None
|
||||
|
||||
|
||||
async def _return_tracks(req, extend_query, limit, offset):
|
||||
async def _return_tracks(req, extend_query, limit, offset, order_by=None):
|
||||
if limit <= 0 or limit > 1000:
|
||||
raise InvalidUsage("invalid limit")
|
||||
|
||||
|
@ -39,7 +39,7 @@ async def _return_tracks(req, extend_query, limit, offset):
|
|||
extend_query(select(Track).options(joinedload(Track.author)))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.order_by(Track.created_at.desc())
|
||||
.order_by(order_by if order_by is not None else Track.created_at)
|
||||
)
|
||||
|
||||
tracks = (await req.ctx.db.execute(query)).scalars()
|
||||
|
@ -76,16 +76,56 @@ async def get_tracks(req):
|
|||
return await _return_tracks(req, extend_query, limit, offset)
|
||||
|
||||
|
||||
def parse_boolean(s):
|
||||
if s is None:
|
||||
return None
|
||||
|
||||
s = s.lower()
|
||||
if s in ("true", "1", "yes", "y", "t"):
|
||||
return True
|
||||
if s in ("false", "0", "no", "n", "f"):
|
||||
return False
|
||||
|
||||
raise ValueError("invalid value for boolean")
|
||||
|
||||
|
||||
@api.get("/tracks/feed")
|
||||
@require_auth
|
||||
async def get_feed(req):
|
||||
limit = req.ctx.get_single_arg("limit", default=20, convert=int)
|
||||
offset = req.ctx.get_single_arg("offset", default=0, convert=int)
|
||||
user_device_id = req.ctx.get_single_arg("user_device_id", default=None, convert=int)
|
||||
|
||||
order_by_columns = {
|
||||
"recordedAt": Track.recorded_at,
|
||||
"title": Track.title,
|
||||
"visibility": Track.public,
|
||||
"length": Track.length,
|
||||
"duration": Track.duration,
|
||||
"user_device_id": Track.user_device_id,
|
||||
}
|
||||
order_by = req.ctx.get_single_arg(
|
||||
"order_by", default=None, convert=order_by_columns.get
|
||||
)
|
||||
|
||||
reversed_ = req.ctx.get_single_arg("reversed", convert=parse_boolean, default=False)
|
||||
if reversed_:
|
||||
order_by = order_by.desc()
|
||||
|
||||
public = req.ctx.get_single_arg("public", convert=parse_boolean, default=None)
|
||||
|
||||
def extend_query(q):
|
||||
return q.where(Track.author_id == req.ctx.user.id)
|
||||
q = q.where(Track.author_id == req.ctx.user.id)
|
||||
|
||||
return await _return_tracks(req, extend_query, limit, offset)
|
||||
if user_device_id is not None:
|
||||
q = q.where(Track.user_device_id == user_device_id)
|
||||
|
||||
if public is not None:
|
||||
q = q.where(Track.public == public)
|
||||
|
||||
return q
|
||||
|
||||
return await _return_tracks(req, extend_query, limit, offset, order_by)
|
||||
|
||||
|
||||
@api.post("/tracks")
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import logging
|
||||
|
||||
from sanic.response import json
|
||||
from sanic.exceptions import InvalidUsage
|
||||
from sanic.exceptions import InvalidUsage, Forbidden
|
||||
from sqlalchemy import select
|
||||
|
||||
from obs.api.app import api, require_auth
|
||||
from obs.api.db import UserDevice
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
@ -28,6 +30,22 @@ async def get_user(req):
|
|||
return json(user_to_json(req.ctx.user) if req.ctx.user else None)
|
||||
|
||||
|
||||
@api.get("/user/devices")
|
||||
async def get_user_devices(req):
|
||||
if not req.ctx.user:
|
||||
raise Forbidden()
|
||||
|
||||
query = (
|
||||
select(UserDevice)
|
||||
.where(UserDevice.user_id == req.ctx.user.id)
|
||||
.order_by(UserDevice.id)
|
||||
)
|
||||
|
||||
devices = (await req.ctx.db.execute(query)).scalars()
|
||||
|
||||
return json([device.to_dict(req.ctx.user.id) for device in devices])
|
||||
|
||||
|
||||
@api.put("/user")
|
||||
@require_auth
|
||||
async def put_user(req):
|
||||
|
|
|
@ -1,17 +1,24 @@
|
|||
import React from 'react'
|
||||
import classnames from 'classnames'
|
||||
import {connect} from 'react-redux'
|
||||
import {List, Grid, Container, Menu, Header, Dropdown} from 'semantic-ui-react'
|
||||
import {BrowserRouter as Router, Switch, Route, Link} from 'react-router-dom'
|
||||
import {useObservable} from 'rxjs-hooks'
|
||||
import {from} from 'rxjs'
|
||||
import {pluck} from 'rxjs/operators'
|
||||
import React from "react";
|
||||
import classnames from "classnames";
|
||||
import { connect } from "react-redux";
|
||||
import {
|
||||
List,
|
||||
Grid,
|
||||
Container,
|
||||
Menu,
|
||||
Header,
|
||||
Dropdown,
|
||||
} from "semantic-ui-react";
|
||||
import { BrowserRouter as Router, Switch, Route, Link } from "react-router-dom";
|
||||
import { useObservable } from "rxjs-hooks";
|
||||
import { from } from "rxjs";
|
||||
import { pluck } from "rxjs/operators";
|
||||
import { Helmet } from "react-helmet";
|
||||
import {useTranslation} from 'react-i18next'
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {useConfig} from 'config'
|
||||
import styles from './App.module.less'
|
||||
import {AVAILABLE_LOCALES, setLocale} from 'i18n'
|
||||
import { useConfig } from "config";
|
||||
import styles from "./App.module.less";
|
||||
import { AVAILABLE_LOCALES, setLocale } from "i18n";
|
||||
|
||||
import {
|
||||
ExportPage,
|
||||
|
@ -25,9 +32,10 @@ import {
|
|||
TrackPage,
|
||||
TracksPage,
|
||||
UploadPage,
|
||||
} from 'pages'
|
||||
import {Avatar, LoginButton} from 'components'
|
||||
import api from 'api'
|
||||
MyTracksPage,
|
||||
} from "pages";
|
||||
import { Avatar, LoginButton } from "components";
|
||||
import api from "api";
|
||||
|
||||
// This component removes the "navigate" prop before rendering a Menu.Item,
|
||||
// which is a workaround for an annoying warning that is somehow caused by the
|
||||
|
@ -37,38 +45,48 @@ function MenuItemForLink({navigate, ...props}) {
|
|||
<Menu.Item
|
||||
{...props}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
navigate()
|
||||
e.preventDefault();
|
||||
navigate();
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
function DropdownItemForLink({ navigate, ...props }) {
|
||||
return (
|
||||
<Dropdown.Item
|
||||
{...props}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
navigate()
|
||||
e.preventDefault();
|
||||
navigate();
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function Banner({text, style = 'warning'}: {text: string; style: 'warning' | 'info'}) {
|
||||
return <div className={classnames(styles.banner, styles[style])}>{text}</div>
|
||||
function Banner({
|
||||
text,
|
||||
style = "warning",
|
||||
}: {
|
||||
text: string;
|
||||
style: "warning" | "info";
|
||||
}) {
|
||||
return <div className={classnames(styles.banner, styles[style])}>{text}</div>;
|
||||
}
|
||||
|
||||
const App = connect((state) => ({login: state.login}))(function App({login}) {
|
||||
const {t} = useTranslation()
|
||||
const config = useConfig()
|
||||
const apiVersion = useObservable(() => from(api.get('/info')).pipe(pluck('version')))
|
||||
const App = connect((state) => ({ login: state.login }))(function App({
|
||||
login,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const config = useConfig();
|
||||
const apiVersion = useObservable(() =>
|
||||
from(api.get("/info")).pipe(pluck("version"))
|
||||
);
|
||||
|
||||
const hasMap = Boolean(config?.obsMapSource)
|
||||
const hasMap = Boolean(config?.obsMapSource);
|
||||
|
||||
React.useEffect(() => {
|
||||
api.loadUser()
|
||||
}, [])
|
||||
api.loadUser();
|
||||
}, []);
|
||||
|
||||
return config ? (
|
||||
<Router basename={config.basename}>
|
||||
|
@ -79,36 +97,59 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
|
|||
{config?.banner && <Banner {...config.banner} />}
|
||||
<Menu className={styles.menu}>
|
||||
<Container>
|
||||
<Link to="/" component={MenuItemForLink} header className={styles.pageTitle}>
|
||||
<Link
|
||||
to="/"
|
||||
component={MenuItemForLink}
|
||||
header
|
||||
className={styles.pageTitle}
|
||||
>
|
||||
OpenBikeSensor
|
||||
</Link>
|
||||
|
||||
{hasMap && (
|
||||
<Link component={MenuItemForLink} to="/map" as="a">
|
||||
{t('App.menu.map')}
|
||||
{t("App.menu.map")}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link component={MenuItemForLink} to="/tracks" as="a">
|
||||
{t('App.menu.tracks')}
|
||||
{t("App.menu.tracks")}
|
||||
</Link>
|
||||
|
||||
<Link component={MenuItemForLink} to="/export" as="a">
|
||||
{t('App.menu.export')}
|
||||
{t("App.menu.export")}
|
||||
</Link>
|
||||
|
||||
<Menu.Menu position="right">
|
||||
{login ? (
|
||||
<>
|
||||
<Link component={MenuItemForLink} to="/my/tracks" as="a">
|
||||
{t('App.menu.myTracks')}
|
||||
{t("App.menu.myTracks")}
|
||||
</Link>
|
||||
<Dropdown item trigger={<Avatar user={login} className={styles.avatar} />}>
|
||||
<Dropdown
|
||||
item
|
||||
trigger={<Avatar user={login} className={styles.avatar} />}
|
||||
>
|
||||
<Dropdown.Menu>
|
||||
<Link to="/upload" component={DropdownItemForLink} icon="cloud upload" text={t('App.menu.uploadTracks')} />
|
||||
<Link to="/settings" component={DropdownItemForLink} icon="cog" text={t('App.menu.settings')}/>
|
||||
<Link
|
||||
to="/upload"
|
||||
component={DropdownItemForLink}
|
||||
icon="cloud upload"
|
||||
text={t("App.menu.uploadTracks")}
|
||||
/>
|
||||
<Link
|
||||
to="/settings"
|
||||
component={DropdownItemForLink}
|
||||
icon="cog"
|
||||
text={t("App.menu.settings")}
|
||||
/>
|
||||
<Dropdown.Divider />
|
||||
<Link to="/logout" component={DropdownItemForLink} icon="sign-out" text={t('App.menu.logout')} />
|
||||
<Link
|
||||
to="/logout"
|
||||
component={DropdownItemForLink}
|
||||
icon="sign-out"
|
||||
text={t("App.menu.logout")}
|
||||
/>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</>
|
||||
|
@ -125,14 +166,16 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
|
|||
<Route path="/" exact>
|
||||
<HomePage />
|
||||
</Route>
|
||||
{hasMap && <Route path="/map" exact>
|
||||
{hasMap && (
|
||||
<Route path="/map" exact>
|
||||
<MapPage />
|
||||
</Route>}
|
||||
</Route>
|
||||
)}
|
||||
<Route path="/tracks" exact>
|
||||
<TracksPage />
|
||||
</Route>
|
||||
<Route path="/my/tracks" exact>
|
||||
<TracksPage privateTracks />
|
||||
<MyTracksPage />
|
||||
</Route>
|
||||
<Route path={`/tracks/:slug`} exact>
|
||||
<TrackPage />
|
||||
|
@ -169,12 +212,14 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
|
|||
<Grid columns={4} stackable>
|
||||
<Grid.Row>
|
||||
<Grid.Column>
|
||||
<Header as="h5">
|
||||
{t('App.footer.aboutTheProject')}
|
||||
</Header>
|
||||
<Header as="h5">{t("App.footer.aboutTheProject")}</Header>
|
||||
<List>
|
||||
<List.Item>
|
||||
<a href="https://openbikesensor.org/" target="_blank" rel="noreferrer">
|
||||
<a
|
||||
href="https://openbikesensor.org/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
openbikesensor.org
|
||||
</a>
|
||||
</List.Item>
|
||||
|
@ -182,41 +227,57 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
|
|||
</Grid.Column>
|
||||
|
||||
<Grid.Column>
|
||||
<Header as="h5">
|
||||
{t('App.footer.getInvolved')}
|
||||
</Header>
|
||||
<Header as="h5">{t("App.footer.getInvolved")}</Header>
|
||||
<List>
|
||||
<List.Item>
|
||||
<a href="https://forum.openbikesensor.org/" target="_blank" rel="noreferrer">
|
||||
{t('App.footer.getHelpInForum')}
|
||||
<a
|
||||
href="https://forum.openbikesensor.org/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t("App.footer.getHelpInForum")}
|
||||
</a>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<a href="https://github.com/openbikesensor/portal/issues/new" target="_blank" rel="noreferrer">
|
||||
{t('App.footer.reportAnIssue')}
|
||||
<a
|
||||
href="https://github.com/openbikesensor/portal/issues/new"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t("App.footer.reportAnIssue")}
|
||||
</a>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<a href="https://github.com/openbikesensor/portal" target="_blank" rel="noreferrer">
|
||||
{t('App.footer.development')}
|
||||
<a
|
||||
href="https://github.com/openbikesensor/portal"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t("App.footer.development")}
|
||||
</a>
|
||||
</List.Item>
|
||||
</List>
|
||||
</Grid.Column>
|
||||
|
||||
<Grid.Column>
|
||||
<Header as="h5">
|
||||
{t('App.footer.thisInstallation')}
|
||||
</Header>
|
||||
<Header as="h5">{t("App.footer.thisInstallation")}</Header>
|
||||
<List>
|
||||
<List.Item>
|
||||
<a href={config?.privacyPolicyUrl} target="_blank" rel="noreferrer">
|
||||
{t('App.footer.privacyPolicy')}
|
||||
<a
|
||||
href={config?.privacyPolicyUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t("App.footer.privacyPolicy")}
|
||||
</a>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<a href={config?.imprintUrl} target="_blank" rel="noreferrer">
|
||||
{t('App.footer.imprint')}
|
||||
<a
|
||||
href={config?.imprintUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t("App.footer.imprint")}
|
||||
</a>
|
||||
</List.Item>
|
||||
{ config?.termsUrl &&
|
||||
|
@ -229,21 +290,29 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
|
|||
<List.Item>
|
||||
<a
|
||||
href={`https://github.com/openbikesensor/portal${
|
||||
apiVersion ? `/releases/tag/${apiVersion}` : ''
|
||||
apiVersion ? `/releases/tag/${apiVersion}` : ""
|
||||
}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{apiVersion ? t('App.footer.version', {apiVersion}) : t('App.footer.versionLoading')}
|
||||
{apiVersion
|
||||
? t("App.footer.version", { apiVersion })
|
||||
: t("App.footer.versionLoading")}
|
||||
</a>
|
||||
</List.Item>
|
||||
</List>
|
||||
</Grid.Column>
|
||||
|
||||
<Grid.Column>
|
||||
<Header as="h5">{t('App.footer.changeLanguage')}</Header>
|
||||
<Header as="h5">{t("App.footer.changeLanguage")}</Header>
|
||||
<List>
|
||||
{AVAILABLE_LOCALES.map(locale => <List.Item key={locale}><a onClick={() => setLocale(locale)}>{t(`locales.${locale}`)}</a></List.Item>)}
|
||||
{AVAILABLE_LOCALES.map((locale) => (
|
||||
<List.Item key={locale}>
|
||||
<a onClick={() => setLocale(locale)}>
|
||||
{t(`locales.${locale}`)}
|
||||
</a>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
|
@ -251,7 +320,7 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
|
|||
</Container>
|
||||
</div>
|
||||
</Router>
|
||||
) : null
|
||||
})
|
||||
) : null;
|
||||
});
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
|
|
326
frontend/src/pages/MyTracksPage.tsx
Normal file
326
frontend/src/pages/MyTracksPage.tsx
Normal file
|
@ -0,0 +1,326 @@
|
|||
import React, { useCallback, useState } from "react";
|
||||
import { connect } from "react-redux";
|
||||
import {
|
||||
Accordion,
|
||||
Button,
|
||||
Header,
|
||||
Icon,
|
||||
Item,
|
||||
List,
|
||||
Loader,
|
||||
Dropdown,
|
||||
SemanticCOLORS,
|
||||
SemanticICONS,
|
||||
Table,
|
||||
} from "semantic-ui-react";
|
||||
import { useObservable } from "rxjs-hooks";
|
||||
import { Link } from "react-router-dom";
|
||||
import { of, from, concat } from "rxjs";
|
||||
import { map, switchMap, distinctUntilChanged } from "rxjs/operators";
|
||||
import _ from "lodash";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { ProcessingStatus, Track, UserDevice } from "types";
|
||||
import { Page, FormattedDate, Visibility } from "components";
|
||||
import api from "api";
|
||||
import { formatDistance, formatDuration } from "utils";
|
||||
|
||||
const COLOR_BY_STATUS: Record<ProcessingStatus, SemanticCOLORS> = {
|
||||
error: "red",
|
||||
complete: "green",
|
||||
created: "grey",
|
||||
queued: "orange",
|
||||
processing: "orange",
|
||||
};
|
||||
|
||||
const ICON_BY_STATUS: Record<ProcessingStatus, SemanticICONS> = {
|
||||
error: "warning sign",
|
||||
complete: "check circle outline",
|
||||
created: "bolt",
|
||||
queued: "bolt",
|
||||
processing: "bolt",
|
||||
};
|
||||
|
||||
function ProcessingStatusLabel({ status }: { status: ProcessingStatus }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span title={t(`TracksPage.processing.${status}`)}>
|
||||
<Icon color={COLOR_BY_STATUS[status]} name={ICON_BY_STATUS[status]} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableHeader({
|
||||
children,
|
||||
setOrderBy,
|
||||
orderBy,
|
||||
reversed,
|
||||
setReversed,
|
||||
name,
|
||||
...props
|
||||
}) {
|
||||
const toggleSort = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (orderBy === name) {
|
||||
if (!reversed) {
|
||||
setReversed(true);
|
||||
} else {
|
||||
setReversed(false);
|
||||
setOrderBy(null);
|
||||
}
|
||||
} else {
|
||||
setReversed(false);
|
||||
setOrderBy(name);
|
||||
}
|
||||
};
|
||||
|
||||
let icon =
|
||||
orderBy === name ? (reversed ? "sort descending" : "sort ascending") : null;
|
||||
|
||||
return (
|
||||
<Table.HeaderCell {...props}>
|
||||
<div onClick={toggleSort}>
|
||||
{children}
|
||||
<Icon name={icon} />
|
||||
</div>
|
||||
</Table.HeaderCell>
|
||||
);
|
||||
}
|
||||
|
||||
type Filters = {
|
||||
userDeviceId?: null | number;
|
||||
visibility?: null | boolean;
|
||||
};
|
||||
|
||||
function TrackFilters({
|
||||
filters,
|
||||
setFilters,
|
||||
deviceNames,
|
||||
}: {
|
||||
filters: Filters;
|
||||
setFilters: (f: Filters) => void;
|
||||
deviceNames: null | Record<number, string>;
|
||||
}) {
|
||||
return (
|
||||
<List horizontal>
|
||||
<List.Item>
|
||||
<List.Header>Device</List.Header>
|
||||
<Dropdown
|
||||
selection
|
||||
clearable
|
||||
options={[
|
||||
{ value: 0, key: "__none__", text: "All my devices" },
|
||||
..._.sortBy(Object.entries(deviceNames ?? {}), 1).map(
|
||||
([deviceId, deviceName]: [string, string]) => ({
|
||||
value: Number(deviceId),
|
||||
key: deviceId,
|
||||
text: deviceName,
|
||||
})
|
||||
),
|
||||
]}
|
||||
value={filters?.userDeviceId ?? 0}
|
||||
onChange={(_e, { value }) =>
|
||||
setFilters({ ...filters, userDeviceId: (value as number) || null })
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
|
||||
<List.Item>
|
||||
<List.Header>Visibility</List.Header>
|
||||
<Dropdown
|
||||
selection
|
||||
clearable
|
||||
options={[
|
||||
{ value: "none", key: "any", text: "Any" },
|
||||
{ value: true, key: "public", text: "Public" },
|
||||
{ value: false, key: "private", text: "Private" },
|
||||
]}
|
||||
value={filters?.visibility ?? "none"}
|
||||
onChange={(_e, { value }) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
visibility: value === "none" ? null : (value as boolean),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
function TracksTable() {
|
||||
const [orderBy, setOrderBy] = useState("recordedAt");
|
||||
const [reversed, setReversed] = useState(false);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [filters, setFilters] = useState<Filters>({});
|
||||
|
||||
const query = _.pickBy(
|
||||
{
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
order_by: orderBy,
|
||||
reversed: reversed ? "true" : "false",
|
||||
user_device_id: filters?.userDeviceId,
|
||||
public: filters?.visibility,
|
||||
},
|
||||
(x) => x != null
|
||||
);
|
||||
|
||||
const tracks: Track[] | null = useObservable(
|
||||
(_$, inputs$) =>
|
||||
inputs$.pipe(
|
||||
map(([query]) => query),
|
||||
distinctUntilChanged(_.isEqual),
|
||||
switchMap((query) =>
|
||||
concat(
|
||||
of(null),
|
||||
from(api.get("/tracks/feed", { query }).then((r) => r.tracks))
|
||||
)
|
||||
)
|
||||
),
|
||||
null,
|
||||
[query]
|
||||
);
|
||||
|
||||
const deviceNames: null | Record<number, string> = useObservable(() =>
|
||||
from(api.get("/user/devices")).pipe(
|
||||
map((response: UserDevice[]) =>
|
||||
Object.fromEntries(
|
||||
response.map((device) => [
|
||||
device.id,
|
||||
device.displayName || device.identifier,
|
||||
])
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const p = { orderBy, setOrderBy, reversed, setReversed };
|
||||
|
||||
return (
|
||||
<div style={{ clear: "both" }}>
|
||||
<Loader content={t("general.loading")} active={tracks == null} />
|
||||
|
||||
<Accordion styled>
|
||||
<Accordion.Title
|
||||
active={showFilters}
|
||||
index={0}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<Icon name="dropdown" />
|
||||
Filters
|
||||
</Accordion.Title>
|
||||
<Accordion.Content active={showFilters}>
|
||||
<TrackFilters {...{ filters, setFilters, deviceNames }} />
|
||||
</Accordion.Content>
|
||||
</Accordion>
|
||||
|
||||
<Table compact>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<SortableHeader {...p} name="title">
|
||||
Title
|
||||
</SortableHeader>
|
||||
<SortableHeader {...p} name="recordedAt">
|
||||
Recorded at
|
||||
</SortableHeader>
|
||||
<SortableHeader {...p} name="visibility">
|
||||
Visibility
|
||||
</SortableHeader>
|
||||
<SortableHeader {...p} name="length" textAlign="right">
|
||||
Length
|
||||
</SortableHeader>
|
||||
<SortableHeader {...p} name="duration" textAlign="right">
|
||||
Duration
|
||||
</SortableHeader>
|
||||
<SortableHeader {...p} name="user_device_id">
|
||||
Device
|
||||
</SortableHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{tracks?.map((track: Track) => (
|
||||
<Table.Row key={track.slug}>
|
||||
<Table.Cell>
|
||||
{track.processingStatus == null ? null : (
|
||||
<ProcessingStatusLabel status={track.processingStatus} />
|
||||
)}
|
||||
<Item.Header as={Link} to={`/tracks/${track.slug}`}>
|
||||
{track.title || t("general.unnamedTrack")}
|
||||
</Item.Header>
|
||||
</Table.Cell>
|
||||
|
||||
<Table.Cell>
|
||||
<FormattedDate date={track.recordedAt} />
|
||||
</Table.Cell>
|
||||
|
||||
<Table.Cell>
|
||||
{track.public == null ? null : (
|
||||
<Visibility public={track.public} />
|
||||
)}
|
||||
</Table.Cell>
|
||||
|
||||
<Table.Cell textAlign="right">
|
||||
{formatDistance(track.length)}
|
||||
</Table.Cell>
|
||||
|
||||
<Table.Cell textAlign="right">
|
||||
{formatDuration(track.duration)}
|
||||
</Table.Cell>
|
||||
|
||||
<Table.Cell>
|
||||
{track.userDeviceId
|
||||
? deviceNames?.[track.userDeviceId] ?? "..."
|
||||
: null}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadButton({ navigate, ...props }) {
|
||||
const { t } = useTranslation();
|
||||
const onClick = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
navigate();
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
{...props}
|
||||
color="green"
|
||||
style={{ float: "right" }}
|
||||
>
|
||||
{t("TracksPage.upload")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const MyTracksPage = connect((state) => ({ login: (state as any).login }))(
|
||||
function MyTracksPage({ login }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const title = t("TracksPage.titleUser");
|
||||
|
||||
return (
|
||||
<Page title={title}>
|
||||
<Link component={UploadButton} to="/upload" />
|
||||
<Header as="h2">{title}</Header>
|
||||
<TracksTable />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default MyTracksPage;
|
|
@ -5,10 +5,7 @@ import { Duration } from "luxon";
|
|||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FormattedDate, Visibility } from "components";
|
||||
|
||||
function formatDuration(seconds) {
|
||||
return Duration.fromMillis((seconds ?? 0) * 1000).toFormat("h'h' mm'm'");
|
||||
}
|
||||
import { formatDistance, formatDuration } from "utils";
|
||||
|
||||
export default function TrackDetails({ track, isAuthor }) {
|
||||
const { t } = useTranslation();
|
||||
|
@ -47,7 +44,7 @@ export default function TrackDetails({ track, isAuthor }) {
|
|||
|
||||
track?.length != null && [
|
||||
t("TrackPage.details.length"),
|
||||
`${(track?.length / 1000).toFixed(2)} km`,
|
||||
formatDistance(track?.length),
|
||||
],
|
||||
|
||||
track?.processingStatus != null &&
|
||||
|
@ -63,19 +60,19 @@ export default function TrackDetails({ track, isAuthor }) {
|
|||
].filter(Boolean);
|
||||
|
||||
const COLUMNS = 4;
|
||||
const chunkSize = Math.ceil(items.length / COLUMNS)
|
||||
const chunkSize = Math.ceil(items.length / COLUMNS);
|
||||
return (
|
||||
<Grid>
|
||||
<Grid.Row columns={COLUMNS}>
|
||||
{_.chunk(items, chunkSize).map((chunkItems, idx) => (
|
||||
<Grid.Column key={idx}>
|
||||
|
||||
<List>
|
||||
{chunkItems.map(([title, value]) => (
|
||||
<List.Item key={title}>
|
||||
<List.Header>{title}</List.Header>
|
||||
<List.Description>{value}</List.Description>
|
||||
</List.Item>))}
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Grid.Column>
|
||||
))}
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
export {default as ExportPage} from './ExportPage'
|
||||
export {default as HomePage} from './HomePage'
|
||||
export {default as LoginRedirectPage} from './LoginRedirectPage'
|
||||
export {default as LogoutPage} from './LogoutPage'
|
||||
export {default as MapPage} from './MapPage'
|
||||
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'
|
||||
export { default as ExportPage } from "./ExportPage";
|
||||
export { default as HomePage } from "./HomePage";
|
||||
export { default as LoginRedirectPage } from "./LoginRedirectPage";
|
||||
export { default as LogoutPage } from "./LogoutPage";
|
||||
export { default as MapPage } from "./MapPage";
|
||||
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 MyTracksPage } from "./MyTracksPage";
|
||||
export { default as UploadPage } from "./UploadPage";
|
||||
|
|
|
@ -1,52 +1,67 @@
|
|||
import type {FeatureCollection, Feature, LineString, Point} from 'geojson'
|
||||
import type { FeatureCollection, Feature, LineString, Point } from "geojson";
|
||||
|
||||
export type UserProfile = {
|
||||
id: number | string
|
||||
displayName: string
|
||||
image?: string | null
|
||||
bio?: string | null
|
||||
export interface UserProfile {
|
||||
username: string;
|
||||
displayName: string;
|
||||
image?: string | null;
|
||||
bio?: string | null;
|
||||
}
|
||||
|
||||
export type TrackData = {
|
||||
track: Feature<LineString>
|
||||
measurements: FeatureCollection
|
||||
overtakingEvents: FeatureCollection
|
||||
export interface TrackData {
|
||||
track: Feature<LineString>;
|
||||
measurements: FeatureCollection;
|
||||
overtakingEvents: FeatureCollection;
|
||||
}
|
||||
|
||||
export type Track = {
|
||||
slug: string
|
||||
author: UserProfile
|
||||
title: string
|
||||
description?: string
|
||||
createdAt: string
|
||||
public?: boolean
|
||||
recordedAt?: Date
|
||||
recordedUntil?: Date
|
||||
duration?: number
|
||||
length?: number
|
||||
segments?: number
|
||||
numEvents?: number
|
||||
numMeasurements?: number
|
||||
numValid?: number
|
||||
export type ProcessingStatus =
|
||||
| "error"
|
||||
| "complete"
|
||||
| "created"
|
||||
| "queued"
|
||||
| "processing";
|
||||
|
||||
export interface Track {
|
||||
slug: string;
|
||||
author: UserProfile;
|
||||
title: string;
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
processingStatus?: ProcessingStatus;
|
||||
public?: boolean;
|
||||
recordedAt?: Date;
|
||||
recordedUntil?: Date;
|
||||
duration?: number;
|
||||
length?: number;
|
||||
segments?: number;
|
||||
numEvents?: number;
|
||||
numMeasurements?: number;
|
||||
numValid?: number;
|
||||
userDeviceId?: number;
|
||||
}
|
||||
|
||||
export type TrackPoint = {
|
||||
type: 'Feature'
|
||||
geometry: Point
|
||||
export interface TrackPoint {
|
||||
type: "Feature";
|
||||
geometry: Point;
|
||||
properties: {
|
||||
distanceOvertaker: null | number
|
||||
distanceStationary: null | number
|
||||
}
|
||||
distanceOvertaker: null | number;
|
||||
distanceStationary: null | number;
|
||||
};
|
||||
}
|
||||
|
||||
export type TrackComment = {
|
||||
id: string
|
||||
body: string
|
||||
createdAt: string
|
||||
author: UserProfile
|
||||
export interface TrackComment {
|
||||
id: string;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
author: UserProfile;
|
||||
}
|
||||
|
||||
export type Location {
|
||||
export interface Location {
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
}
|
||||
|
||||
export interface UserDevice {
|
||||
id: number;
|
||||
identifier: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
|
|
@ -1,32 +1,51 @@
|
|||
import {useRef, useCallback} from 'react'
|
||||
import { useRef, useCallback } from "react";
|
||||
import { Duration } from "luxon";
|
||||
|
||||
// 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)
|
||||
}
|
||||
const found = element
|
||||
? element.querySelector("input, textarea, select, checkbox")
|
||||
: null;
|
||||
register(found);
|
||||
};
|
||||
}
|
||||
|
||||
// Generates pairs from the input iterable
|
||||
export function* pairwise(it) {
|
||||
let lastValue
|
||||
let firstRound = true
|
||||
let lastValue;
|
||||
let firstRound = true;
|
||||
|
||||
for (const i of it) {
|
||||
if (firstRound) {
|
||||
firstRound = false
|
||||
firstRound = false;
|
||||
} else {
|
||||
yield [lastValue, i]
|
||||
yield [lastValue, i];
|
||||
}
|
||||
lastValue = i
|
||||
lastValue = i;
|
||||
}
|
||||
}
|
||||
|
||||
export function useCallbackRef(fn) {
|
||||
const fnRef = useRef()
|
||||
fnRef.current = fn
|
||||
return useCallback(((...args) => fnRef.current(...args)), [])
|
||||
const fnRef = useRef();
|
||||
fnRef.current = fn;
|
||||
return useCallback((...args) => fnRef.current(...args), []);
|
||||
}
|
||||
|
||||
export function formatDuration(seconds) {
|
||||
return Duration.fromMillis((seconds ?? 0) * 1000).toFormat("h'h' mm'm'");
|
||||
}
|
||||
|
||||
export function formatDistance(meters) {
|
||||
if (meters == null) return null;
|
||||
|
||||
if (meters < 0) return "-" + formatDistance(meters);
|
||||
|
||||
if (meters < 1000) {
|
||||
return `${meters.toFixed(0)} m`;
|
||||
} else {
|
||||
return `${(meters / 1000).toFixed(2)} km`;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue