Merge branch 'device-identifiers' into next
This commit is contained in:
commit
0d44560830
41
api/migrations/versions/f7b21148126a_add_user_device.py
Normal file
41
api/migrations/versions/f7b21148126a_add_user_device.py
Normal 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")
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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
|
|
@ -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": [
|
||||||
|
|
|
@ -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,
|
||||||
|
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 { Helmet } from "react-helmet";
|
||||||
import {useTranslation} from 'react-i18next'
|
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,9 +32,10 @@ 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
|
||||||
|
@ -37,38 +45,48 @@ function MenuItemForLink({navigate, ...props}) {
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
{...props}
|
{...props}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
navigate()
|
navigate();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
function DropdownItemForLink({ navigate, ...props }) {
|
function DropdownItemForLink({ navigate, ...props }) {
|
||||||
return (
|
return (
|
||||||
<Dropdown.Item
|
<Dropdown.Item
|
||||||
{...props}
|
{...props}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
navigate()
|
navigate();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Banner({text, style = 'warning'}: {text: string; style: 'warning' | 'info'}) {
|
function Banner({
|
||||||
return <div className={classnames(styles.banner, styles[style])}>{text}</div>
|
text,
|
||||||
|
style = "warning",
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
style: "warning" | "info";
|
||||||
|
}) {
|
||||||
|
return <div className={classnames(styles.banner, styles[style])}>{text}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const App = connect((state) => ({login: state.login}))(function App({login}) {
|
const App = connect((state) => ({ login: state.login }))(function App({
|
||||||
const {t} = useTranslation()
|
login,
|
||||||
const config = useConfig()
|
}) {
|
||||||
const apiVersion = useObservable(() => from(api.get('/info')).pipe(pluck('version')))
|
const { t } = useTranslation();
|
||||||
|
const config = useConfig();
|
||||||
|
const apiVersion = useObservable(() =>
|
||||||
|
from(api.get("/info")).pipe(pluck("version"))
|
||||||
|
);
|
||||||
|
|
||||||
const hasMap = Boolean(config?.obsMapSource)
|
const hasMap = Boolean(config?.obsMapSource);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
api.loadUser()
|
api.loadUser();
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return config ? (
|
return config ? (
|
||||||
<Router basename={config.basename}>
|
<Router basename={config.basename}>
|
||||||
|
@ -79,36 +97,59 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
|
||||||
{config?.banner && <Banner {...config.banner} />}
|
{config?.banner && <Banner {...config.banner} />}
|
||||||
<Menu className={styles.menu}>
|
<Menu className={styles.menu}>
|
||||||
<Container>
|
<Container>
|
||||||
<Link to="/" component={MenuItemForLink} header className={styles.pageTitle}>
|
<Link
|
||||||
|
to="/"
|
||||||
|
component={MenuItemForLink}
|
||||||
|
header
|
||||||
|
className={styles.pageTitle}
|
||||||
|
>
|
||||||
OpenBikeSensor
|
OpenBikeSensor
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{hasMap && (
|
{hasMap && (
|
||||||
<Link component={MenuItemForLink} to="/map" as="a">
|
<Link component={MenuItemForLink} to="/map" as="a">
|
||||||
{t('App.menu.map')}
|
{t("App.menu.map")}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Link component={MenuItemForLink} to="/tracks" as="a">
|
<Link component={MenuItemForLink} to="/tracks" as="a">
|
||||||
{t('App.menu.tracks')}
|
{t("App.menu.tracks")}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link component={MenuItemForLink} to="/export" as="a">
|
<Link component={MenuItemForLink} to="/export" as="a">
|
||||||
{t('App.menu.export')}
|
{t("App.menu.export")}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Menu.Menu position="right">
|
<Menu.Menu position="right">
|
||||||
{login ? (
|
{login ? (
|
||||||
<>
|
<>
|
||||||
<Link component={MenuItemForLink} to="/my/tracks" as="a">
|
<Link component={MenuItemForLink} to="/my/tracks" as="a">
|
||||||
{t('App.menu.myTracks')}
|
{t("App.menu.myTracks")}
|
||||||
</Link>
|
</Link>
|
||||||
<Dropdown item trigger={<Avatar user={login} className={styles.avatar} />}>
|
<Dropdown
|
||||||
|
item
|
||||||
|
trigger={<Avatar user={login} className={styles.avatar} />}
|
||||||
|
>
|
||||||
<Dropdown.Menu>
|
<Dropdown.Menu>
|
||||||
<Link to="/upload" component={DropdownItemForLink} icon="cloud upload" text={t('App.menu.uploadTracks')} />
|
<Link
|
||||||
<Link to="/settings" component={DropdownItemForLink} icon="cog" text={t('App.menu.settings')}/>
|
to="/upload"
|
||||||
|
component={DropdownItemForLink}
|
||||||
|
icon="cloud upload"
|
||||||
|
text={t("App.menu.uploadTracks")}
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
component={DropdownItemForLink}
|
||||||
|
icon="cog"
|
||||||
|
text={t("App.menu.settings")}
|
||||||
|
/>
|
||||||
<Dropdown.Divider />
|
<Dropdown.Divider />
|
||||||
<Link to="/logout" component={DropdownItemForLink} icon="sign-out" text={t('App.menu.logout')} />
|
<Link
|
||||||
|
to="/logout"
|
||||||
|
component={DropdownItemForLink}
|
||||||
|
icon="sign-out"
|
||||||
|
text={t("App.menu.logout")}
|
||||||
|
/>
|
||||||
</Dropdown.Menu>
|
</Dropdown.Menu>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</>
|
</>
|
||||||
|
@ -125,14 +166,16 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
|
||||||
<Route path="/" exact>
|
<Route path="/" exact>
|
||||||
<HomePage />
|
<HomePage />
|
||||||
</Route>
|
</Route>
|
||||||
{hasMap && <Route path="/map" exact>
|
{hasMap && (
|
||||||
|
<Route path="/map" exact>
|
||||||
<MapPage />
|
<MapPage />
|
||||||
</Route>}
|
</Route>
|
||||||
|
)}
|
||||||
<Route path="/tracks" exact>
|
<Route path="/tracks" exact>
|
||||||
<TracksPage />
|
<TracksPage />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/my/tracks" exact>
|
<Route path="/my/tracks" exact>
|
||||||
<TracksPage privateTracks />
|
<MyTracksPage />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path={`/tracks/:slug`} exact>
|
<Route path={`/tracks/:slug`} exact>
|
||||||
<TrackPage />
|
<TrackPage />
|
||||||
|
@ -169,12 +212,14 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
|
||||||
<Grid columns={4} stackable>
|
<Grid columns={4} stackable>
|
||||||
<Grid.Row>
|
<Grid.Row>
|
||||||
<Grid.Column>
|
<Grid.Column>
|
||||||
<Header as="h5">
|
<Header as="h5">{t("App.footer.aboutTheProject")}</Header>
|
||||||
{t('App.footer.aboutTheProject')}
|
|
||||||
</Header>
|
|
||||||
<List>
|
<List>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<a href="https://openbikesensor.org/" target="_blank" rel="noreferrer">
|
<a
|
||||||
|
href="https://openbikesensor.org/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
openbikesensor.org
|
openbikesensor.org
|
||||||
</a>
|
</a>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
|
@ -182,41 +227,57 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
|
||||||
</Grid.Column>
|
</Grid.Column>
|
||||||
|
|
||||||
<Grid.Column>
|
<Grid.Column>
|
||||||
<Header as="h5">
|
<Header as="h5">{t("App.footer.getInvolved")}</Header>
|
||||||
{t('App.footer.getInvolved')}
|
|
||||||
</Header>
|
|
||||||
<List>
|
<List>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<a href="https://forum.openbikesensor.org/" target="_blank" rel="noreferrer">
|
<a
|
||||||
{t('App.footer.getHelpInForum')}
|
href="https://forum.openbikesensor.org/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{t("App.footer.getHelpInForum")}
|
||||||
</a>
|
</a>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<a href="https://github.com/openbikesensor/portal/issues/new" target="_blank" rel="noreferrer">
|
<a
|
||||||
{t('App.footer.reportAnIssue')}
|
href="https://github.com/openbikesensor/portal/issues/new"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{t("App.footer.reportAnIssue")}
|
||||||
</a>
|
</a>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<a href="https://github.com/openbikesensor/portal" target="_blank" rel="noreferrer">
|
<a
|
||||||
{t('App.footer.development')}
|
href="https://github.com/openbikesensor/portal"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{t("App.footer.development")}
|
||||||
</a>
|
</a>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
</List>
|
</List>
|
||||||
</Grid.Column>
|
</Grid.Column>
|
||||||
|
|
||||||
<Grid.Column>
|
<Grid.Column>
|
||||||
<Header as="h5">
|
<Header as="h5">{t("App.footer.thisInstallation")}</Header>
|
||||||
{t('App.footer.thisInstallation')}
|
|
||||||
</Header>
|
|
||||||
<List>
|
<List>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<a href={config?.privacyPolicyUrl} target="_blank" rel="noreferrer">
|
<a
|
||||||
{t('App.footer.privacyPolicy')}
|
href={config?.privacyPolicyUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{t("App.footer.privacyPolicy")}
|
||||||
</a>
|
</a>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<a href={config?.imprintUrl} target="_blank" rel="noreferrer">
|
<a
|
||||||
{t('App.footer.imprint')}
|
href={config?.imprintUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{t("App.footer.imprint")}
|
||||||
</a>
|
</a>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
{ config?.termsUrl &&
|
{ config?.termsUrl &&
|
||||||
|
@ -229,21 +290,29 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<a
|
<a
|
||||||
href={`https://github.com/openbikesensor/portal${
|
href={`https://github.com/openbikesensor/portal${
|
||||||
apiVersion ? `/releases/tag/${apiVersion}` : ''
|
apiVersion ? `/releases/tag/${apiVersion}` : ""
|
||||||
}`}
|
}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
{apiVersion ? t('App.footer.version', {apiVersion}) : t('App.footer.versionLoading')}
|
{apiVersion
|
||||||
|
? t("App.footer.version", { apiVersion })
|
||||||
|
: t("App.footer.versionLoading")}
|
||||||
</a>
|
</a>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
</List>
|
</List>
|
||||||
</Grid.Column>
|
</Grid.Column>
|
||||||
|
|
||||||
<Grid.Column>
|
<Grid.Column>
|
||||||
<Header as="h5">{t('App.footer.changeLanguage')}</Header>
|
<Header as="h5">{t("App.footer.changeLanguage")}</Header>
|
||||||
<List>
|
<List>
|
||||||
{AVAILABLE_LOCALES.map(locale => <List.Item key={locale}><a onClick={() => setLocale(locale)}>{t(`locales.${locale}`)}</a></List.Item>)}
|
{AVAILABLE_LOCALES.map((locale) => (
|
||||||
|
<List.Item key={locale}>
|
||||||
|
<a onClick={() => setLocale(locale)}>
|
||||||
|
{t(`locales.${locale}`)}
|
||||||
|
</a>
|
||||||
|
</List.Item>
|
||||||
|
))}
|
||||||
</List>
|
</List>
|
||||||
</Grid.Column>
|
</Grid.Column>
|
||||||
</Grid.Row>
|
</Grid.Row>
|
||||||
|
@ -251,7 +320,7 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
</Router>
|
</Router>
|
||||||
) : null
|
) : null;
|
||||||
})
|
});
|
||||||
|
|
||||||
export default App
|
export default App;
|
||||||
|
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
413
frontend/src/pages/MyTracksPage.tsx
Normal file
413
frontend/src/pages/MyTracksPage.tsx
Normal 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;
|
|
@ -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;
|
|
125
frontend/src/pages/SettingsPage/ApiKeySettings.tsx
Normal file
125
frontend/src/pages/SettingsPage/ApiKeySettings.tsx
Normal 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;
|
126
frontend/src/pages/SettingsPage/DeviceList.tsx
Normal file
126
frontend/src/pages/SettingsPage/DeviceList.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
89
frontend/src/pages/SettingsPage/UserSettingsForm.tsx
Normal file
89
frontend/src/pages/SettingsPage/UserSettingsForm.tsx
Normal 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;
|
70
frontend/src/pages/SettingsPage/index.tsx
Normal file
70
frontend/src/pages/SettingsPage/index.tsx
Normal 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;
|
|
@ -5,10 +5,7 @@ import { Duration } from "luxon";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { FormattedDate, Visibility } from "components";
|
import { FormattedDate, Visibility } from "components";
|
||||||
|
import { formatDistance, formatDuration } from "utils";
|
||||||
function formatDuration(seconds) {
|
|
||||||
return Duration.fromMillis((seconds ?? 0) * 1000).toFormat("h'h' mm'm'");
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TrackDetails({ track, isAuthor }) {
|
export default function TrackDetails({ track, isAuthor }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
@ -47,7 +44,7 @@ export default function TrackDetails({ track, isAuthor }) {
|
||||||
|
|
||||||
track?.length != null && [
|
track?.length != null && [
|
||||||
t("TrackPage.details.length"),
|
t("TrackPage.details.length"),
|
||||||
`${(track?.length / 1000).toFixed(2)} km`,
|
formatDistance(track?.length),
|
||||||
],
|
],
|
||||||
|
|
||||||
track?.processingStatus != null &&
|
track?.processingStatus != null &&
|
||||||
|
@ -63,19 +60,19 @@ export default function TrackDetails({ track, isAuthor }) {
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
const COLUMNS = 4;
|
const COLUMNS = 4;
|
||||||
const chunkSize = Math.ceil(items.length / COLUMNS)
|
const chunkSize = Math.ceil(items.length / COLUMNS);
|
||||||
return (
|
return (
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.Row columns={COLUMNS}>
|
<Grid.Row columns={COLUMNS}>
|
||||||
{_.chunk(items, chunkSize).map((chunkItems, idx) => (
|
{_.chunk(items, chunkSize).map((chunkItems, idx) => (
|
||||||
<Grid.Column key={idx}>
|
<Grid.Column key={idx}>
|
||||||
|
|
||||||
<List>
|
<List>
|
||||||
{chunkItems.map(([title, value]) => (
|
{chunkItems.map(([title, value]) => (
|
||||||
<List.Item key={title}>
|
<List.Item key={title}>
|
||||||
<List.Header>{title}</List.Header>
|
<List.Header>{title}</List.Header>
|
||||||
<List.Description>{value}</List.Description>
|
<List.Description>{value}</List.Description>
|
||||||
</List.Item>))}
|
</List.Item>
|
||||||
|
))}
|
||||||
</List>
|
</List>
|
||||||
</Grid.Column>
|
</Grid.Column>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
@ -248,7 +248,7 @@ SettingsPage:
|
||||||
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é
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue