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
)
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
recorded_at = 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:
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)
@ -409,6 +416,28 @@ class User(Base):
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):
__tablename__ = "comment"
id = Column(Integer, autoincrement=True, primary_key=True)
@ -468,6 +497,14 @@ Track.overtaking_events = relationship(
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
# Two hour intervals

View file

@ -8,7 +8,7 @@ import pytz
from os.path import join
from datetime import datetime
from sqlalchemy import delete, select
from sqlalchemy import delete, select, and_
from sqlalchemy.orm import joinedload
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.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
log = logging.getLogger(__name__)
@ -144,10 +144,11 @@ async def process_track(session, track, data_source):
os.makedirs(output_dir, exist_ok=True)
log.info("Annotating and filtering CSV file")
imported_data, statistics = ImportMeasurementsCsv().read(
imported_data, statistics, track_metadata = ImportMeasurementsCsv().read(
original_file_path,
user_id="dummy", # TODO: user username or id or nothing?
dataset_id=Track.slug, # TODO: use track id or slug or nothing?
return_metadata=True,
)
annotator = AnnotateMeasurements(
@ -217,6 +218,36 @@ async def process_track(session, track, data_source):
await clear_track_data(session, track)
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...")
await import_overtaking_events(session, track, overtaking_events)

View file

@ -3,7 +3,7 @@ import re
from json import load as jsonload
from os.path import join, exists, isfile
from sqlalchemy import select, func
from sqlalchemy import select, func, and_
from sqlalchemy.orm import joinedload
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
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,89 @@ 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/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")

View file

@ -1,9 +1,11 @@
import logging
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.db import UserDevice
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)
@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")
@require_auth
async def put_user(req):

View file

@ -11,3 +11,4 @@ sqlalchemy[asyncio]~=1.4.39 <2.0
asyncpg~=0.24.0
pyshp~=2.3.1
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",
"asyncpg~=0.24.0",
"alembic~=1.7.7",
"stream-zip~=0.0.50",
],
entry_points={
"console_scripts": [

View file

@ -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
// <Link /> and <Menu.Item /> combination.
function MenuItemForLink({navigate, ...props}) {
function MenuItemForLink({ navigate, ...props }) {
return (
<Menu.Item
{...props}
onClick={(e) => {
e.preventDefault()
navigate()
e.preventDefault();
navigate();
}}
/>
)
);
}
function DropdownItemForLink({navigate, ...props}) {
function DropdownItemForLink({ navigate, ...props }) {
return (
<Dropdown.Item
{...props}
onClick={(e) => {
e.preventDefault()
navigate()
e.preventDefault();
navigate();
}}
/>
)
);
}
function Banner({text, style = 'warning'}: {text: string; style: 'warning' | 'info'}) {
return <div className={classnames(styles.banner, styles[style])}>{text}</div>
function Banner({
text,
style = "warning",
}: {
text: string;
style: "warning" | "info";
}) {
return <div className={classnames(styles.banner, styles[style])}>{text}</div>;
}
const App = connect((state) => ({login: state.login}))(function App({login}) {
const {t} = useTranslation()
const config = useConfig()
const apiVersion = useObservable(() => from(api.get('/info')).pipe(pluck('version')))
const App = connect((state) => ({ login: state.login }))(function App({
login,
}) {
const { t } = useTranslation();
const config = useConfig();
const apiVersion = useObservable(() =>
from(api.get("/info")).pipe(pluck("version"))
);
const hasMap = Boolean(config?.obsMapSource)
const hasMap = Boolean(config?.obsMapSource);
React.useEffect(() => {
api.loadUser()
}, [])
api.loadUser();
}, []);
return config ? (
<Router basename={config.basename}>
@ -79,36 +97,59 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
{config?.banner && <Banner {...config.banner} />}
<Menu className={styles.menu}>
<Container>
<Link to="/" component={MenuItemForLink} header className={styles.pageTitle}>
<Link
to="/"
component={MenuItemForLink}
header
className={styles.pageTitle}
>
OpenBikeSensor
</Link>
{hasMap && (
<Link component={MenuItemForLink} to="/map" as="a">
{t('App.menu.map')}
</Link>
<Link component={MenuItemForLink} to="/map" as="a">
{t("App.menu.map")}
</Link>
)}
<Link component={MenuItemForLink} to="/tracks" as="a">
{t('App.menu.tracks')}
{t("App.menu.tracks")}
</Link>
<Link component={MenuItemForLink} to="/export" as="a">
{t('App.menu.export')}
{t("App.menu.export")}
</Link>
<Menu.Menu position="right">
{login ? (
<>
<Link component={MenuItemForLink} to="/my/tracks" as="a">
{t('App.menu.myTracks')}
{t("App.menu.myTracks")}
</Link>
<Dropdown item trigger={<Avatar user={login} className={styles.avatar} />}>
<Dropdown
item
trigger={<Avatar user={login} className={styles.avatar} />}
>
<Dropdown.Menu>
<Link to="/upload" component={DropdownItemForLink} icon="cloud upload" text={t('App.menu.uploadTracks')} />
<Link to="/settings" component={DropdownItemForLink} icon="cog" text={t('App.menu.settings')}/>
<Link
to="/upload"
component={DropdownItemForLink}
icon="cloud upload"
text={t("App.menu.uploadTracks")}
/>
<Link
to="/settings"
component={DropdownItemForLink}
icon="cog"
text={t("App.menu.settings")}
/>
<Dropdown.Divider />
<Link to="/logout" component={DropdownItemForLink} icon="sign-out" text={t('App.menu.logout')} />
<Link
to="/logout"
component={DropdownItemForLink}
icon="sign-out"
text={t("App.menu.logout")}
/>
</Dropdown.Menu>
</Dropdown>
</>
@ -125,14 +166,16 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
<Route path="/" exact>
<HomePage />
</Route>
{hasMap && <Route path="/map" exact>
<MapPage />
</Route>}
{hasMap && (
<Route path="/map" exact>
<MapPage />
</Route>
)}
<Route path="/tracks" exact>
<TracksPage />
</Route>
<Route path="/my/tracks" exact>
<TracksPage privateTracks />
<MyTracksPage />
</Route>
<Route path={`/tracks/:slug`} exact>
<TrackPage />
@ -169,12 +212,14 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
<Grid columns={4} stackable>
<Grid.Row>
<Grid.Column>
<Header as="h5">
{t('App.footer.aboutTheProject')}
</Header>
<Header as="h5">{t("App.footer.aboutTheProject")}</Header>
<List>
<List.Item>
<a href="https://openbikesensor.org/" target="_blank" rel="noreferrer">
<a
href="https://openbikesensor.org/"
target="_blank"
rel="noreferrer"
>
openbikesensor.org
</a>
</List.Item>
@ -182,41 +227,57 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
</Grid.Column>
<Grid.Column>
<Header as="h5">
{t('App.footer.getInvolved')}
</Header>
<Header as="h5">{t("App.footer.getInvolved")}</Header>
<List>
<List.Item>
<a href="https://forum.openbikesensor.org/" target="_blank" rel="noreferrer">
{t('App.footer.getHelpInForum')}
<a
href="https://forum.openbikesensor.org/"
target="_blank"
rel="noreferrer"
>
{t("App.footer.getHelpInForum")}
</a>
</List.Item>
<List.Item>
<a href="https://github.com/openbikesensor/portal/issues/new" target="_blank" rel="noreferrer">
{t('App.footer.reportAnIssue')}
<a
href="https://github.com/openbikesensor/portal/issues/new"
target="_blank"
rel="noreferrer"
>
{t("App.footer.reportAnIssue")}
</a>
</List.Item>
<List.Item>
<a href="https://github.com/openbikesensor/portal" target="_blank" rel="noreferrer">
{t('App.footer.development')}
<a
href="https://github.com/openbikesensor/portal"
target="_blank"
rel="noreferrer"
>
{t("App.footer.development")}
</a>
</List.Item>
</List>
</Grid.Column>
<Grid.Column>
<Header as="h5">
{t('App.footer.thisInstallation')}
</Header>
<Header as="h5">{t("App.footer.thisInstallation")}</Header>
<List>
<List.Item>
<a href={config?.privacyPolicyUrl} target="_blank" rel="noreferrer">
{t('App.footer.privacyPolicy')}
<a
href={config?.privacyPolicyUrl}
target="_blank"
rel="noreferrer"
>
{t("App.footer.privacyPolicy")}
</a>
</List.Item>
<List.Item>
<a href={config?.imprintUrl} target="_blank" rel="noreferrer">
{t('App.footer.imprint')}
<a
href={config?.imprintUrl}
target="_blank"
rel="noreferrer"
>
{t("App.footer.imprint")}
</a>
</List.Item>
{ config?.termsUrl &&
@ -229,21 +290,29 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
<List.Item>
<a
href={`https://github.com/openbikesensor/portal${
apiVersion ? `/releases/tag/${apiVersion}` : ''
apiVersion ? `/releases/tag/${apiVersion}` : ""
}`}
target="_blank"
rel="noreferrer"
>
{apiVersion ? t('App.footer.version', {apiVersion}) : t('App.footer.versionLoading')}
{apiVersion
? t("App.footer.version", { apiVersion })
: t("App.footer.versionLoading")}
</a>
</List.Item>
</List>
</Grid.Column>
<Grid.Column>
<Header as="h5">{t('App.footer.changeLanguage')}</Header>
<Header as="h5">{t("App.footer.changeLanguage")}</Header>
<List>
{AVAILABLE_LOCALES.map(locale => <List.Item key={locale}><a onClick={() => setLocale(locale)}>{t(`locales.${locale}`)}</a></List.Item>)}
{AVAILABLE_LOCALES.map((locale) => (
<List.Item key={locale}>
<a onClick={() => setLocale(locale)}>
{t(`locales.${locale}`)}
</a>
</List.Item>
))}
</List>
</Grid.Column>
</Grid.Row>
@ -251,7 +320,7 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
</Container>
</div>
</Router>
) : null
})
) : null;
});
export default App
export default App;

View file

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

View file

@ -1,6 +1,6 @@
import _ from "lodash";
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 { useTranslation } from "react-i18next";
@ -150,8 +150,10 @@ export default function UploadPage() {
const { t } = useTranslation();
const title = t("UploadPage.title");
return (
<Page title="Upload">
<Page title={title}>
<Header as="h1">{title}</Header>
{files.length ? (
<Table>
<Table.Header>

View file

@ -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";

View file

@ -49,11 +49,10 @@ LoginButton:
login: Anmelden
HomePage:
stats: Statistik
mostRecentTrack: Neueste Fahrt
Stats:
title: Statistik
titleUser: Meine Statistik
placeholder: "..."
totalTrackLength: Gesamtfahrstrecke
timeRecorded: Aufzeichnungszeit
@ -104,6 +103,7 @@ ExportPage:
label: Geografischer Bereich
UploadPage:
title: Fahrten hochladen
uploadProgress: Lade hoch {{progress}}%
processing: Verarbeiten...
@ -212,10 +212,10 @@ MapPage:
eventCount: Anzahl Überholungen
SettingsPage:
title: Einstellungen
title: Mein Konto
profile:
title: Mein Profil
title: Profil
publicNotice: Alle Informationen ab hier sind öffentlich.
username:
label: Kontoname
@ -231,7 +231,7 @@ SettingsPage:
label: Avatar URL
apiKey:
title: Mein API-Schlüssel
title: API-Schlüssel
description: |
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
@ -258,6 +258,14 @@ SettingsPage:
generate: Neuen API-Schlüssel erstellen
stats:
title: Statistik
devices:
title: Geräte
identifier: Bezeichner
alias: Anzeigename
TrackPage:
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.

View file

@ -54,11 +54,10 @@ LoginButton:
login: Login
HomePage:
stats: Statistics
mostRecentTrack: Most recent track
Stats:
title: Statistics
titleUser: My Statistic
placeholder: "..."
totalTrackLength: Total track length
timeRecorded: Time recorded
@ -110,6 +109,7 @@ ExportPage:
label: Bounding Box
UploadPage:
title: Upload tracks
uploadProgress: Uploading {{progress}}%
processing: Processing...
@ -217,10 +217,10 @@ MapPage:
eventCount: Event count
SettingsPage:
title: Settings
title: My Account
profile:
title: My profile
title: Profile
publicNotice: All of the information below is public.
username:
label: Username
@ -236,7 +236,7 @@ SettingsPage:
label: Avatar URL
apiKey:
title: My API Key
title: API Key
description: |
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
@ -260,6 +260,13 @@ SettingsPage:
generate: Generate new API key
stats:
title: Statistics
devices:
title: Devices
identifier: Identifier
alias: Alias
TrackPage:
downloadFailed: Download failed

View file

@ -236,7 +236,7 @@ SettingsPage:
label: URL d'avatar
apiKey:
title: MA clé d'API
title: Ma clé d'API
description: |
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
@ -245,10 +245,10 @@ SettingsPage:
Veuillez protéger votre clé API soigneusement car elle permet un contrôle
total sur votre compte.
urlDescription: |
L'URL de l'API doit être définie comme suit :
L'URL de l'API doit être définie comme suit:
generateDescription: |
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:
label: Clé API Personnel
@ -260,6 +260,10 @@ SettingsPage:
generate: Générer une nouvelle clé API
devices:
title: Appareils
identifier: Identifiant
alias: Alias
TrackPage:
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 = {
id: number | string
displayName: string
image?: string | null
bio?: string | null
export interface UserProfile {
username: string;
displayName: string;
image?: string | null;
bio?: string | null;
}
export type TrackData = {
track: Feature<LineString>
measurements: FeatureCollection
overtakingEvents: FeatureCollection
export interface TrackData {
track: Feature<LineString>;
measurements: FeatureCollection;
overtakingEvents: FeatureCollection;
}
export type Track = {
slug: string
author: UserProfile
title: string
description?: string
createdAt: string
public?: boolean
recordedAt?: Date
recordedUntil?: Date
duration?: number
length?: number
segments?: number
numEvents?: number
numMeasurements?: number
numValid?: number
export type ProcessingStatus =
| "error"
| "complete"
| "created"
| "queued"
| "processing";
export interface Track {
slug: string;
author: UserProfile;
title: string;
description?: string;
createdAt: string;
processingStatus?: ProcessingStatus;
public?: boolean;
recordedAt?: Date;
recordedUntil?: Date;
duration?: number;
length?: number;
segments?: number;
numEvents?: number;
numMeasurements?: number;
numValid?: number;
userDeviceId?: number;
}
export type TrackPoint = {
type: 'Feature'
geometry: Point
export interface TrackPoint {
type: "Feature";
geometry: Point;
properties: {
distanceOvertaker: null | number
distanceStationary: null | number
}
distanceOvertaker: null | number;
distanceStationary: null | number;
};
}
export type TrackComment = {
id: string
body: string
createdAt: string
author: UserProfile
export interface TrackComment {
id: string;
body: string;
createdAt: string;
author: UserProfile;
}
export type Location {
export interface Location {
longitude: number;
latitude: number;
}
export interface UserDevice {
id: number;
identifier: string;
displayName?: string;
}

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