Build awesome "My Tracks" table with filters and sorting

This commit is contained in:
Paul Bienkowski 2022-09-22 17:35:12 +02:00
parent 5a78d7eb38
commit cbab83e6e3
9 changed files with 645 additions and 159 deletions

View file

@ -259,6 +259,7 @@ class Track(Base):
if for_user_id is not None and for_user_id == self.author_id: if for_user_id is not None and for_user_id == self.author_id:
result["uploadedByUserAgent"] = self.uploaded_by_user_agent result["uploadedByUserAgent"] = self.uploaded_by_user_agent
result["originalFileName"] = self.original_file_name result["originalFileName"] = self.original_file_name
result["userDeviceId"] = self.user_device_id
if self.author: if self.author:
result["author"] = self.author.to_dict(for_user_id=for_user_id) result["author"] = self.author.to_dict(for_user_id=for_user_id)

View file

@ -23,7 +23,7 @@ def normalize_user_agent(user_agent):
return m[0] if m else None 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: if limit <= 0 or limit > 1000:
raise InvalidUsage("invalid limit") 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))) extend_query(select(Track).options(joinedload(Track.author)))
.limit(limit) .limit(limit)
.offset(offset) .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() 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) 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") @api.get("/tracks/feed")
@require_auth @require_auth
async def get_feed(req): async def get_feed(req):
limit = req.ctx.get_single_arg("limit", default=20, convert=int) limit = req.ctx.get_single_arg("limit", default=20, convert=int)
offset = req.ctx.get_single_arg("offset", default=0, 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): 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") @api.post("/tracks")

View file

@ -1,9 +1,11 @@
import logging import logging
from sanic.response import json 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.app import api, require_auth
from obs.api.db import UserDevice
log = logging.getLogger(__name__) 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) 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") @api.put("/user")
@require_auth @require_auth
async def put_user(req): async def put_user(req):

View file

@ -1,17 +1,24 @@
import React from 'react' import React from "react";
import classnames from 'classnames' import classnames from "classnames";
import {connect} from 'react-redux' import { connect } from "react-redux";
import {List, Grid, Container, Menu, Header, Dropdown} from 'semantic-ui-react' import {
import {BrowserRouter as Router, Switch, Route, Link} from 'react-router-dom' List,
import {useObservable} from 'rxjs-hooks' Grid,
import {from} from 'rxjs' Container,
import {pluck} from 'rxjs/operators' Menu,
import {Helmet} from "react-helmet"; Header,
import {useTranslation} from 'react-i18next' 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 {useConfig} from 'config' import { useConfig } from "config";
import styles from './App.module.less' import styles from "./App.module.less";
import {AVAILABLE_LOCALES, setLocale} from 'i18n' import { AVAILABLE_LOCALES, setLocale } from "i18n";
import { import {
ExportPage, ExportPage,
@ -25,50 +32,61 @@ import {
TrackPage, TrackPage,
TracksPage, TracksPage,
UploadPage, UploadPage,
} from 'pages' MyTracksPage,
import {Avatar, LoginButton} from 'components' } from "pages";
import api from 'api' import { Avatar, LoginButton } from "components";
import api from "api";
// This component removes the "navigate" prop before rendering a Menu.Item, // 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 // which is a workaround for an annoying warning that is somehow caused by the
// <Link /> and <Menu.Item /> combination. // <Link /> and <Menu.Item /> combination.
function MenuItemForLink({navigate, ...props}) { function MenuItemForLink({ navigate, ...props }) {
return ( return (
<Menu.Item <Menu.Item
{...props} {...props}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault();
navigate() navigate();
}} }}
/> />
) );
} }
function DropdownItemForLink({navigate, ...props}) { function DropdownItemForLink({ navigate, ...props }) {
return ( return (
<Dropdown.Item <Dropdown.Item
{...props} {...props}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault();
navigate() navigate();
}} }}
/> />
) );
} }
function Banner({text, style = 'warning'}: {text: string; style: 'warning' | 'info'}) { function Banner({
return <div className={classnames(styles.banner, styles[style])}>{text}</div> 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 App = connect((state) => ({ login: state.login }))(function App({
const {t} = useTranslation() login,
const config = useConfig() }) {
const apiVersion = useObservable(() => from(api.get('/info')).pipe(pluck('version'))) 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(() => { React.useEffect(() => {
api.loadUser() api.loadUser();
}, []) }, []);
return config ? ( return config ? (
<Router basename={config.basename}> <Router basename={config.basename}>
@ -79,36 +97,59 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
{config?.banner && <Banner {...config.banner} />} {config?.banner && <Banner {...config.banner} />}
<Menu className={styles.menu}> <Menu className={styles.menu}>
<Container> <Container>
<Link to="/" component={MenuItemForLink} header className={styles.pageTitle}> <Link
to="/"
component={MenuItemForLink}
header
className={styles.pageTitle}
>
OpenBikeSensor OpenBikeSensor
</Link> </Link>
{hasMap && ( {hasMap && (
<Link component={MenuItemForLink} to="/map" as="a"> <Link component={MenuItemForLink} to="/map" as="a">
{t('App.menu.map')} {t("App.menu.map")}
</Link> </Link>
)} )}
<Link component={MenuItemForLink} to="/tracks" as="a"> <Link component={MenuItemForLink} to="/tracks" as="a">
{t('App.menu.tracks')} {t("App.menu.tracks")}
</Link> </Link>
<Link component={MenuItemForLink} to="/export" as="a"> <Link component={MenuItemForLink} to="/export" as="a">
{t('App.menu.export')} {t("App.menu.export")}
</Link> </Link>
<Menu.Menu position="right"> <Menu.Menu position="right">
{login ? ( {login ? (
<> <>
<Link component={MenuItemForLink} to="/my/tracks" as="a"> <Link component={MenuItemForLink} to="/my/tracks" as="a">
{t('App.menu.myTracks')} {t("App.menu.myTracks")}
</Link> </Link>
<Dropdown item trigger={<Avatar user={login} className={styles.avatar} />}> <Dropdown
item
trigger={<Avatar user={login} className={styles.avatar} />}
>
<Dropdown.Menu> <Dropdown.Menu>
<Link to="/upload" component={DropdownItemForLink} icon="cloud upload" text={t('App.menu.uploadTracks')} /> <Link
<Link to="/settings" component={DropdownItemForLink} icon="cog" text={t('App.menu.settings')}/> 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 /> <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.Menu>
</Dropdown> </Dropdown>
</> </>
@ -125,14 +166,16 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
<Route path="/" exact> <Route path="/" exact>
<HomePage /> <HomePage />
</Route> </Route>
{hasMap && <Route path="/map" exact> {hasMap && (
<Route path="/map" exact>
<MapPage /> <MapPage />
</Route>} </Route>
)}
<Route path="/tracks" exact> <Route path="/tracks" exact>
<TracksPage /> <TracksPage />
</Route> </Route>
<Route path="/my/tracks" exact> <Route path="/my/tracks" exact>
<TracksPage privateTracks /> <MyTracksPage />
</Route> </Route>
<Route path={`/tracks/:slug`} exact> <Route path={`/tracks/:slug`} exact>
<TrackPage /> <TrackPage />
@ -169,12 +212,14 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
<Grid columns={4} stackable> <Grid columns={4} stackable>
<Grid.Row> <Grid.Row>
<Grid.Column> <Grid.Column>
<Header as="h5"> <Header as="h5">{t("App.footer.aboutTheProject")}</Header>
{t('App.footer.aboutTheProject')}
</Header>
<List> <List>
<List.Item> <List.Item>
<a href="https://openbikesensor.org/" target="_blank" rel="noreferrer"> <a
href="https://openbikesensor.org/"
target="_blank"
rel="noreferrer"
>
openbikesensor.org openbikesensor.org
</a> </a>
</List.Item> </List.Item>
@ -182,41 +227,57 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
</Grid.Column> </Grid.Column>
<Grid.Column> <Grid.Column>
<Header as="h5"> <Header as="h5">{t("App.footer.getInvolved")}</Header>
{t('App.footer.getInvolved')}
</Header>
<List> <List>
<List.Item> <List.Item>
<a href="https://forum.openbikesensor.org/" target="_blank" rel="noreferrer"> <a
{t('App.footer.getHelpInForum')} href="https://forum.openbikesensor.org/"
target="_blank"
rel="noreferrer"
>
{t("App.footer.getHelpInForum")}
</a> </a>
</List.Item> </List.Item>
<List.Item> <List.Item>
<a href="https://github.com/openbikesensor/portal/issues/new" target="_blank" rel="noreferrer"> <a
{t('App.footer.reportAnIssue')} href="https://github.com/openbikesensor/portal/issues/new"
target="_blank"
rel="noreferrer"
>
{t("App.footer.reportAnIssue")}
</a> </a>
</List.Item> </List.Item>
<List.Item> <List.Item>
<a href="https://github.com/openbikesensor/portal" target="_blank" rel="noreferrer"> <a
{t('App.footer.development')} href="https://github.com/openbikesensor/portal"
target="_blank"
rel="noreferrer"
>
{t("App.footer.development")}
</a> </a>
</List.Item> </List.Item>
</List> </List>
</Grid.Column> </Grid.Column>
<Grid.Column> <Grid.Column>
<Header as="h5"> <Header as="h5">{t("App.footer.thisInstallation")}</Header>
{t('App.footer.thisInstallation')}
</Header>
<List> <List>
<List.Item> <List.Item>
<a href={config?.privacyPolicyUrl} target="_blank" rel="noreferrer"> <a
{t('App.footer.privacyPolicy')} href={config?.privacyPolicyUrl}
target="_blank"
rel="noreferrer"
>
{t("App.footer.privacyPolicy")}
</a> </a>
</List.Item> </List.Item>
<List.Item> <List.Item>
<a href={config?.imprintUrl} target="_blank" rel="noreferrer"> <a
{t('App.footer.imprint')} href={config?.imprintUrl}
target="_blank"
rel="noreferrer"
>
{t("App.footer.imprint")}
</a> </a>
</List.Item> </List.Item>
{ config?.termsUrl && { config?.termsUrl &&
@ -229,21 +290,29 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
<List.Item> <List.Item>
<a <a
href={`https://github.com/openbikesensor/portal${ href={`https://github.com/openbikesensor/portal${
apiVersion ? `/releases/tag/${apiVersion}` : '' apiVersion ? `/releases/tag/${apiVersion}` : ""
}`} }`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
{apiVersion ? t('App.footer.version', {apiVersion}) : t('App.footer.versionLoading')} {apiVersion
? t("App.footer.version", { apiVersion })
: t("App.footer.versionLoading")}
</a> </a>
</List.Item> </List.Item>
</List> </List>
</Grid.Column> </Grid.Column>
<Grid.Column> <Grid.Column>
<Header as="h5">{t('App.footer.changeLanguage')}</Header> <Header as="h5">{t("App.footer.changeLanguage")}</Header>
<List> <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> </List>
</Grid.Column> </Grid.Column>
</Grid.Row> </Grid.Row>
@ -251,7 +320,7 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
</Container> </Container>
</div> </div>
</Router> </Router>
) : null ) : null;
}) });
export default App export default App;

View 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;

View file

@ -5,10 +5,7 @@ import { Duration } from "luxon";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FormattedDate, Visibility } from "components"; import { FormattedDate, Visibility } from "components";
import { formatDistance, formatDuration } from "utils";
function formatDuration(seconds) {
return Duration.fromMillis((seconds ?? 0) * 1000).toFormat("h'h' mm'm'");
}
export default function TrackDetails({ track, isAuthor }) { export default function TrackDetails({ track, isAuthor }) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -47,7 +44,7 @@ export default function TrackDetails({ track, isAuthor }) {
track?.length != null && [ track?.length != null && [
t("TrackPage.details.length"), t("TrackPage.details.length"),
`${(track?.length / 1000).toFixed(2)} km`, formatDistance(track?.length),
], ],
track?.processingStatus != null && track?.processingStatus != null &&
@ -63,19 +60,19 @@ export default function TrackDetails({ track, isAuthor }) {
].filter(Boolean); ].filter(Boolean);
const COLUMNS = 4; const COLUMNS = 4;
const chunkSize = Math.ceil(items.length / COLUMNS) const chunkSize = Math.ceil(items.length / COLUMNS);
return ( return (
<Grid> <Grid>
<Grid.Row columns={COLUMNS}> <Grid.Row columns={COLUMNS}>
{_.chunk(items, chunkSize).map((chunkItems, idx) => ( {_.chunk(items, chunkSize).map((chunkItems, idx) => (
<Grid.Column key={idx}> <Grid.Column key={idx}>
<List> <List>
{chunkItems.map(([title, value]) => ( {chunkItems.map(([title, value]) => (
<List.Item key={title}> <List.Item key={title}>
<List.Header>{title}</List.Header> <List.Header>{title}</List.Header>
<List.Description>{value}</List.Description> <List.Description>{value}</List.Description>
</List.Item>))} </List.Item>
))}
</List> </List>
</Grid.Column> </Grid.Column>
))} ))}

View file

@ -1,11 +1,12 @@
export {default as ExportPage} from './ExportPage' export { default as ExportPage } from "./ExportPage";
export {default as HomePage} from './HomePage' export { default as HomePage } from "./HomePage";
export {default as LoginRedirectPage} from './LoginRedirectPage' export { default as LoginRedirectPage } from "./LoginRedirectPage";
export {default as LogoutPage} from './LogoutPage' export { default as LogoutPage } from "./LogoutPage";
export {default as MapPage} from './MapPage' export { default as MapPage } from "./MapPage";
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 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 MyTracksPage } from "./MyTracksPage";
export { default as UploadPage } from "./UploadPage";

View file

@ -1,52 +1,67 @@
import type {FeatureCollection, Feature, LineString, Point} from 'geojson' import type { FeatureCollection, Feature, LineString, Point } from "geojson";
export type UserProfile = { export interface UserProfile {
id: number | string username: string;
displayName: string displayName: string;
image?: string | null image?: string | null;
bio?: string | null bio?: string | null;
} }
export type TrackData = { export interface TrackData {
track: Feature<LineString> track: Feature<LineString>;
measurements: FeatureCollection measurements: FeatureCollection;
overtakingEvents: FeatureCollection overtakingEvents: FeatureCollection;
} }
export type Track = { export type ProcessingStatus =
slug: string | "error"
author: UserProfile | "complete"
title: string | "created"
description?: string | "queued"
createdAt: string | "processing";
public?: boolean
recordedAt?: Date export interface Track {
recordedUntil?: Date slug: string;
duration?: number author: UserProfile;
length?: number title: string;
segments?: number description?: string;
numEvents?: number createdAt: string;
numMeasurements?: number processingStatus?: ProcessingStatus;
numValid?: number public?: boolean;
recordedAt?: Date;
recordedUntil?: Date;
duration?: number;
length?: number;
segments?: number;
numEvents?: number;
numMeasurements?: number;
numValid?: number;
userDeviceId?: number;
} }
export type TrackPoint = { export interface TrackPoint {
type: 'Feature' type: "Feature";
geometry: Point geometry: Point;
properties: { properties: {
distanceOvertaker: null | number distanceOvertaker: null | number;
distanceStationary: null | number distanceStationary: null | number;
} };
} }
export type TrackComment = { export interface TrackComment {
id: string id: string;
body: string body: string;
createdAt: string createdAt: string;
author: UserProfile author: UserProfile;
} }
export type Location { export interface Location {
longitude: number; longitude: number;
latitude: number; latitude: number;
} }
export interface UserDevice {
id: number;
identifier: string;
displayName?: string;
}

View file

@ -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 // 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 // any child of the provided element that is an input component will be
// registered. // registered.
export function findInput(register) { export function findInput(register) {
return (element) => { return (element) => {
const found = element ? element.querySelector('input, textarea, select, checkbox') : null const found = element
register(found) ? element.querySelector("input, textarea, select, checkbox")
} : null;
register(found);
};
} }
// Generates pairs from the input iterable // Generates pairs from the input iterable
export function* pairwise(it) { export function* pairwise(it) {
let lastValue let lastValue;
let firstRound = true let firstRound = true;
for (const i of it) { for (const i of it) {
if (firstRound) { if (firstRound) {
firstRound = false firstRound = false;
} else { } else {
yield [lastValue, i] yield [lastValue, i];
} }
lastValue = i lastValue = i;
} }
} }
export function useCallbackRef(fn) { export function useCallbackRef(fn) {
const fnRef = useRef() const fnRef = useRef();
fnRef.current = fn fnRef.current = fn;
return useCallback(((...args) => fnRef.current(...args)), []) 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`;
}
} }