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 && } - + OpenBikeSensor {hasMap && ( - - {t('App.menu.map')} - + + {t("App.menu.map")} + )} - {t('App.menu.tracks')} + {t("App.menu.tracks")} - {t('App.menu.export')} + {t("App.menu.export")} {login ? ( <> - {t('App.menu.myTracks')} + {t("App.menu.myTracks")} - }> + } + > - - + + - + @@ -125,14 +166,16 @@ const App = connect((state) => ({login: state.login}))(function App({login}) { - {hasMap && - - } + {hasMap && ( + + + + )} - + @@ -169,12 +212,14 @@ const App = connect((state) => ({login: state.login}))(function App({login}) { -
- {t('App.footer.aboutTheProject')} -
+
{t("App.footer.aboutTheProject")}
- + openbikesensor.org @@ -182,41 +227,57 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
-
- {t('App.footer.getInvolved')} -
+
{t("App.footer.getInvolved")}
- - {t('App.footer.getHelpInForum')} + + {t("App.footer.getHelpInForum")} - - {t('App.footer.reportAnIssue')} + + {t("App.footer.reportAnIssue")} - - {t('App.footer.development')} + + {t("App.footer.development")}
-
- {t('App.footer.thisInstallation')} -
+
{t("App.footer.thisInstallation")}
- - {t('App.footer.privacyPolicy')} + + {t("App.footer.privacyPolicy")} - - {t('App.footer.imprint')} + + {t("App.footer.imprint")} { config?.termsUrl && @@ -229,21 +290,29 @@ const App = connect((state) => ({login: state.login}))(function App({login}) { - {apiVersion ? t('App.footer.version', {apiVersion}) : t('App.footer.versionLoading')} + {apiVersion + ? t("App.footer.version", { apiVersion }) + : t("App.footer.versionLoading")}
-
{t('App.footer.changeLanguage')}
+
{t("App.footer.changeLanguage")}
- {AVAILABLE_LOCALES.map(locale => setLocale(locale)}>{t(`locales.${locale}`)})} + {AVAILABLE_LOCALES.map((locale) => ( + + setLocale(locale)}> + {t(`locales.${locale}`)} + + + ))}
@@ -251,7 +320,7 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
- ) : 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 ( + + +
{title}
+ +
+ ); + } +); + +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`; + } }