diff --git a/api/obs/api/db.py b/api/obs/api/db.py
index 74b5b20..4e870ca 100644
--- a/api/obs/api/db.py
+++ b/api/obs/api/db.py
@@ -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)
diff --git a/api/obs/api/routes/tracks.py b/api/obs/api/routes/tracks.py
index 28f4097..e9df89c 100644
--- a/api/obs/api/routes/tracks.py
+++ b/api/obs/api/routes/tracks.py
@@ -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")
diff --git a/api/obs/api/routes/users.py b/api/obs/api/routes/users.py
index 60b0c19..38ffaf1 100644
--- a/api/obs/api/routes/users.py
+++ b/api/obs/api/routes/users.py
@@ -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):
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 9c66e8e..c3d7cfd 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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 {Helmet} from "react-helmet";
-import {useTranslation} from 'react-i18next'
+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 {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,50 +32,61 @@ 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
// and
combination.
-function MenuItemForLink({navigate, ...props}) {
+function MenuItemForLink({ navigate, ...props }) {
return (
{
- e.preventDefault()
- navigate()
+ e.preventDefault();
+ navigate();
}}
/>
- )
+ );
}
-function DropdownItemForLink({navigate, ...props}) {
+function DropdownItemForLink({ navigate, ...props }) {
return (
{
- e.preventDefault()
- navigate()
+ e.preventDefault();
+ navigate();
}}
/>
- )
+ );
}
-function Banner({text, style = 'warning'}: {text: string; style: 'warning' | 'info'}) {
- return {text}
+function Banner({
+ text,
+ style = "warning",
+}: {
+ text: string;
+ style: "warning" | "info";
+}) {
+ return {text}
;
}
-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 ? (
@@ -79,36 +97,59 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
{config?.banner && }
- ) : null
-})
+ ) : null;
+});
-export default App
+export default App;
diff --git a/frontend/src/pages/MyTracksPage.tsx b/frontend/src/pages/MyTracksPage.tsx
new file mode 100644
index 0000000..f8615f3
--- /dev/null
+++ b/frontend/src/pages/MyTracksPage.tsx
@@ -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 = {
+ error: "red",
+ complete: "green",
+ created: "grey",
+ queued: "orange",
+ processing: "orange",
+};
+
+const ICON_BY_STATUS: Record = {
+ error: "warning sign",
+ complete: "check circle outline",
+ created: "bolt",
+ queued: "bolt",
+ processing: "bolt",
+};
+
+function ProcessingStatusLabel({ status }: { status: ProcessingStatus }) {
+ const { t } = useTranslation();
+ return (
+
+
+
+ );
+}
+
+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 (
+
+
+ {children}
+
+
+
+ );
+}
+
+type Filters = {
+ userDeviceId?: null | number;
+ visibility?: null | boolean;
+};
+
+function TrackFilters({
+ filters,
+ setFilters,
+ deviceNames,
+}: {
+ filters: Filters;
+ setFilters: (f: Filters) => void;
+ deviceNames: null | Record;
+}) {
+ return (
+
+
+ Device
+ ({
+ value: Number(deviceId),
+ key: deviceId,
+ text: deviceName,
+ })
+ ),
+ ]}
+ value={filters?.userDeviceId ?? 0}
+ onChange={(_e, { value }) =>
+ setFilters({ ...filters, userDeviceId: (value as number) || null })
+ }
+ />
+
+
+
+ Visibility
+
+ setFilters({
+ ...filters,
+ visibility: value === "none" ? null : (value as boolean),
+ })
+ }
+ />
+
+
+ );
+}
+
+function TracksTable() {
+ const [orderBy, setOrderBy] = useState("recordedAt");
+ const [reversed, setReversed] = useState(false);
+ const [showFilters, setShowFilters] = useState(false);
+ const [filters, setFilters] = useState({});
+
+ 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 = 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 (
+
+
+
+
+ setShowFilters(!showFilters)}
+ >
+
+ Filters
+
+
+
+
+
+
+
+
+
+
+ Title
+
+
+ Recorded at
+
+
+ Visibility
+
+
+ Length
+
+
+ Duration
+
+
+ Device
+
+
+
+
+
+ {tracks?.map((track: Track) => (
+
+
+ {track.processingStatus == null ? null : (
+
+ )}
+
+ {track.title || t("general.unnamedTrack")}
+
+
+
+
+
+
+
+
+ {track.public == null ? null : (
+
+ )}
+
+
+
+ {formatDistance(track.length)}
+
+
+
+ {formatDuration(track.duration)}
+
+
+
+ {track.userDeviceId
+ ? deviceNames?.[track.userDeviceId] ?? "..."
+ : null}
+
+
+ ))}
+
+
+
+ );
+}
+
+function UploadButton({ navigate, ...props }) {
+ const { t } = useTranslation();
+ const onClick = useCallback(
+ (e) => {
+ e.preventDefault();
+ navigate();
+ },
+ [navigate]
+ );
+ return (
+
+ );
+}
+
+const MyTracksPage = connect((state) => ({ login: (state as any).login }))(
+ function MyTracksPage({ login }) {
+ const { t } = useTranslation();
+
+ const title = t("TracksPage.titleUser");
+
+ return (
+
+
+
+
+
+ );
+ }
+);
+
+export default MyTracksPage;
diff --git a/frontend/src/pages/TrackPage/TrackDetails.tsx b/frontend/src/pages/TrackPage/TrackDetails.tsx
index d5002b2..c94af57 100644
--- a/frontend/src/pages/TrackPage/TrackDetails.tsx
+++ b/frontend/src/pages/TrackPage/TrackDetails.tsx
@@ -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,23 +60,23 @@ 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 (
- {_.chunk(items, chunkSize).map((chunkItems, idx) => (
-
-
-
- {chunkItems.map(([title, value]) => (
-
- {title}
- {value}
- ))}
-
-
- ))}
-
+ {_.chunk(items, chunkSize).map((chunkItems, idx) => (
+
+
+ {chunkItems.map(([title, value]) => (
+
+ {title}
+ {value}
+
+ ))}
+
+
+ ))}
+
);
}
diff --git a/frontend/src/pages/index.js b/frontend/src/pages/index.js
index 07233ed..6d0a958 100644
--- a/frontend/src/pages/index.js
+++ b/frontend/src/pages/index.js
@@ -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";
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index e3f5c74..36f5cfd 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -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
- measurements: FeatureCollection
- overtakingEvents: FeatureCollection
+export interface TrackData {
+ track: Feature;
+ 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;
+}
diff --git a/frontend/src/utils.js b/frontend/src/utils.js
index 2fdb1a7..f4f3dc7 100644
--- a/frontend/src/utils.js
+++ b/frontend/src/utils.js
@@ -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`;
+ }
}