Merge branch 'device-identifiers' into next

This commit is contained in:
Paul Bienkowski 2023-03-12 13:38:42 +01:00
commit 0d44560830
24 changed files with 1431 additions and 458 deletions

View file

@ -0,0 +1,41 @@
"""add user_device
Revision ID: f7b21148126a
Revises: a9627f63fbed
Create Date: 2022-09-15 17:48:06.764342
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "f7b21148126a"
down_revision = "a049e5eb24dd"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"user_device",
sa.Column("id", sa.Integer, autoincrement=True, primary_key=True),
sa.Column("user_id", sa.Integer, sa.ForeignKey("user.id", ondelete="CASCADE")),
sa.Column("identifier", sa.String, nullable=False),
sa.Column("display_name", sa.String, nullable=True),
sa.Index("user_id_identifier", "user_id", "identifier", unique=True),
)
op.add_column(
"track",
sa.Column(
"user_device_id",
sa.Integer,
sa.ForeignKey("user_device.id", ondelete="RESTRICT"),
nullable=True,
),
)
def downgrade():
op.drop_column("track", "user_device_id")
op.drop_table("user_device")

View file

@ -221,6 +221,12 @@ class Track(Base):
Integer, ForeignKey("user.id", ondelete="CASCADE"), nullable=False Integer, ForeignKey("user.id", ondelete="CASCADE"), nullable=False
) )
user_device_id = Column(
Integer,
ForeignKey("user_device.id", ondelete="RESTRICT"),
nullable=True,
)
# Statistics... maybe we'll drop some of this if we can easily compute them from SQL # Statistics... maybe we'll drop some of this if we can easily compute them from SQL
recorded_at = Column(DateTime) recorded_at = Column(DateTime)
recorded_until = Column(DateTime) recorded_until = Column(DateTime)
@ -253,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)
@ -409,6 +416,28 @@ class User(Base):
self.username = new_name self.username = new_name
class UserDevice(Base):
__tablename__ = "user_device"
id = Column(Integer, autoincrement=True, primary_key=True)
user_id = Column(Integer, ForeignKey("user.id", ondelete="CASCADE"))
identifier = Column(String, nullable=False)
display_name = Column(String, nullable=True)
__table_args__ = (
Index("user_id_identifier", "user_id", "identifier", unique=True),
)
def to_dict(self, for_user_id=None):
if for_user_id != self.user_id:
return {}
return {
"id": self.id,
"identifier": self.identifier,
"displayName": self.display_name,
}
class Comment(Base): class Comment(Base):
__tablename__ = "comment" __tablename__ = "comment"
id = Column(Integer, autoincrement=True, primary_key=True) id = Column(Integer, autoincrement=True, primary_key=True)
@ -468,6 +497,14 @@ Track.overtaking_events = relationship(
passive_deletes=True, passive_deletes=True,
) )
Track.user_device = relationship("UserDevice", back_populates="tracks")
UserDevice.tracks = relationship(
"Track",
order_by=Track.created_at,
back_populates="user_device",
passive_deletes=False,
)
# 0..4 Night, 4..10 Morning, 10..14 Noon, 14..18 Afternoon, 18..22 Evening, 22..00 Night # 0..4 Night, 4..10 Morning, 10..14 Noon, 14..18 Afternoon, 18..22 Evening, 22..00 Night
# Two hour intervals # Two hour intervals

View file

@ -8,7 +8,7 @@ import pytz
from os.path import join from os.path import join
from datetime import datetime from datetime import datetime
from sqlalchemy import delete, select from sqlalchemy import delete, select, and_
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from obs.face.importer import ImportMeasurementsCsv from obs.face.importer import ImportMeasurementsCsv
@ -27,7 +27,7 @@ from obs.face.filter import (
from obs.face.osm import DataSource, DatabaseTileSource, OverpassTileSource from obs.face.osm import DataSource, DatabaseTileSource, OverpassTileSource
from obs.api.db import OvertakingEvent, RoadUsage, Track, make_session from obs.api.db import OvertakingEvent, RoadUsage, Track, UserDevice, make_session
from obs.api.app import app from obs.api.app import app
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -144,10 +144,11 @@ async def process_track(session, track, data_source):
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
log.info("Annotating and filtering CSV file") log.info("Annotating and filtering CSV file")
imported_data, statistics = ImportMeasurementsCsv().read( imported_data, statistics, track_metadata = ImportMeasurementsCsv().read(
original_file_path, original_file_path,
user_id="dummy", # TODO: user username or id or nothing? user_id="dummy", # TODO: user username or id or nothing?
dataset_id=Track.slug, # TODO: use track id or slug or nothing? dataset_id=Track.slug, # TODO: use track id or slug or nothing?
return_metadata=True,
) )
annotator = AnnotateMeasurements( annotator = AnnotateMeasurements(
@ -217,6 +218,36 @@ async def process_track(session, track, data_source):
await clear_track_data(session, track) await clear_track_data(session, track)
await session.commit() await session.commit()
device_identifier = track_metadata.get("DeviceId")
if device_identifier:
if isinstance(device_identifier, list):
device_identifier = device_identifier[0]
log.info("Finding or creating device %s", device_identifier)
user_device = (
await session.execute(
select(UserDevice).where(
and_(
UserDevice.user_id == track.author_id,
UserDevice.identifier == device_identifier,
)
)
)
).scalar()
log.debug("user_device is %s", user_device)
if not user_device:
user_device = UserDevice(
user_id=track.author_id, identifier=device_identifier
)
log.debug("Create new device for this user")
session.add(user_device)
track.user_device = user_device
else:
log.info("No DeviceId in track metadata.")
log.info("Import events into database...") log.info("Import events into database...")
await import_overtaking_events(session, track, overtaking_events) await import_overtaking_events(session, track, overtaking_events)

View file

@ -3,7 +3,7 @@ import re
from json import load as jsonload from json import load as jsonload
from os.path import join, exists, isfile from os.path import join, exists, isfile
from sqlalchemy import select, func from sqlalchemy import select, func, and_
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from obs.api.db import Track, User, Comment, DuplicateTrackFileError from obs.api.db import Track, User, Comment, DuplicateTrackFileError
@ -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,89 @@ 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/bulk")
@require_auth
async def tracks_bulk_action(req):
body = req.json
action = body["action"]
track_slugs = body["tracks"]
if action not in ("delete", "makePublic", "makePrivate", "reprocess"):
raise InvalidUsage("invalid action")
query = select(Track).where(
and_(Track.author_id == req.ctx.user.id, Track.slug.in_(track_slugs))
)
for track in (await req.ctx.db.execute(query)).scalars():
if action == "delete":
await req.ctx.db.delete(track)
elif action == "makePublic":
if not track.public:
track.queue_processing()
track.public = True
elif action == "makePrivate":
if track.public:
track.queue_processing()
track.public = False
elif action == "reprocess":
track.queue_processing()
await req.ctx.db.commit()
return empty()
@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, NotFound
from sqlalchemy import and_, 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,48 @@ 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/devices/<device_id:int>")
async def put_user_device(req, device_id):
if not req.ctx.user:
raise Forbidden()
body = req.json
query = (
select(UserDevice)
.where(and_(UserDevice.user_id == req.ctx.user.id, UserDevice.id == device_id))
.limit(1)
)
device = (await req.ctx.db.execute(query)).scalar()
if device is None:
raise NotFound()
new_name = body.get("displayName", "").strip()
if new_name and device.display_name != new_name:
device.display_name = new_name
await req.ctx.db.commit()
return json(device.to_dict())
@api.put("/user") @api.put("/user")
@require_auth @require_auth
async def put_user(req): async def put_user(req):

View file

@ -11,3 +11,4 @@ sqlalchemy[asyncio]~=1.4.39 <2.0
asyncpg~=0.24.0 asyncpg~=0.24.0
pyshp~=2.3.1 pyshp~=2.3.1
alembic~=1.7.7 alembic~=1.7.7
stream-zip~=0.0.50

@ -1 +1 @@
Subproject commit 8e9395fd3cd0f1e83b4413546bc2d3cb0c726738 Subproject commit bbc6feca08aee9ea4f4263bb7c07e199d9c989ee

View file

@ -23,6 +23,7 @@ setup(
"sqlalchemy[asyncio]~=1.4.25", "sqlalchemy[asyncio]~=1.4.25",
"asyncpg~=0.24.0", "asyncpg~=0.24.0",
"alembic~=1.7.7", "alembic~=1.7.7",
"stream-zip~=0.0.50",
], ],
entry_points={ entry_points={
"console_scripts": [ "console_scripts": [

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 && (
<MapPage /> <Route path="/map" exact>
</Route>} <MapPage />
</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

@ -1,118 +1,145 @@
import React, {useState, useCallback} from 'react' import React, { useState, useCallback } from "react";
import {pickBy} from 'lodash' import { pickBy } from "lodash";
import {Loader, Statistic, Segment, Header, Menu} from 'semantic-ui-react' import { Loader, Statistic, Segment, Header, Menu } from "semantic-ui-react";
import {useObservable} from 'rxjs-hooks' import { useObservable } from "rxjs-hooks";
import {of, from, concat, combineLatest} from 'rxjs' import { of, from, concat, combineLatest } from "rxjs";
import {map, switchMap, distinctUntilChanged} from 'rxjs/operators' import { map, switchMap, distinctUntilChanged } from "rxjs/operators";
import {Duration, DateTime} from 'luxon' import { Duration, DateTime } from "luxon";
import {useTranslation} from 'react-i18next' import { useTranslation } from "react-i18next";
import api from 'api' import api from "api";
function formatDuration(seconds) { function formatDuration(seconds) {
return ( return (
Duration.fromMillis((seconds ?? 0) * 1000) Duration.fromMillis((seconds ?? 0) * 1000)
.as('hours') .as("hours")
.toFixed(1) + ' h' .toFixed(1) + " h"
) );
} }
export default function Stats({user = null}: {user?: null | string}) { export default function Stats({ user = null }: { user?: null | string }) {
const {t} = useTranslation() const { t } = useTranslation();
const [timeframe, setTimeframe] = useState('all_time') const [timeframe, setTimeframe] = useState("all_time");
const onClick = useCallback((_e, {name}) => setTimeframe(name), [setTimeframe]) const onClick = useCallback(
(_e, { name }) => setTimeframe(name),
[setTimeframe]
);
const stats = useObservable( const stats = useObservable(
(_$, inputs$) => { (_$, inputs$) => {
const timeframe$ = inputs$.pipe( const timeframe$ = inputs$.pipe(
map((inputs) => inputs[0]), map((inputs) => inputs[0]),
distinctUntilChanged() distinctUntilChanged()
) );
const user$ = inputs$.pipe( const user$ = inputs$.pipe(
map((inputs) => inputs[1]), map((inputs) => inputs[1]),
distinctUntilChanged() distinctUntilChanged()
) );
return combineLatest(timeframe$, user$).pipe( return combineLatest(timeframe$, user$).pipe(
map(([timeframe_, user_]) => { map(([timeframe_, user_]) => {
const now = DateTime.now() const now = DateTime.now();
let start, end let start, end;
switch (timeframe_) { switch (timeframe_) {
case 'this_month': case "this_month":
start = now.startOf('month') start = now.startOf("month");
end = now.endOf('month') end = now.endOf("month");
break break;
case 'this_year': case "this_year":
start = now.startOf('year') start = now.startOf("year");
end = now.endOf('year') end = now.endOf("year");
break break;
} }
return pickBy({ return pickBy({
start: start?.toISODate(), start: start?.toISODate(),
end: end?.toISODate(), end: end?.toISODate(),
user: user_, user: user_,
}) });
}), }),
switchMap((query) => concat(of(null), from(api.get('/stats', {query})))) switchMap((query) =>
) concat(of(null), from(api.get("/stats", { query })))
)
);
}, },
null, null,
[timeframe, user] [timeframe, user]
) );
const placeholder = t('Stats.placeholder') const placeholder = t("Stats.placeholder");
return ( return (
<> <>
<Header as="h2">{user ? t('Stats.titleUser') : t('Stats.title')}</Header>
<div> <div>
<Segment attached="top"> <Segment attached="top">
<Loader active={stats == null} /> <Loader active={stats == null} />
<Statistic.Group widths={2} size="tiny"> <Statistic.Group widths={2} size="tiny">
<Statistic> <Statistic>
<Statistic.Value>{stats ? `${Number(stats?.trackLength / 1000).toFixed(1)} km` : placeholder}</Statistic.Value> <Statistic.Value>
<Statistic.Label>{t('Stats.totalTrackLength')}</Statistic.Label> {stats
? `${Number(stats?.trackLength / 1000).toFixed(1)} km`
: placeholder}
</Statistic.Value>
<Statistic.Label>{t("Stats.totalTrackLength")}</Statistic.Label>
</Statistic> </Statistic>
<Statistic> <Statistic>
<Statistic.Value>{stats ? formatDuration(stats?.trackDuration) : placeholder}</Statistic.Value> <Statistic.Value>
<Statistic.Label>{t('Stats.timeRecorded')}</Statistic.Label> {stats ? formatDuration(stats?.trackDuration) : placeholder}
</Statistic.Value>
<Statistic.Label>{t("Stats.timeRecorded")}</Statistic.Label>
</Statistic> </Statistic>
<Statistic> <Statistic>
<Statistic.Value>{stats?.numEvents ?? placeholder}</Statistic.Value> <Statistic.Value>
<Statistic.Label>{t('Stats.eventsConfirmed')}</Statistic.Label> {stats?.numEvents ?? placeholder}
</Statistic.Value>
<Statistic.Label>{t("Stats.eventsConfirmed")}</Statistic.Label>
</Statistic> </Statistic>
{user ? ( {user ? (
<Statistic> <Statistic>
<Statistic.Value>{stats?.trackCount ?? placeholder}</Statistic.Value> <Statistic.Value>
<Statistic.Label>{t('Stats.tracksRecorded')}</Statistic.Label> {stats?.trackCount ?? placeholder}
</Statistic.Value>
<Statistic.Label>{t("Stats.tracksRecorded")}</Statistic.Label>
</Statistic> </Statistic>
) : ( ) : (
<Statistic> <Statistic>
<Statistic.Value>{stats?.userCount ?? placeholder}</Statistic.Value> <Statistic.Value>
<Statistic.Label>{t('Stats.membersJoined')}</Statistic.Label> {stats?.userCount ?? placeholder}
</Statistic.Value>
<Statistic.Label>{t("Stats.membersJoined")}</Statistic.Label>
</Statistic> </Statistic>
)} )}
</Statistic.Group> </Statistic.Group>
</Segment> </Segment>
<Menu widths={3} attached="bottom" size="small"> <Menu widths={3} attached="bottom" size="small">
<Menu.Item name="this_month" active={timeframe === 'this_month'} onClick={onClick}> <Menu.Item
{t('Stats.thisMonth')} name="this_month"
active={timeframe === "this_month"}
onClick={onClick}
>
{t("Stats.thisMonth")}
</Menu.Item> </Menu.Item>
<Menu.Item name="this_year" active={timeframe === 'this_year'} onClick={onClick}> <Menu.Item
{t('Stats.thisYear')} name="this_year"
active={timeframe === "this_year"}
onClick={onClick}
>
{t("Stats.thisYear")}
</Menu.Item> </Menu.Item>
<Menu.Item name="all_time" active={timeframe === 'all_time'} onClick={onClick}> <Menu.Item
{t('Stats.allTime')} name="all_time"
active={timeframe === "all_time"}
onClick={onClick}
>
{t("Stats.allTime")}
</Menu.Item> </Menu.Item>
</Menu> </Menu>
</div> </div>
</> </>
) );
} }

View file

@ -0,0 +1,413 @@
import React, { useCallback, useMemo, useState } from "react";
import { connect } from "react-redux";
import {
Accordion,
Button,
Checkbox,
Confirm,
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, BehaviorSubject, combineLatest } 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 { useCallbackRef, 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({ title }) {
const [orderBy, setOrderBy] = useState("recordedAt");
const [reversed, setReversed] = useState(false);
const [showFilters, setShowFilters] = useState(false);
const [filters, setFilters] = useState<Filters>({});
const [selectedTracks, setSelectedTracks] = useState<Record<string, boolean>>(
{}
);
const toggleTrackSelection = useCallbackRef(
(slug: string, selected?: boolean) => {
const newSelected = selected ?? !selectedTracks[slug];
setSelectedTracks(
_.pickBy({ ...selectedTracks, [slug]: newSelected }, _.identity)
);
}
);
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 forceUpdate$ = useMemo(() => new BehaviorSubject(null), []);
const tracks: Track[] | null = useObservable(
(_$, inputs$) =>
combineLatest([
inputs$.pipe(
map(([query]) => query),
distinctUntilChanged(_.isEqual)
),
forceUpdate$,
]).pipe(
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 };
const selectedCount = Object.keys(selectedTracks).length;
const noneSelected = selectedCount === 0;
const allSelected = selectedCount === tracks?.length;
const selectAll = () => {
setSelectedTracks(
Object.fromEntries(tracks?.map((t) => [t.slug, true]) ?? [])
);
};
const selectNone = () => {
setSelectedTracks({});
};
const bulkAction = async (action: string) => {
await api.post("/tracks/bulk", {
body: {
action,
tracks: Object.keys(selectedTracks),
},
});
setShowBulkDelete(false);
setSelectedTracks({});
forceUpdate$.next(null);
};
const [showBulkDelete, setShowBulkDelete] = useState(false);
return (
<>
<div style={{ float: "right" }}>
<Dropdown disabled={noneSelected} text="Bulk actions" floating button>
<Dropdown.Menu>
<Dropdown.Header>
Selection of {selectedCount} tracks
</Dropdown.Header>
<Dropdown.Item onClick={() => bulkAction("makePrivate")}>
Make private
</Dropdown.Item>
<Dropdown.Item onClick={() => bulkAction("makePublic")}>
Make public
</Dropdown.Item>
<Dropdown.Item onClick={() => bulkAction("reprocess")}>
Reprocess
</Dropdown.Item>
<Dropdown.Item onClick={() => setShowBulkDelete(true)}>
Delete
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
<Link component={UploadButton} to="/upload" />
</div>
<Header as="h1">{title}</Header>
<div style={{ clear: "both" }}>
<Loader content={t("general.loading")} active={tracks == null} />
<Accordion>
<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>
<Confirm
open={showBulkDelete}
onCancel={() => setShowBulkDelete(false)}
onConfirm={() => bulkAction("delete")}
content={`Are you sure you want to delete ${selectedCount} tracks?`}
confirmButton={t("general.delete")}
cancelButton={t("general.cancel")}
/>
<Table compact>
<Table.Header>
<Table.Row>
<Table.HeaderCell>
<Checkbox
checked={allSelected}
indeterminate={!allSelected && !noneSelected}
onClick={() => (noneSelected ? selectAll() : selectNone())}
/>
</Table.HeaderCell>
<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>
<Checkbox
onClick={(e) => toggleTrackSelection(track.slug)}
checked={selectedTracks[track.slug] ?? false}
/>
</Table.Cell>
<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">
{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}>
<TracksTable {...{ title }} />
</Page>
);
}
);
export default MyTracksPage;

View file

@ -1,227 +0,0 @@
import React from "react";
import { connect } from "react-redux";
import {
Message,
Icon,
Grid,
Form,
Button,
TextArea,
Ref,
Input,
Header,
Divider,
Popup,
} from "semantic-ui-react";
import { useForm } from "react-hook-form";
import Markdown from "react-markdown";
import { useTranslation } from "react-i18next";
import { setLogin } from "reducers/login";
import { Page, Stats } from "components";
import api from "api";
import { findInput } from "utils";
import { useConfig } from "config";
const SettingsPage = connect((state) => ({ login: state.login }), { setLogin })(
function SettingsPage({ login, setLogin }) {
const { t } = useTranslation();
const { register, handleSubmit } = useForm();
const [loading, setLoading] = React.useState(false);
const [errors, setErrors] = React.useState(null);
const onSave = React.useCallback(
async (changes) => {
setLoading(true);
setErrors(null);
try {
const response = await api.put("/user", { body: changes });
setLogin(response);
} catch (err) {
setErrors(err.errors);
} finally {
setLoading(false);
}
},
[setLoading, setLogin, setErrors]
);
const onGenerateNewKey = React.useCallback(async () => {
setLoading(true);
setErrors(null);
try {
const response = await api.put("/user", {
body: { updateApiKey: true },
});
setLogin(response);
} catch (err) {
setErrors(err.errors);
} finally {
setLoading(false);
}
}, [setLoading, setLogin, setErrors]);
return (
<Page title={t("SettingsPage.title")}>
<Grid centered relaxed divided stackable>
<Grid.Row>
<Grid.Column width={8}>
<Header as="h2">{t("SettingsPage.profile.title")}</Header>
<Form onSubmit={handleSubmit(onSave)} loading={loading}>
<Form.Field error={errors?.username}>
<label>{t("SettingsPage.profile.username.label")}</label>
<Ref innerRef={findInput(register)}>
<Input
name="username"
defaultValue={login.username}
disabled
/>
</Ref>
<small>{t("SettingsPage.profile.username.hint")}</small>
</Form.Field>
<Message info visible>
{t("SettingsPage.profile.publicNotice")}
</Message>
<Form.Field error={errors?.displayName}>
<label>{t("SettingsPage.profile.displayName.label")}</label>
<Ref innerRef={findInput(register)}>
<Input
name="displayName"
defaultValue={login.displayName}
placeholder={login.username}
/>
</Ref>
<small>
{t("SettingsPage.profile.displayName.fallbackNotice")}
</small>
</Form.Field>
<Form.Field error={errors?.bio}>
<label>{t("SettingsPage.profile.bio.label")}</label>
<Ref innerRef={register}>
<TextArea name="bio" rows={4} defaultValue={login.bio} />
</Ref>
</Form.Field>
<Form.Field error={errors?.image}>
<label>{t("SettingsPage.profile.avatarUrl.label")}</label>
<Ref innerRef={findInput(register)}>
<Input name="image" defaultValue={login.image} />
</Ref>
</Form.Field>
<Button type="submit" primary>
{t("general.save")}
</Button>
</Form>
</Grid.Column>
<Grid.Column width={6}>
<ApiKeyDialog {...{ login, onGenerateNewKey }} />
<Divider />
<Stats user={login.id} />
</Grid.Column>
</Grid.Row>
</Grid>
</Page>
);
}
);
function CopyInput({ value, ...props }) {
const { t } = useTranslation();
const [success, setSuccess] = React.useState(null);
const onClick = async () => {
try {
await window.navigator?.clipboard?.writeText(value);
setSuccess(true);
} catch (err) {
setSuccess(false);
} finally {
setTimeout(() => {
setSuccess(null);
}, 2000);
}
};
return (
<Popup
trigger={
<Input
{...props}
value={value}
fluid
action={{ icon: "copy", onClick }}
/>
}
position="top right"
open={success != null}
content={success ? t("general.copied") : t("general.copyError")}
/>
);
}
const selectField = findInput((ref) => ref?.select());
function ApiKeyDialog({ login, onGenerateNewKey }) {
const { t } = useTranslation();
const config = useConfig();
const [show, setShow] = React.useState(false);
const onClick = React.useCallback(
(e) => {
e.preventDefault();
setShow(true);
},
[setShow]
);
const onGenerateNewKeyInner = React.useCallback(
(e) => {
e.preventDefault();
onGenerateNewKey();
},
[onGenerateNewKey]
);
return (
<>
<Header as="h2">{t("SettingsPage.apiKey.title")}</Header>
<Markdown>{t("SettingsPage.apiKey.description")}</Markdown>
<div style={{ minHeight: 40, marginBottom: 16 }}>
{show ? (
login.apiKey ? (
<Ref innerRef={selectField}>
<CopyInput
label={t("SettingsPage.apiKey.key.label")}
value={login.apiKey}
/>
</Ref>
) : (
<Message warning content={t("SettingsPage.apiKey.key.empty")} />
)
) : (
<Button onClick={onClick}>
<Icon name="lock" /> {t("SettingsPage.apiKey.key.show")}
</Button>
)}
</div>
<Markdown>{t("SettingsPage.apiKey.urlDescription")}</Markdown>
<div style={{ marginBottom: 16 }}>
<CopyInput
label={t("SettingsPage.apiKey.url.label")}
value={config?.apiUrl?.replace(/\/api$/, "") ?? "..."}
/>
</div>
<Markdown>{t("SettingsPage.apiKey.generateDescription")}</Markdown>
<p></p>
<Button onClick={onGenerateNewKeyInner}>
{t("SettingsPage.apiKey.generate")}
</Button>
</>
);
}
export default SettingsPage;

View file

@ -0,0 +1,125 @@
import React from "react";
import { connect } from "react-redux";
import {
Message,
Icon,
Button,
Ref,
Input,
Segment,
Popup,
} from "semantic-ui-react";
import Markdown from "react-markdown";
import { useTranslation } from "react-i18next";
import { setLogin } from "reducers/login";
import api from "api";
import { findInput } from "utils";
import { useConfig } from "config";
function CopyInput({ value, ...props }) {
const { t } = useTranslation();
const [success, setSuccess] = React.useState(null);
const onClick = async () => {
try {
await window.navigator?.clipboard?.writeText(value);
setSuccess(true);
} catch (err) {
setSuccess(false);
} finally {
setTimeout(() => {
setSuccess(null);
}, 2000);
}
};
return (
<Popup
trigger={
<Input
{...props}
value={value}
fluid
action={{ icon: "copy", onClick }}
/>
}
position="top right"
open={success != null}
content={success ? t("general.copied") : t("general.copyError")}
/>
);
}
const selectField = findInput((ref) => ref?.select());
const ApiKeySettings = connect((state) => ({ login: state.login }), {
setLogin,
})(function ApiKeySettings({ login, setLogin, setErrors }) {
const { t } = useTranslation();
const [loading, setLoading] = React.useState(false);
const config = useConfig();
const [show, setShow] = React.useState(false);
const onClick = React.useCallback(
(e) => {
e.preventDefault();
setShow(true);
},
[setShow]
);
const onGenerateNewKey = React.useCallback(
async (e) => {
e.preventDefault();
setLoading(true);
try {
const response = await api.put("/user", {
body: { updateApiKey: true },
});
setLogin(response);
} catch (err) {
setErrors(err.errors);
} finally {
setLoading(false);
}
},
[setLoading, setLogin, setErrors]
);
return (
<Segment style={{ maxWidth: 600, margin: "24px auto" }}>
<Markdown>{t("SettingsPage.apiKey.description")}</Markdown>
<div style={{ minHeight: 40, marginBottom: 16 }}>
{show ? (
login.apiKey ? (
<Ref innerRef={selectField}>
<CopyInput
label={t("SettingsPage.apiKey.key.label")}
value={login.apiKey}
/>
</Ref>
) : (
<Message warning content={t("SettingsPage.apiKey.key.empty")} />
)
) : (
<Button onClick={onClick}>
<Icon name="lock" /> {t("SettingsPage.apiKey.key.show")}
</Button>
)}
</div>
<Markdown>{t("SettingsPage.apiKey.urlDescription")}</Markdown>
<div style={{ marginBottom: 16 }}>
<CopyInput
label={t("SettingsPage.apiKey.url.label")}
value={config?.apiUrl?.replace(/\/api$/, "") ?? "..."}
/>
</div>
<Markdown>{t("SettingsPage.apiKey.generateDescription")}</Markdown>
<p></p>
<Button onClick={onGenerateNewKey}>
{t("SettingsPage.apiKey.generate")}
</Button>
</Segment>
);
});
export default ApiKeySettings;

View file

@ -0,0 +1,126 @@
import React, {useCallback, useMemo, useRef} from 'react'
import {useObservable} from 'rxjs-hooks'
import {concat, from, of, Subject} from 'rxjs'
import {Table, Button, Input} from 'semantic-ui-react'
import {useTranslation} from 'react-i18next'
import api from 'api'
import {UserDevice} from 'types'
import {startWith, switchMap} from 'rxjs/operators'
function EditField({value, onEdit}) {
const [editing, setEditing] = React.useState(false)
const [tempValue, setTempValue] = React.useState(value)
const timeoutRef = useRef<null | number>(null)
const cancelTimeout = useCallback(() => {
if (timeoutRef.current != null) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}, [timeoutRef])
const abort = useCallback(() => {
cancelTimeout()
setEditing(false)
setTempValue(value)
}, [setEditing, setTempValue, value, cancelTimeout])
const confirm = useCallback(() => {
cancelTimeout()
setEditing(false)
onEdit(tempValue)
}, [setEditing, onEdit, tempValue, cancelTimeout])
React.useEffect(() => {
if (value !== tempValue) {
setTempValue(value)
}
}, [value])
if (editing) {
return (
<>
<Input
value={tempValue}
onChange={(e) => setTempValue(e.target.value)}
onBlur={(e) => {
timeoutRef.current = setTimeout(abort, 20)
}}
onKeyPress={(e) => {
if (e.key === 'Enter') {
confirm()
} else if (e.key === 'Escape') {
abort()
}
}}
style={{marginRight: 8}}
/>
<Button icon="check" size="tiny" onClick={confirm} />
<Button icon="repeat" size="tiny" onClick={abort} />
</>
)
} else {
return (
<>
{value && <span style={{marginRight: 8}}>{value}</span>}
<Button icon="edit" size="tiny" onClick={() => setEditing(true)} />
</>
)
}
}
export default function DeviceList() {
const {t} = useTranslation()
const [loading_, setLoading] = React.useState(false)
const trigger$ = useMemo(() => new Subject(), [])
const devices: null | UserDevice[] = useObservable(() =>
trigger$.pipe(
startWith(null),
switchMap(() => concat(of(null), from(api.get('/user/devices'))))
)
)
const setDeviceDisplayName = useCallback(
async (deviceId: number, displayName: string) => {
setLoading(true)
try {
await api.put(`/user/devices/${deviceId}`, {body: {displayName}})
} finally {
setLoading(false)
trigger$.next(null)
}
},
[trigger$, setLoading]
)
const loading = devices == null || loading_
return (
<>
<Table compact {...{loading}}>
<Table.Header>
<Table.Row>
<Table.HeaderCell width={4}>{t('SettingsPage.devices.identifier')}</Table.HeaderCell>
<Table.HeaderCell>{t('SettingsPage.devices.alias')}</Table.HeaderCell>
<Table.HeaderCell />
</Table.Row>
</Table.Header>
<Table.Body>
{devices?.map((device: UserDevice) => (
<Table.Row key={device.id}>
<Table.Cell> {device.identifier}</Table.Cell>
<Table.Cell>
<EditField
value={device.displayName}
onEdit={(displayName: string) => setDeviceDisplayName(device.id, displayName)}
/>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</>
)
}

View file

@ -0,0 +1,89 @@
import React from "react";
import { connect } from "react-redux";
import {
Segment,
Message,
Form,
Button,
TextArea,
Ref,
Input,
} from "semantic-ui-react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { setLogin } from "reducers/login";
import api from "api";
import { findInput } from "utils";
const UserSettingsForm = connect((state) => ({ login: state.login }), {
setLogin,
})(function UserSettingsForm({ login, setLogin, errors, setErrors }) {
const { t } = useTranslation();
const { register, handleSubmit } = useForm();
const [loading, setLoading] = React.useState(false);
const onSave = React.useCallback(
async (changes) => {
setLoading(true);
setErrors(null);
try {
const response = await api.put("/user", { body: changes });
setLogin(response);
} catch (err) {
setErrors(err.errors);
} finally {
setLoading(false);
}
},
[setLoading, setLogin, setErrors]
);
return (
<Segment style={{ maxWidth: 600 }}>
<Form onSubmit={handleSubmit(onSave)} loading={loading}>
<Form.Field error={errors?.username}>
<label>{t("SettingsPage.profile.username.label")}</label>
<Ref innerRef={findInput(register)}>
<Input name="username" defaultValue={login.username} disabled />
</Ref>
<small>{t("SettingsPage.profile.username.hint")}</small>
</Form.Field>
<Message info visible>
{t("SettingsPage.profile.publicNotice")}
</Message>
<Form.Field error={errors?.displayName}>
<label>{t("SettingsPage.profile.displayName.label")}</label>
<Ref innerRef={findInput(register)}>
<Input
name="displayName"
defaultValue={login.displayName}
placeholder={login.username}
/>
</Ref>
<small>{t("SettingsPage.profile.displayName.fallbackNotice")}</small>
</Form.Field>
<Form.Field error={errors?.bio}>
<label>{t("SettingsPage.profile.bio.label")}</label>
<Ref innerRef={register}>
<TextArea name="bio" rows={4} defaultValue={login.bio} />
</Ref>
</Form.Field>
<Form.Field error={errors?.image}>
<label>{t("SettingsPage.profile.avatarUrl.label")}</label>
<Ref innerRef={findInput(register)}>
<Input name="image" defaultValue={login.image} />
</Ref>
</Form.Field>
<Button type="submit" primary>
{t("general.save")}
</Button>
</Form>
</Segment>
);
});
export default UserSettingsForm;

View file

@ -0,0 +1,70 @@
import React from "react";
import { connect } from "react-redux";
import { Header, Tab } from "semantic-ui-react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { setLogin } from "reducers/login";
import { Page, Stats } from "components";
import api from "api";
import ApiKeySettings from "./ApiKeySettings";
import UserSettingsForm from "./UserSettingsForm";
import DeviceList from "./DeviceList";
const SettingsPage = connect((state) => ({ login: state.login }), { setLogin })(
function SettingsPage({ login, setLogin }) {
const { t } = useTranslation();
const { register, handleSubmit } = useForm();
const [loading, setLoading] = React.useState(false);
const [errors, setErrors] = React.useState(null);
const onGenerateNewKey = React.useCallback(async () => {
setLoading(true);
setErrors(null);
try {
const response = await api.put("/user", {
body: { updateApiKey: true },
});
setLogin(response);
} catch (err) {
setErrors(err.errors);
} finally {
setLoading(false);
}
}, [setLoading, setLogin, setErrors]);
return (
<Page title={t("SettingsPage.title")}>
<Header as="h1">{t("SettingsPage.title")}</Header>
<Tab
menu={{ secondary: true, pointing: true }}
panes={[
{
menuItem: t("SettingsPage.profile.title"),
render: () => <UserSettingsForm {...{ errors, setErrors }} />,
},
{
menuItem: t("SettingsPage.apiKey.title"),
render: () => <ApiKeySettings {...{ errors, setErrors }} />,
},
{
menuItem: t("SettingsPage.stats.title"),
render: () => <Stats user={login.id} />,
},
{
menuItem: t("SettingsPage.devices.title"),
render: () => <DeviceList />,
},
]}
/>
</Page>
);
}
);
export default SettingsPage;

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,23 +60,23 @@ 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>
))} ))}
</Grid.Row> </Grid.Row>
</Grid> </Grid>
); );
} }

View file

@ -1,6 +1,6 @@
import _ from "lodash"; import _ from "lodash";
import React from "react"; import React from "react";
import { List, Loader, Table, Icon } from "semantic-ui-react"; import { Header, List, Loader, Table, Icon } from "semantic-ui-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -150,8 +150,10 @@ export default function UploadPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const title = t("UploadPage.title");
return ( return (
<Page title="Upload"> <Page title={title}>
<Header as="h1">{title}</Header>
{files.length ? ( {files.length ? (
<Table> <Table>
<Table.Header> <Table.Header>

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

@ -49,11 +49,10 @@ LoginButton:
login: Anmelden login: Anmelden
HomePage: HomePage:
stats: Statistik
mostRecentTrack: Neueste Fahrt mostRecentTrack: Neueste Fahrt
Stats: Stats:
title: Statistik
titleUser: Meine Statistik
placeholder: "..." placeholder: "..."
totalTrackLength: Gesamtfahrstrecke totalTrackLength: Gesamtfahrstrecke
timeRecorded: Aufzeichnungszeit timeRecorded: Aufzeichnungszeit
@ -104,6 +103,7 @@ ExportPage:
label: Geografischer Bereich label: Geografischer Bereich
UploadPage: UploadPage:
title: Fahrten hochladen
uploadProgress: Lade hoch {{progress}}% uploadProgress: Lade hoch {{progress}}%
processing: Verarbeiten... processing: Verarbeiten...
@ -212,10 +212,10 @@ MapPage:
eventCount: Anzahl Überholungen eventCount: Anzahl Überholungen
SettingsPage: SettingsPage:
title: Einstellungen title: Mein Konto
profile: profile:
title: Mein Profil title: Profil
publicNotice: Alle Informationen ab hier sind öffentlich. publicNotice: Alle Informationen ab hier sind öffentlich.
username: username:
label: Kontoname label: Kontoname
@ -231,7 +231,7 @@ SettingsPage:
label: Avatar URL label: Avatar URL
apiKey: apiKey:
title: Mein API-Schlüssel title: API-Schlüssel
description: | description: |
Hier findest du deinen API-Schlüssel für die Nutzung mit dem Hier findest du deinen API-Schlüssel für die Nutzung mit dem
OpenBikeSensor. Du kannst ihn dir herauskopieren und in der Seite für die OpenBikeSensor. Du kannst ihn dir herauskopieren und in der Seite für die
@ -258,6 +258,14 @@ SettingsPage:
generate: Neuen API-Schlüssel erstellen generate: Neuen API-Schlüssel erstellen
stats:
title: Statistik
devices:
title: Geräte
identifier: Bezeichner
alias: Anzeigename
TrackPage: TrackPage:
downloadFailed: Download fehlgeschlagen downloadFailed: Download fehlgeschlagen
downloadError: Diese Fahrt wurde vermutlich nicht korrekt importiert, oder in letzter Zeit nicht aktualisiert. Bitte frage den Administrator um Hilfe mit diesem Problem. downloadError: Diese Fahrt wurde vermutlich nicht korrekt importiert, oder in letzter Zeit nicht aktualisiert. Bitte frage den Administrator um Hilfe mit diesem Problem.

View file

@ -54,11 +54,10 @@ LoginButton:
login: Login login: Login
HomePage: HomePage:
stats: Statistics
mostRecentTrack: Most recent track mostRecentTrack: Most recent track
Stats: Stats:
title: Statistics
titleUser: My Statistic
placeholder: "..." placeholder: "..."
totalTrackLength: Total track length totalTrackLength: Total track length
timeRecorded: Time recorded timeRecorded: Time recorded
@ -110,6 +109,7 @@ ExportPage:
label: Bounding Box label: Bounding Box
UploadPage: UploadPage:
title: Upload tracks
uploadProgress: Uploading {{progress}}% uploadProgress: Uploading {{progress}}%
processing: Processing... processing: Processing...
@ -217,10 +217,10 @@ MapPage:
eventCount: Event count eventCount: Event count
SettingsPage: SettingsPage:
title: Settings title: My Account
profile: profile:
title: My profile title: Profile
publicNotice: All of the information below is public. publicNotice: All of the information below is public.
username: username:
label: Username label: Username
@ -236,7 +236,7 @@ SettingsPage:
label: Avatar URL label: Avatar URL
apiKey: apiKey:
title: My API Key title: API Key
description: | description: |
Here you find your API Key, for use in the OpenBikeSensor. You can to Here you find your API Key, for use in the OpenBikeSensor. You can to
copy and paste it into your sensor's configuration interface to allow copy and paste it into your sensor's configuration interface to allow
@ -260,6 +260,13 @@ SettingsPage:
generate: Generate new API key generate: Generate new API key
stats:
title: Statistics
devices:
title: Devices
identifier: Identifier
alias: Alias
TrackPage: TrackPage:
downloadFailed: Download failed downloadFailed: Download failed

View file

@ -236,7 +236,7 @@ SettingsPage:
label: URL d'avatar label: URL d'avatar
apiKey: apiKey:
title: MA clé d'API title: Ma clé d'API
description: | description: |
Ici vous trouvez votre clé API, pour l'utilisation dans le OpenBikeSensor. Ici vous trouvez votre clé API, pour l'utilisation dans le OpenBikeSensor.
Vous pouvez la copier et coller dans l'interface de configuration de votre Vous pouvez la copier et coller dans l'interface de configuration de votre
@ -245,10 +245,10 @@ SettingsPage:
Veuillez protéger votre clé API soigneusement car elle permet un contrôle Veuillez protéger votre clé API soigneusement car elle permet un contrôle
total sur votre compte. total sur votre compte.
urlDescription: | urlDescription: |
L'URL de l'API doit être définie comme suit : L'URL de l'API doit être définie comme suit:
generateDescription: | generateDescription: |
Vous pouvez générer une nouvelle clé API ici, qui invalidera l'ancienne, Vous pouvez générer une nouvelle clé API ici, qui invalidera l'ancienne,
déconnectant de votre compte tous les appareils sur lesquels vous l'avez utilisée.. déconnectant de votre compte tous les appareils sur lesquels vous l'avez utilisée.
key: key:
label: Clé API Personnel label: Clé API Personnel
@ -260,6 +260,10 @@ SettingsPage:
generate: Générer une nouvelle clé API generate: Générer une nouvelle clé API
devices:
title: Appareils
identifier: Identifiant
alias: Alias
TrackPage: TrackPage:
downloadFailed: Le téléchargement a échoué downloadFailed: Le téléchargement a échoué

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`;
}
} }