Format all JS/TS files

This commit is contained in:
Paul Bienkowski 2023-08-26 10:26:13 +02:00
parent ba7de7582d
commit 278bcfc603
35 changed files with 1717 additions and 2266 deletions

View file

@ -1,24 +1,17 @@
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 { import {List, Grid, Container, Menu, Header, Dropdown} from 'semantic-ui-react'
List, import {BrowserRouter as Router, Switch, Route, Link} from 'react-router-dom'
Grid, import {useObservable} from 'rxjs-hooks'
Container, import {from} from 'rxjs'
Menu, import {pluck} from 'rxjs/operators'
Header, import {Helmet} from 'react-helmet'
Dropdown, import {useTranslation} from 'react-i18next'
} from "semantic-ui-react";
import { BrowserRouter as Router, Switch, Route, Link } from "react-router-dom";
import { useObservable } from "rxjs-hooks";
import { from } from "rxjs";
import { pluck } from "rxjs/operators";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { useConfig } from "config"; import {useConfig} from 'config'
import styles from "./App.module.less"; import styles from './App.module.less'
import { AVAILABLE_LOCALES, setLocale } from "i18n"; import {AVAILABLE_LOCALES, setLocale} from 'i18n'
import { import {
AcknowledgementsPage, AcknowledgementsPage,
@ -34,60 +27,50 @@ import {
TracksPage, TracksPage,
UploadPage, UploadPage,
MyTracksPage, MyTracksPage,
} from "pages"; } from 'pages'
import { Avatar, LoginButton } from "components"; import {Avatar, LoginButton} from 'components'
import api from "api"; import api from 'api'
// This component removes the "navigate" prop before rendering a Menu.Item, // This component removes the "navigate" prop before rendering a Menu.Item,
// which is a workaround for an annoying warning that is somehow caused by the // which is a workaround for an annoying warning that is somehow caused by the
// <Link /> and <Menu.Item /> combination. // <Link /> and <Menu.Item /> combination.
function MenuItemForLink({ navigate, ...props }) { function MenuItemForLink({navigate, ...props}) {
return ( return (
<Menu.Item <Menu.Item
{...props} {...props}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault()
navigate(); navigate()
}} }}
/> />
); )
} }
function DropdownItemForLink({ navigate, ...props }) { function DropdownItemForLink({navigate, ...props}) {
return ( return (
<Dropdown.Item <Dropdown.Item
{...props} {...props}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault()
navigate(); navigate()
}} }}
/> />
); )
} }
function Banner({ function Banner({text, style = 'warning'}: {text: string; style: 'warning' | 'info'}) {
text, return <div className={classnames(styles.banner, styles[style])}>{text}</div>
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({ const App = connect((state) => ({login: state.login}))(function App({login}) {
login, const {t} = useTranslation()
}) { const config = useConfig()
const { t } = useTranslation(); const apiVersion = useObservable(() => from(api.get('/info')).pipe(pluck('version')))
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}>
@ -98,59 +81,41 @@ const App = connect((state) => ({ login: state.login }))(function App({
{config?.banner && <Banner {...config.banner} />} {config?.banner && <Banner {...config.banner} />}
<Menu className={styles.menu} stackable> <Menu className={styles.menu} stackable>
<Container> <Container>
<Link <Link to="/" component={MenuItemForLink} header className={styles.pageTitle}>
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 <Dropdown item trigger={<Avatar user={login} className={styles.avatar} />}>
item
trigger={<Avatar user={login} className={styles.avatar} />}
>
<Dropdown.Menu> <Dropdown.Menu>
<Link <Link
to="/upload" to="/upload"
component={DropdownItemForLink} component={DropdownItemForLink}
icon="cloud upload" icon="cloud upload"
text={t("App.menu.uploadTracks")} text={t('App.menu.uploadTracks')}
/>
<Link
to="/settings"
component={DropdownItemForLink}
icon="cog"
text={t("App.menu.settings")}
/> />
<Link to="/settings" component={DropdownItemForLink} icon="cog" text={t('App.menu.settings')} />
<Dropdown.Divider /> <Dropdown.Divider />
<Link <Link to="/logout" component={DropdownItemForLink} icon="sign-out" text={t('App.menu.logout')} />
to="/logout"
component={DropdownItemForLink}
icon="sign-out"
text={t("App.menu.logout")}
/>
</Dropdown.Menu> </Dropdown.Menu>
</Dropdown> </Dropdown>
</> </>
@ -216,14 +181,10 @@ const App = connect((state) => ({ login: state.login }))(function App({
<Grid columns={4} stackable> <Grid columns={4} stackable>
<Grid.Row> <Grid.Row>
<Grid.Column> <Grid.Column>
<Header as="h5">{t("App.footer.aboutTheProject")}</Header> <Header as="h5">{t('App.footer.aboutTheProject')}</Header>
<List> <List>
<List.Item> <List.Item>
<a <a href="https://openbikesensor.org/" target="_blank" rel="noreferrer">
href="https://openbikesensor.org/"
target="_blank"
rel="noreferrer"
>
openbikesensor.org openbikesensor.org
</a> </a>
</List.Item> </List.Item>
@ -231,94 +192,66 @@ const App = connect((state) => ({ login: state.login }))(function App({
</Grid.Column> </Grid.Column>
<Grid.Column> <Grid.Column>
<Header as="h5">{t("App.footer.getInvolved")}</Header> <Header as="h5">{t('App.footer.getInvolved')}</Header>
<List> <List>
<List.Item> <List.Item>
<a <a href="https://forum.openbikesensor.org/" target="_blank" rel="noreferrer">
href="https://forum.openbikesensor.org/" {t('App.footer.getHelpInForum')}
target="_blank"
rel="noreferrer"
>
{t("App.footer.getHelpInForum")}
</a> </a>
</List.Item> </List.Item>
<List.Item> <List.Item>
<a <a href="https://github.com/openbikesensor/portal/issues/new" target="_blank" rel="noreferrer">
href="https://github.com/openbikesensor/portal/issues/new" {t('App.footer.reportAnIssue')}
target="_blank"
rel="noreferrer"
>
{t("App.footer.reportAnIssue")}
</a> </a>
</List.Item> </List.Item>
<List.Item> <List.Item>
<a <a href="https://github.com/openbikesensor/portal" target="_blank" rel="noreferrer">
href="https://github.com/openbikesensor/portal" {t('App.footer.development')}
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">{t("App.footer.thisInstallation")}</Header> <Header as="h5">{t('App.footer.thisInstallation')}</Header>
<List> <List>
<List.Item> <List.Item>
<a <a href={config?.privacyPolicyUrl} target="_blank" rel="noreferrer">
href={config?.privacyPolicyUrl} {t('App.footer.privacyPolicy')}
target="_blank"
rel="noreferrer"
>
{t("App.footer.privacyPolicy")}
</a> </a>
</List.Item> </List.Item>
<List.Item> <List.Item>
<a <a href={config?.imprintUrl} target="_blank" rel="noreferrer">
href={config?.imprintUrl} {t('App.footer.imprint')}
target="_blank"
rel="noreferrer"
>
{t("App.footer.imprint")}
</a> </a>
</List.Item> </List.Item>
{config?.termsUrl && ( {config?.termsUrl && (
<List.Item> <List.Item>
<a <a href={config?.termsUrl} target="_blank" rel="noreferrer">
href={config?.termsUrl} {t('App.footer.terms')}
target="_blank"
rel="noreferrer"
>
{t("App.footer.terms")}
</a> </a>
</List.Item> </List.Item>
)} )}
<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 {apiVersion ? t('App.footer.version', {apiVersion}) : t('App.footer.versionLoading')}
? 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) => ( {AVAILABLE_LOCALES.map((locale) => (
<List.Item key={locale}> <List.Item key={locale}>
<a onClick={() => setLocale(locale)}> <a onClick={() => setLocale(locale)}>{t(`locales.${locale}`)}</a>
{t(`locales.${locale}`)}
</a>
</List.Item> </List.Item>
))} ))}
</List> </List>
@ -328,7 +261,7 @@ const App = connect((state) => ({ login: state.login }))(function App({
</Container> </Container>
</div> </div>
</Router> </Router>
) : null; ) : null
}); })
export default App; export default App

View file

@ -1,42 +1,39 @@
import React from "react"; import React from 'react'
import { Comment } from "semantic-ui-react"; import {Comment} from 'semantic-ui-react'
import classnames from "classnames"; import classnames from 'classnames'
import "./styles.less"; import './styles.less'
function hashCode(s) { function hashCode(s) {
let hash = 0; let hash = 0
for (let i = 0; i < s.length; i++) { for (let i = 0; i < s.length; i++) {
hash = (hash << 5) - hash + s.charCodeAt(i); hash = (hash << 5) - hash + s.charCodeAt(i)
hash |= 0; hash |= 0
} }
return hash; return hash
} }
function getColor(s) { function getColor(s) {
const h = Math.floor(hashCode(s)) % 360; const h = Math.floor(hashCode(s)) % 360
return `hsl(${h}, 50%, 50%)`; return `hsl(${h}, 50%, 50%)`
} }
export default function Avatar({ user, className }) { export default function Avatar({user, className}) {
const { image, displayName } = user || {}; const {image, displayName} = user || {}
if (image) { if (image) {
return <Comment.Avatar src={image} className={className} />; return <Comment.Avatar src={image} className={className} />
} }
if (!displayName) { if (!displayName) {
return <div className={classnames(className, "avatar", "empty-avatar")} />; return <div className={classnames(className, 'avatar', 'empty-avatar')} />
} }
const color = getColor(displayName); const color = getColor(displayName)
return ( return (
<div <div className={classnames(className, 'avatar', 'text-avatar')} style={{background: color}}>
className={classnames(className, "avatar", "text-avatar")}
style={{ background: color }}
>
{displayName && <span>{displayName[0]}</span>} {displayName && <span>{displayName[0]}</span>}
</div> </div>
); )
} }

View file

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react'
import ReactEChartsCore from 'echarts-for-react/lib/core'; import ReactEChartsCore from 'echarts-for-react/lib/core'
import * as echarts from 'echarts/core'; import * as echarts from 'echarts/core'
import { import {
// LineChart, // LineChart,
@ -26,7 +26,7 @@ import {
// ThemeRiverChart, // ThemeRiverChart,
// SunburstChart, // SunburstChart,
// CustomChart, // CustomChart,
} from 'echarts/charts'; } from 'echarts/charts'
// import components, all suffixed with Component // import components, all suffixed with Component
import { import {
@ -60,25 +60,18 @@ import {
// AriaComponent, // AriaComponent,
// TransformComponent, // TransformComponent,
DatasetComponent, DatasetComponent,
} from 'echarts/components'; } from 'echarts/components'
// Import renderer, note that introducing the CanvasRenderer or SVGRenderer is a required step // Import renderer, note that introducing the CanvasRenderer or SVGRenderer is a required step
import { import {
CanvasRenderer, CanvasRenderer,
// SVGRenderer, // SVGRenderer,
} from 'echarts/renderers'; } from 'echarts/renderers'
// Register the required components // Register the required components
echarts.use( echarts.use([TitleComponent, TooltipComponent, GridComponent, BarChart, CanvasRenderer])
[TitleComponent, TooltipComponent, GridComponent, BarChart, CanvasRenderer]
);
// The usage of ReactEChartsCore are same with above. // The usage of ReactEChartsCore are same with above.
export default function Chart(props) { export default function Chart(props) {
return <ReactEChartsCore return <ReactEChartsCore echarts={echarts} notMerge lazyUpdate {...props} />
echarts={echarts}
notMerge
lazyUpdate
{...props}
/>
} }

View file

@ -1,18 +1,18 @@
import React, {useMemo} from "react"; import React, {useMemo} from 'react'
type ColorMap = [number, string][]
import styles from './ColorMapLegend.module.less' import styles from './ColorMapLegend.module.less'
type ColorMap = [number, string][]
function* pairs(arr) { function* pairs(arr) {
for (let i = 1; i < arr.length; i++) { for (let i = 1; i < arr.length; i++) {
yield [arr[i - 1], arr[i]]; yield [arr[i - 1], arr[i]]
} }
} }
function* zip(...arrs) { function* zip(...arrs) {
const l = Math.min(...arrs.map(a => a.length)); const l = Math.min(...arrs.map((a) => a.length))
for (let i = 0; i < l; i++) { for (let i = 0; i < l; i++) {
yield arrs.map(a => a[i]); yield arrs.map((a) => a[i])
} }
} }
@ -25,10 +25,10 @@ export function DiscreteColorMapLegend({map}: {map: ColorMap}) {
min -= buffer min -= buffer
max += buffer max += buffer
const normalizeValue = (v) => (v - min) / (max - min) const normalizeValue = (v) => (v - min) / (max - min)
const stopPairs = Array.from(pairs([min, ...stops, max])); const stopPairs = Array.from(pairs([min, ...stops, max]))
const gradientId = useMemo(() => `gradient${Math.floor(Math.random() * 1000000)}`, []); const gradientId = useMemo(() => `gradient${Math.floor(Math.random() * 1000000)}`, [])
const gradientUrl = `url(#${gradientId})`; const gradientUrl = `url(#${gradientId})`
const parts = Array.from(zip(stopPairs, colors)) const parts = Array.from(zip(stopPairs, colors))
@ -38,11 +38,10 @@ export function DiscreteColorMapLegend({map}: {map: ColorMap}) {
<defs> <defs>
<linearGradient id={gradientId} x1="0" x2="1" y1="0" y2="0"> <linearGradient id={gradientId} x1="0" x2="1" y1="0" y2="0">
{parts.map(([[left, right], color]) => ( {parts.map(([[left, right], color]) => (
<React.Fragment key={left}> <React.Fragment key={left}>
<stop offset={normalizeValue(left) * 100 + '%'} stopColor={color} /> <stop offset={normalizeValue(left) * 100 + '%'} stopColor={color} />
<stop offset={normalizeValue(right) * 100 + '%'} stopColor={color} /> <stop offset={normalizeValue(right) * 100 + '%'} stopColor={color} />
</React.Fragment> </React.Fragment>
))} ))}
</linearGradient> </linearGradient>
</defs> </defs>
@ -59,13 +58,21 @@ export function DiscreteColorMapLegend({map}: {map: ColorMap}) {
) )
} }
export default function ColorMapLegend({map, twoTicks = false, digits=2}: {map: ColorMap, twoTicks?: boolean, digits?: number}) { export default function ColorMapLegend({
map,
twoTicks = false,
digits = 2,
}: {
map: ColorMap
twoTicks?: boolean
digits?: number
}) {
const min = map[0][0] const min = map[0][0]
const max = map[map.length - 1][0] const max = map[map.length - 1][0]
const normalizeValue = (v) => (v - min) / (max - min) const normalizeValue = (v) => (v - min) / (max - min)
const gradientId = useMemo(() => `gradient${Math.floor(Math.random() * 1000000)}`, []); const gradientId = useMemo(() => `gradient${Math.floor(Math.random() * 1000000)}`, [])
const gradientUrl = `url(#${gradientId})`; const gradientUrl = `url(#${gradientId})`
const tickValues = twoTicks ? [map[0], map[map.length-1]] : map const tickValues = twoTicks ? [map[0], map[map.length - 1]] : map
return ( return (
<div className={styles.colorMapLegend}> <div className={styles.colorMapLegend}>
<svg width="100%" height="20" version="1.1" xmlns="http://www.w3.org/2000/svg"> <svg width="100%" height="20" version="1.1" xmlns="http://www.w3.org/2000/svg">

View file

@ -1,31 +1,31 @@
import React from "react"; import React from 'react'
import { Icon, Segment, Header, Button } from "semantic-ui-react"; import {Icon, Segment, Header, Button} from 'semantic-ui-react'
import { useTranslation } from "react-i18next"; import {useTranslation} from 'react-i18next'
import { FileDrop } from "components"; import {FileDrop} from 'components'
export default function FileUploadField({ onSelect: onSelect_, multiple }) { export default function FileUploadField({onSelect: onSelect_, multiple}) {
const { t } = useTranslation(); const {t} = useTranslation()
const labelRef = React.useRef(); const labelRef = React.useRef()
const [labelRefState, setLabelRefState] = React.useState(); const [labelRefState, setLabelRefState] = React.useState()
const onSelect = multiple ? onSelect_ : (files) => onSelect_(files?.[0]); const onSelect = multiple ? onSelect_ : (files) => onSelect_(files?.[0])
React.useLayoutEffect( React.useLayoutEffect(
() => { () => {
setLabelRefState(labelRef.current); setLabelRefState(labelRef.current)
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[labelRef.current] [labelRef.current]
); )
function onChangeField(e) { function onChangeField(e) {
e.preventDefault?.(); e.preventDefault?.()
if (e.target.files && e.target.files.length) { if (e.target.files && e.target.files.length) {
onSelect(e.target.files); onSelect(e.target.files)
} }
e.target.value = ""; // reset the form field for uploading again e.target.value = '' // reset the form field for uploading again
} }
return ( return (
@ -36,7 +36,7 @@ export default function FileUploadField({ onSelect: onSelect_, multiple }) {
style={{ style={{
width: 0, width: 0,
height: 0, height: 0,
position: "fixed", position: 'fixed',
left: -1000, left: -1000,
top: -1000, top: -1000,
opacity: 0.001, opacity: 0.001,
@ -48,34 +48,22 @@ export default function FileUploadField({ onSelect: onSelect_, multiple }) {
<label htmlFor="upload-field" ref={labelRef}> <label htmlFor="upload-field" ref={labelRef}>
{labelRefState && ( {labelRefState && (
<FileDrop onDrop={onSelect} frame={labelRefState}> <FileDrop onDrop={onSelect} frame={labelRefState}>
{({ {({draggingOverFrame, draggingOverTarget, onDragOver, onDragLeave, onDrop, onClick}) => (
draggingOverFrame,
draggingOverTarget,
onDragOver,
onDragLeave,
onDrop,
onClick,
}) => (
<Segment <Segment
placeholder placeholder
{...{ onDragOver, onDragLeave, onDrop }} {...{onDragOver, onDragLeave, onDrop}}
style={{ style={{
background: background: draggingOverTarget || draggingOverFrame ? '#E0E0EE' : null,
draggingOverTarget || draggingOverFrame ? "#E0E0EE" : null, transition: 'background 0.2s',
transition: "background 0.2s",
}} }}
> >
<Header icon> <Header icon>
<Icon name="cloud upload" /> <Icon name="cloud upload" />
{multiple {multiple ? t('FileUploadField.dropOrClickMultiple') : t('FileUploadField.dropOrClick')}
? t("FileUploadField.dropOrClickMultiple")
: t("FileUploadField.dropOrClick")}
</Header> </Header>
<Button primary as="span"> <Button primary as="span">
{multiple {multiple ? t('FileUploadField.uploadFiles') : t('FileUploadField.uploadFile')}
? t("FileUploadField.uploadFiles")
: t("FileUploadField.uploadFile")}
</Button> </Button>
</Segment> </Segment>
)} )}
@ -83,5 +71,5 @@ export default function FileUploadField({ onSelect: onSelect_, multiple }) {
)} )}
</label> </label>
</> </>
); )
} }

View file

@ -21,5 +21,9 @@ export default function FormattedDate({date, relative = false}) {
} }
const iso = dateTime.toISO() const iso = dateTime.toISO()
return <time dateTime={iso} title={iso}>{str}</time> return (
<time dateTime={iso} title={iso}>
{str}
</time>
)
} }

View file

@ -1,9 +1,9 @@
import React from "react"; import React from 'react'
import classnames from "classnames"; import classnames from 'classnames'
import { Container } from "semantic-ui-react"; import {Container} from 'semantic-ui-react'
import { Helmet } from "react-helmet"; import {Helmet} from 'react-helmet'
import styles from "./Page.module.less"; import styles from './Page.module.less'
export default function Page({ export default function Page({
small, small,
@ -12,11 +12,11 @@ export default function Page({
stage, stage,
title, title,
}: { }: {
small?: boolean; small?: boolean
children: ReactNode; children: ReactNode
fullScreen?: boolean; fullScreen?: boolean
stage?: ReactNode; stage?: ReactNode
title?: string; title?: string
}) { }) {
return ( return (
<> <>
@ -37,5 +37,5 @@ export default function Page({
{fullScreen ? children : <Container>{children}</Container>} {fullScreen ? children : <Container>{children}</Container>}
</main> </main>
</> </>
); )
} }

View file

@ -1,76 +1,71 @@
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( const onClick = useCallback((_e, {name}) => setTimeframe(name), [setTimeframe])
(_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) => switchMap((query) => concat(of(null), from(api.get('/stats', {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 (
<> <>
@ -80,43 +75,31 @@ export default function Stats({ user = null }: { user?: null | string }) {
<Statistic.Group widths={2} size="tiny"> <Statistic.Group widths={2} size="tiny">
<Statistic> <Statistic>
<Statistic.Value> <Statistic.Value>
{stats {stats ? `${Number(stats?.trackLength / 1000).toFixed(1)} km` : placeholder}
? `${Number(stats?.trackLength / 1000).toFixed(1)} km`
: placeholder}
</Statistic.Value> </Statistic.Value>
<Statistic.Label>{t("Stats.totalTrackLength")}</Statistic.Label> <Statistic.Label>{t('Stats.totalTrackLength')}</Statistic.Label>
</Statistic> </Statistic>
<Statistic> <Statistic>
<Statistic.Value> <Statistic.Value>{stats ? formatDuration(stats?.trackDuration) : placeholder}</Statistic.Value>
{stats ? formatDuration(stats?.trackDuration) : placeholder} <Statistic.Label>{t('Stats.timeRecorded')}</Statistic.Label>
</Statistic.Value>
<Statistic.Label>{t("Stats.timeRecorded")}</Statistic.Label>
</Statistic> </Statistic>
<Statistic> <Statistic>
<Statistic.Value> <Statistic.Value>{stats?.numEvents ?? placeholder}</Statistic.Value>
{stats?.numEvents ?? placeholder} <Statistic.Label>{t('Stats.eventsConfirmed')}</Statistic.Label>
</Statistic.Value>
<Statistic.Label>{t("Stats.eventsConfirmed")}</Statistic.Label>
</Statistic> </Statistic>
<Statistic> <Statistic>
<Statistic.Value> <Statistic.Value>{stats?.trackCount ?? placeholder}</Statistic.Value>
{stats?.trackCount ?? placeholder} <Statistic.Label>{t('Stats.tracksRecorded')}</Statistic.Label>
</Statistic.Value>
<Statistic.Label>{t("Stats.tracksRecorded")}</Statistic.Label>
</Statistic> </Statistic>
{!user && ( {!user && (
<> <>
<Statistic> <Statistic>
<Statistic.Value> <Statistic.Value>{stats?.userCount ?? placeholder}</Statistic.Value>
{stats?.userCount ?? placeholder} <Statistic.Label>{t('Stats.membersJoined')}</Statistic.Label>
</Statistic.Value>
<Statistic.Label>{t("Stats.membersJoined")}</Statistic.Label>
</Statistic> </Statistic>
<Statistic> <Statistic>
<Statistic.Value> <Statistic.Value>{stats?.deviceCount ?? placeholder}</Statistic.Value>
{stats?.deviceCount ?? placeholder} <Statistic.Label>{t('Stats.deviceCount')}</Statistic.Label>
</Statistic.Value>
<Statistic.Label>{t("Stats.deviceCount")}</Statistic.Label>
</Statistic> </Statistic>
</> </>
)} )}
@ -124,29 +107,17 @@ export default function Stats({ user = null }: { user?: null | string }) {
</Segment> </Segment>
<Menu widths={3} attached="bottom" size="small"> <Menu widths={3} attached="bottom" size="small">
<Menu.Item <Menu.Item name="this_month" active={timeframe === 'this_month'} onClick={onClick}>
name="this_month" {t('Stats.thisMonth')}
active={timeframe === "this_month"}
onClick={onClick}
>
{t("Stats.thisMonth")}
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item name="this_year" active={timeframe === 'this_year'} onClick={onClick}>
name="this_year" {t('Stats.thisYear')}
active={timeframe === "this_year"}
onClick={onClick}
>
{t("Stats.thisYear")}
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item name="all_time" active={timeframe === 'all_time'} onClick={onClick}>
name="all_time" {t('Stats.allTime')}
active={timeframe === "all_time"}
onClick={onClick}
>
{t("Stats.allTime")}
</Menu.Item> </Menu.Item>
</Menu> </Menu>
</div> </div>
</> </>
); )
} }

View file

@ -1,18 +1,14 @@
import React from "react"; import React from 'react'
import { Icon } from "semantic-ui-react"; import {Icon} from 'semantic-ui-react'
import { useTranslation } from "react-i18next"; import {useTranslation} from 'react-i18next'
export default function Visibility({ public: public_ }: { public: boolean }) { export default function Visibility({public: public_}: {public: boolean}) {
const { t } = useTranslation(); const {t} = useTranslation()
const icon = public_ ? ( const icon = public_ ? <Icon color="blue" name="eye" fitted /> : <Icon name="eye slash" fitted />
<Icon color="blue" name="eye" fitted /> const text = public_ ? t('general.public') : t('general.private')
) : (
<Icon name="eye slash" fitted />
);
const text = public_ ? t("general.public") : t("general.private");
return ( return (
<> <>
{icon} {text} {icon} {text}
</> </>
); )
} }

View file

@ -1,46 +1,46 @@
import React from "react"; import React from 'react'
export type MapSource = { export type MapSource = {
type: "vector"; type: 'vector'
tiles: string[]; tiles: string[]
minzoom: number; minzoom: number
maxzoom: number; maxzoom: number
}; }
export interface Config { export interface Config {
apiUrl: string; apiUrl: string
mapHome: { mapHome: {
latitude: number; latitude: number
longitude: number; longitude: number
zoom: number; zoom: number
}; }
obsMapSource?: MapSource; obsMapSource?: MapSource
imprintUrl?: string; imprintUrl?: string
privacyPolicyUrl?: string; privacyPolicyUrl?: string
termsUrl?: string; termsUrl?: string
banner?: { banner?: {
text: string; text: string
style?: "warning" | "info"; style?: 'warning' | 'info'
}; }
} }
async function loadConfig(): Promise<Config> { async function loadConfig(): Promise<Config> {
const response = await fetch(__webpack_public_path__ + "config.json"); const response = await fetch(__webpack_public_path__ + 'config.json')
const config = await response.json(); const config = await response.json()
return config; return config
} }
let _configPromise: Promise<Config> = loadConfig(); let _configPromise: Promise<Config> = loadConfig()
let _configCache: null | Config = null; let _configCache: null | Config = null
export function useConfig() { export function useConfig() {
const [config, setConfig] = React.useState<Config>(_configCache); const [config, setConfig] = React.useState<Config>(_configCache)
React.useEffect(() => { React.useEffect(() => {
if (!_configCache) { if (!_configCache) {
_configPromise.then(setConfig); _configPromise.then(setConfig)
} }
}, []); }, [])
return config; return config
} }
export default _configPromise; export default _configPromise

View file

@ -1,95 +1,87 @@
import { useState, useEffect, useMemo } from "react"; import {useState, useEffect, useMemo} from 'react'
import i18next, { TOptions } from "i18next"; import i18next, {TOptions} from 'i18next'
import { BehaviorSubject, combineLatest } from "rxjs"; import {BehaviorSubject, combineLatest} from 'rxjs'
import { map, distinctUntilChanged } from "rxjs/operators"; import {map, distinctUntilChanged} from 'rxjs/operators'
import HttpBackend, { import HttpBackend, {BackendOptions, RequestCallback} from 'i18next-http-backend'
BackendOptions, import {initReactI18next} from 'react-i18next'
RequestCallback, import LanguageDetector from 'i18next-browser-languagedetector'
} from "i18next-http-backend";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
export type AvailableLocales = "en" | "de" | "fr"; export type AvailableLocales = 'en' | 'de' | 'fr'
async function request( async function request(_options: BackendOptions, url: string, _payload: any, callback: RequestCallback) {
_options: BackendOptions,
url: string,
_payload: any,
callback: RequestCallback
) {
try { try {
const [lng] = url.split("/"); const [lng] = url.split('/')
const locale = await import(`translations/${lng}.yaml`); const locale = await import(`translations/${lng}.yaml`)
callback(null, { status: 200, data: locale }); callback(null, {status: 200, data: locale})
} catch (e) { } catch (e) {
console.error(`Unable to load locale at ${url}\n`, e); console.error(`Unable to load locale at ${url}\n`, e)
callback(null, { status: 404, data: String(e) }); callback(null, {status: 404, data: String(e)})
} }
} }
export const AVAILABLE_LOCALES: AvailableLocales[] = ["en", "de", "fr"]; export const AVAILABLE_LOCALES: AvailableLocales[] = ['en', 'de', 'fr']
const i18n = i18next.createInstance(); const i18n = i18next.createInstance()
const options: TOptions = { const options: TOptions = {
fallbackLng: "en", fallbackLng: 'en',
ns: ["common"], ns: ['common'],
defaultNS: "common", defaultNS: 'common',
whitelist: AVAILABLE_LOCALES, whitelist: AVAILABLE_LOCALES,
// loading via webpack // loading via webpack
backend: { backend: {
loadPath: "{{lng}}/{{ns}}", loadPath: '{{lng}}/{{ns}}',
parse: (data: any) => data, parse: (data: any) => data,
request, request,
}, },
load: "languageOnly", load: 'languageOnly',
interpolation: { interpolation: {
escapeValue: false, // not needed for react as it escapes by default escapeValue: false, // not needed for react as it escapes by default
}, },
}; }
i18n i18n
.use(HttpBackend) .use(HttpBackend)
.use(initReactI18next) .use(initReactI18next)
.use(LanguageDetector) .use(LanguageDetector)
.init({ ...options }); .init({...options})
const locale$ = new BehaviorSubject<AvailableLocales>("en"); const locale$ = new BehaviorSubject<AvailableLocales>('en')
export const translate = i18n.t.bind(i18n); export const translate = i18n.t.bind(i18n)
export const translate$ = (stringAndData$: [string, any]) => export const translate$ = (stringAndData$: [string, any]) =>
combineLatest([stringAndData$, locale$.pipe(distinctUntilChanged())]).pipe( combineLatest([stringAndData$, locale$.pipe(distinctUntilChanged())]).pipe(
map(([stringAndData]) => { map(([stringAndData]) => {
if (typeof stringAndData === "string") { if (typeof stringAndData === 'string') {
return i18n.t(stringAndData); return i18n.t(stringAndData)
} else { } else {
const [string, data] = stringAndData; const [string, data] = stringAndData
return i18n.t(string, { data }); return i18n.t(string, {data})
} }
}) })
); )
export const setLocale = (locale: AvailableLocales) => { export const setLocale = (locale: AvailableLocales) => {
i18n.changeLanguage(locale); i18n.changeLanguage(locale)
locale$.next(locale); locale$.next(locale)
};
export function useLocale() {
const [, reload] = useState();
useEffect(() => {
i18n.on("languageChanged", reload);
return () => {
i18n.off("languageChanged", reload);
};
}, []);
return i18n.language;
} }
export default i18n; export function useLocale() {
const [, reload] = useState()
useEffect(() => {
i18n.on('languageChanged', reload)
return () => {
i18n.off('languageChanged', reload)
}
}, [])
return i18n.language
}
export default i18n

View file

@ -1,169 +1,146 @@
import _ from "lodash"; import _ from 'lodash'
import produce from "immer"; import produce from 'immer'
import bright from "./bright.json"; import bright from './bright.json'
import positron from "./positron.json"; import positron from './positron.json'
import viridisBase from "colormap/res/res/viridis"; import viridisBase from 'colormap/res/res/viridis'
export { bright, positron }; export {bright, positron}
export const baseMapStyles = { bright, positron }; export const baseMapStyles = {bright, positron}
function simplifyColormap(colormap, maxCount = 16) { function simplifyColormap(colormap, maxCount = 16) {
const result = []; const result = []
const step = Math.ceil(colormap.length / maxCount); const step = Math.ceil(colormap.length / maxCount)
for (let i = 0; i < colormap.length; i += step) { for (let i = 0; i < colormap.length; i += step) {
result.push(colormap[i]); result.push(colormap[i])
} }
return result; return result
} }
function rgbArrayToColor(arr) { function rgbArrayToColor(arr) {
return ["rgb", ...arr.map((v) => Math.round(v * 255))]; return ['rgb', ...arr.map((v) => Math.round(v * 255))]
} }
function rgbArrayToHtml(arr) { function rgbArrayToHtml(arr) {
return ( return (
"#" + '#' +
arr arr
.map((v) => Math.round(v * 255).toString(16)) .map((v) => Math.round(v * 255).toString(16))
.map((v) => (v.length == 1 ? "0" : "") + v) .map((v) => (v.length == 1 ? '0' : '') + v)
.join("") .join('')
); )
} }
export function colormapToScale(colormap, value, min, max) { export function colormapToScale(colormap, value, min, max) {
return [ return [
"interpolate-hcl", 'interpolate-hcl',
["linear"], ['linear'],
value, value,
...colormap.flatMap((v, i, a) => [ ...colormap.flatMap((v, i, a) => [(i / (a.length - 1)) * (max - min) + min, v]),
(i / (a.length - 1)) * (max - min) + min, ]
v,
]),
];
} }
export const viridis = simplifyColormap(viridisBase.map(rgbArrayToColor), 20); export const viridis = simplifyColormap(viridisBase.map(rgbArrayToColor), 20)
export const viridisSimpleHtml = simplifyColormap( export const viridisSimpleHtml = simplifyColormap(viridisBase.map(rgbArrayToHtml), 10)
viridisBase.map(rgbArrayToHtml), export const grayscale = ['#FFFFFF', '#000000']
10 export const reds = ['rgba( 255, 0, 0, 0)', 'rgba( 255, 0, 0, 255)']
);
export const grayscale = ["#FFFFFF", "#000000"];
export const reds = ["rgba( 255, 0, 0, 0)", "rgba( 255, 0, 0, 255)"];
export function colorByCount( export function colorByCount(attribute = 'event_count', maxCount, colormap = viridis) {
attribute = "event_count", return colormapToScale(colormap, ['case', isValidAttribute(attribute), ['get', attribute], 0], 0, maxCount)
maxCount,
colormap = viridis
) {
return colormapToScale(
colormap,
["case", isValidAttribute(attribute), ["get", attribute], 0],
0,
maxCount
);
} }
var steps = { rural: [1.6, 1.8, 2.0, 2.2], urban: [1.1, 1.3, 1.5, 1.7] }; var steps = {rural: [1.6, 1.8, 2.0, 2.2], urban: [1.1, 1.3, 1.5, 1.7]}
export function isValidAttribute(attribute) { export function isValidAttribute(attribute) {
if (attribute.endsWith("zone")) { if (attribute.endsWith('zone')) {
return ["in", ["get", attribute], ["literal", ["rural", "urban"]]]; return ['in', ['get', attribute], ['literal', ['rural', 'urban']]]
} }
return ["to-boolean", ["get", attribute]]; return ['to-boolean', ['get', attribute]]
} }
export function borderByZone() { export function borderByZone() {
return ["match", ["get", "zone"], "rural", "cyan", "urban", "blue", "purple"]; return ['match', ['get', 'zone'], 'rural', 'cyan', 'urban', 'blue', 'purple']
} }
export function colorByDistance( export function colorByDistance(attribute = 'distance_overtaker_mean', fallback = '#ABC', zone = 'urban') {
attribute = "distance_overtaker_mean",
fallback = "#ABC",
zone = "urban"
) {
return [ return [
"case", 'case',
["!", isValidAttribute(attribute)], ['!', isValidAttribute(attribute)],
fallback, fallback,
[ [
"match", 'match',
["get", "zone"], ['get', 'zone'],
"rural", 'rural',
[ [
"step", 'step',
["get", attribute], ['get', attribute],
"rgba(150, 0, 0, 1)", 'rgba(150, 0, 0, 1)',
steps["rural"][0], steps['rural'][0],
"rgba(255, 0, 0, 1)", 'rgba(255, 0, 0, 1)',
steps["rural"][1], steps['rural'][1],
"rgba(255, 220, 0, 1)", 'rgba(255, 220, 0, 1)',
steps["rural"][2], steps['rural'][2],
"rgba(67, 200, 0, 1)", 'rgba(67, 200, 0, 1)',
steps["rural"][3], steps['rural'][3],
"rgba(67, 150, 0, 1)", 'rgba(67, 150, 0, 1)',
], ],
"urban", 'urban',
[ [
"step", 'step',
["get", attribute], ['get', attribute],
"rgba(150, 0, 0, 1)", 'rgba(150, 0, 0, 1)',
steps["urban"][0], steps['urban'][0],
"rgba(255, 0, 0, 1)", 'rgba(255, 0, 0, 1)',
steps["urban"][1], steps['urban'][1],
"rgba(255, 220, 0, 1)", 'rgba(255, 220, 0, 1)',
steps["urban"][2], steps['urban'][2],
"rgba(67, 200, 0, 1)", 'rgba(67, 200, 0, 1)',
steps["urban"][3], steps['urban'][3],
"rgba(67, 150, 0, 1)", 'rgba(67, 150, 0, 1)',
], ],
[ [
"step", 'step',
["get", attribute], ['get', attribute],
"rgba(150, 0, 0, 1)", 'rgba(150, 0, 0, 1)',
steps["urban"][0], steps['urban'][0],
"rgba(255, 0, 0, 1)", 'rgba(255, 0, 0, 1)',
steps["urban"][1], steps['urban'][1],
"rgba(255, 220, 0, 1)", 'rgba(255, 220, 0, 1)',
steps["urban"][2], steps['urban'][2],
"rgba(67, 200, 0, 1)", 'rgba(67, 200, 0, 1)',
steps["urban"][3], steps['urban'][3],
"rgba(67, 150, 0, 1)", 'rgba(67, 150, 0, 1)',
], ],
], ],
]; ]
} }
export const trackLayer = { export const trackLayer = {
type: "line", type: 'line',
paint: { paint: {
"line-width": ["interpolate", ["linear"], ["zoom"], 14, 2, 17, 5], 'line-width': ['interpolate', ['linear'], ['zoom'], 14, 2, 17, 5],
"line-color": "#F06292", 'line-color': '#F06292',
"line-opacity": 0.6, 'line-opacity': 0.6,
}, },
}; }
export const getRegionLayers = ( export const getRegionLayers = (adminLevel = 6, baseColor = '#00897B', maxValue = 5000) => [
adminLevel = 6,
baseColor = "#00897B",
maxValue = 5000
) => [
{ {
id: "region", id: 'region',
type: "fill", type: 'fill',
source: "obs", source: 'obs',
"source-layer": "obs_regions", 'source-layer': 'obs_regions',
minzoom: 0, minzoom: 0,
maxzoom: 10, maxzoom: 10,
// filter: [">", "overtaking_event_count", 0], // filter: [">", "overtaking_event_count", 0],
paint: { paint: {
"fill-color": baseColor, 'fill-color': baseColor,
"fill-antialias": true, 'fill-antialias': true,
"fill-opacity": [ 'fill-opacity': [
"interpolate", 'interpolate',
["linear"], ['linear'],
["log10", ["max",["get", "overtaking_event_count"],1]], ['log10', ['max', ['get', 'overtaking_event_count'], 1]],
0, 0,
0, 0,
Math.log10(maxValue), Math.log10(maxValue),
@ -172,38 +149,38 @@ export const getRegionLayers = (
}, },
}, },
{ {
id: "region-border", id: 'region-border',
type: "line", type: 'line',
source: "obs", source: 'obs',
"source-layer": "obs_regions", 'source-layer': 'obs_regions',
minzoom: 0, minzoom: 0,
maxzoom: 10, maxzoom: 10,
// filter: [">", "overtaking_event_count", 0], // filter: [">", "overtaking_event_count", 0],
paint: { paint: {
"line-width": [ 'line-width': [
"interpolate", 'interpolate',
["linear"], ['linear'],
["log10", ["max",["get", "overtaking_event_count"],1]], ['log10', ['max', ['get', 'overtaking_event_count'], 1]],
0, 0,
0.2, 0.2,
Math.log10(maxValue), Math.log10(maxValue),
1.5, 1.5,
], ],
"line-color": baseColor, 'line-color': baseColor,
}, },
layout: { layout: {
"line-join": "round", 'line-join': 'round',
"line-cap": "round", 'line-cap': 'round',
}, },
}, },
]; ]
export const trackLayerRaw = produce(trackLayer, (draft) => { export const trackLayerRaw = produce(trackLayer, (draft) => {
// draft.paint['line-color'] = '#81D4FA' // draft.paint['line-color'] = '#81D4FA'
draft.paint["line-width"][4] = 1; draft.paint['line-width'][4] = 1
draft.paint["line-width"][6] = 2; draft.paint['line-width'][6] = 2
draft.paint["line-dasharray"] = [3, 3]; draft.paint['line-dasharray'] = [3, 3]
delete draft.paint["line-opacity"]; delete draft.paint['line-opacity']
}); })
export const basemap = positron; export const basemap = positron

View file

@ -1,18 +1,18 @@
import React from "react"; import React from 'react'
import { Header } from "semantic-ui-react"; import {Header} from 'semantic-ui-react'
import { useTranslation } from "react-i18next"; import {useTranslation} from 'react-i18next'
import Markdown from "react-markdown"; import Markdown from 'react-markdown'
import { Page } from "components"; import {Page} from 'components'
export default function AcknowledgementsPage() { export default function AcknowledgementsPage() {
const { t } = useTranslation(); const {t} = useTranslation()
const title = t("AcknowledgementsPage.title"); const title = t('AcknowledgementsPage.title')
return ( return (
<Page title={title}> <Page title={title}>
<Header as="h2">{title}</Header> <Header as="h2">{title}</Header>
<Markdown>{t("AcknowledgementsPage.information")}</Markdown> <Markdown>{t('AcknowledgementsPage.information')}</Markdown>
</Page> </Page>
); )
} }

View file

@ -1,134 +1,121 @@
import React, { useState, useCallback, useMemo } from "react"; import React, {useState, useCallback, useMemo} from 'react'
import { Source, Layer } from "react-map-gl"; import {Source, Layer} from 'react-map-gl'
import _ from "lodash"; import _ from 'lodash'
import { import {Button, Form, Dropdown, Header, Message, Icon} from 'semantic-ui-react'
Button, import {useTranslation, Trans as Translate} from 'react-i18next'
Form, import Markdown from 'react-markdown'
Dropdown,
Header,
Message,
Icon,
} from "semantic-ui-react";
import { useTranslation, Trans as Translate } from "react-i18next";
import Markdown from "react-markdown";
import { useConfig } from "config"; import {useConfig} from 'config'
import { Page, Map } from "components"; import {Page, Map} from 'components'
const BoundingBoxSelector = React.forwardRef( const BoundingBoxSelector = React.forwardRef(({value, name, onChange}, ref) => {
({ value, name, onChange }, ref) => { const {t} = useTranslation()
const { t } = useTranslation(); const [pointNum, setPointNum] = useState(0)
const [pointNum, setPointNum] = useState(0); const [point0, setPoint0] = useState(null)
const [point0, setPoint0] = useState(null); const [point1, setPoint1] = useState(null)
const [point1, setPoint1] = useState(null);
const onClick = (e) => { const onClick = (e) => {
if (pointNum == 0) { if (pointNum == 0) {
setPoint0(e.lngLat); setPoint0(e.lngLat)
} else { } else {
setPoint1(e.lngLat); setPoint1(e.lngLat)
} }
setPointNum(1 - pointNum); setPointNum(1 - pointNum)
};
React.useEffect(() => {
if (!point0 || !point1) return;
const bbox = `${point0[0]},${point0[1]},${point1[0]},${point1[1]}`;
if (bbox !== value) {
onChange(bbox);
}
}, [point0, point1]);
React.useEffect(() => {
if (!value) return;
const [p00, p01, p10, p11] = value
.split(",")
.map((v) => Number.parseFloat(v));
if (!point0 || point0[0] != p00 || point0[1] != p01)
setPoint0([p00, p01]);
if (!point1 || point1[0] != p10 || point1[1] != p11)
setPoint1([p10, p11]);
}, [value]);
return (
<div>
<Form.Input
label={t('ExportPage.boundingBox.label')}
{...{ name, value }}
onChange={(e) => onChange(e.target.value)}
/>
<div style={{ height: 400, position: "relative", marginBottom: 16 }}>
<Map onClick={onClick}>
<Source
id="bbox"
type="geojson"
data={
point0 && point1
? {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: {
type: "Polygon",
coordinates: [
[
[point0[0], point0[1]],
[point1[0], point0[1]],
[point1[0], point1[1]],
[point0[0], point1[1]],
[point0[0], point0[1]],
],
],
},
},
],
}
: {}
}
>
<Layer
id="bbox"
type="line"
paint={{
"line-width": 4,
"line-color": "#F06292",
}}
/>
</Source>
</Map>
</div>
</div>
);
} }
);
const MODES = ["events", "segments"]; React.useEffect(() => {
const FORMATS = ["geojson", "shapefile"]; if (!point0 || !point1) return
const bbox = `${point0[0]},${point0[1]},${point1[0]},${point1[1]}`
if (bbox !== value) {
onChange(bbox)
}
}, [point0, point1])
React.useEffect(() => {
if (!value) return
const [p00, p01, p10, p11] = value.split(',').map((v) => Number.parseFloat(v))
if (!point0 || point0[0] != p00 || point0[1] != p01) setPoint0([p00, p01])
if (!point1 || point1[0] != p10 || point1[1] != p11) setPoint1([p10, p11])
}, [value])
return (
<div>
<Form.Input
label={t('ExportPage.boundingBox.label')}
{...{name, value}}
onChange={(e) => onChange(e.target.value)}
/>
<div style={{height: 400, position: 'relative', marginBottom: 16}}>
<Map onClick={onClick}>
<Source
id="bbox"
type="geojson"
data={
point0 && point1
? {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[point0[0], point0[1]],
[point1[0], point0[1]],
[point1[0], point1[1]],
[point0[0], point1[1]],
[point0[0], point0[1]],
],
],
},
},
],
}
: {}
}
>
<Layer
id="bbox"
type="line"
paint={{
'line-width': 4,
'line-color': '#F06292',
}}
/>
</Source>
</Map>
</div>
</div>
)
})
const MODES = ['events', 'segments']
const FORMATS = ['geojson', 'shapefile']
export default function ExportPage() { export default function ExportPage() {
const [mode, setMode] = useState("events"); const [mode, setMode] = useState('events')
const [bbox, setBbox] = useState("8.294678,49.651182,9.059601,50.108249"); const [bbox, setBbox] = useState('8.294678,49.651182,9.059601,50.108249')
const [fmt, setFmt] = useState("geojson"); const [fmt, setFmt] = useState('geojson')
const config = useConfig(); const config = useConfig()
const { t } = useTranslation(); const {t} = useTranslation()
return ( return (
<Page title="Export"> <Page title="Export">
<Header as="h2">{t("ExportPage.title")}</Header> <Header as="h2">{t('ExportPage.title')}</Header>
<Message icon info> <Message icon info>
<Icon name="info circle" /> <Icon name="info circle" />
<Message.Content> <Message.Content>
<Markdown>{t("ExportPage.information")}</Markdown> <Markdown>{t('ExportPage.information')}</Markdown>
</Message.Content> </Message.Content>
</Message> </Message>
<Form> <Form>
<Form.Field> <Form.Field>
<label>{t("ExportPage.mode.label")}</label> <label>{t('ExportPage.mode.label')}</label>
<Dropdown <Dropdown
placeholder={t("ExportPage.mode.placeholder")} placeholder={t('ExportPage.mode.placeholder')}
fluid fluid
selection selection
options={MODES.map((value) => ({ options={MODES.map((value) => ({
@ -137,14 +124,14 @@ export default function ExportPage() {
value, value,
}))} }))}
value={mode} value={mode}
onChange={(_e, { value }) => setMode(value)} onChange={(_e, {value}) => setMode(value)}
/> />
</Form.Field> </Form.Field>
<Form.Field> <Form.Field>
<label>{t("ExportPage.format.label")}</label> <label>{t('ExportPage.format.label')}</label>
<Dropdown <Dropdown
placeholder={t("ExportPage.format.placeholder")} placeholder={t('ExportPage.format.placeholder')}
fluid fluid
selection selection
options={FORMATS.map((value) => ({ options={FORMATS.map((value) => ({
@ -153,7 +140,7 @@ export default function ExportPage() {
value, value,
}))} }))}
value={fmt} value={fmt}
onChange={(_e, { value }) => setFmt(value)} onChange={(_e, {value}) => setFmt(value)}
/> />
</Form.Field> </Form.Field>
@ -170,5 +157,5 @@ export default function ExportPage() {
</Button> </Button>
</Form> </Form>
</Page> </Page>
); )
} }

View file

@ -1,69 +1,66 @@
import React from "react"; import React from 'react'
import { connect } from "react-redux"; import {connect} from 'react-redux'
import { Redirect, useLocation, useHistory } from "react-router-dom"; import {Redirect, useLocation, useHistory} from 'react-router-dom'
import { Icon, Message } from "semantic-ui-react"; import {Icon, Message} from 'semantic-ui-react'
import { useObservable } from "rxjs-hooks"; import {useObservable} from 'rxjs-hooks'
import { switchMap, pluck, distinctUntilChanged } from "rxjs/operators"; import {switchMap, pluck, distinctUntilChanged} from 'rxjs/operators'
import { useTranslation } from "react-i18next"; import {useTranslation} from 'react-i18next'
import { Page } from "components"; import {Page} from 'components'
import api from "api"; import api from 'api'
const LoginRedirectPage = connect((state) => ({ const LoginRedirectPage = connect((state) => ({
loggedIn: Boolean(state.login), loggedIn: Boolean(state.login),
}))(function LoginRedirectPage({ loggedIn }) { }))(function LoginRedirectPage({loggedIn}) {
const location = useLocation(); const location = useLocation()
const history = useHistory(); const history = useHistory()
const { search } = location; const {search} = location
const { t } = useTranslation(); const {t} = useTranslation()
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
// Hook dependency arrays in this block are intentionally left blank, we want // Hook dependency arrays in this block are intentionally left blank, we want
// to keep the initial state, but reset the url once, ASAP, to not leak the // to keep the initial state, but reset the url once, ASAP, to not leak the
// query parameters. This is considered good practice by OAuth. // query parameters. This is considered good practice by OAuth.
const searchParams = React.useMemo( const searchParams = React.useMemo(() => Object.fromEntries(new URLSearchParams(search).entries()), [])
() => Object.fromEntries(new URLSearchParams(search).entries()),
[]
);
React.useEffect(() => { React.useEffect(() => {
history.replace({ ...location, search: "" }); history.replace({...location, search: ''})
}, []); }, [])
/* eslint-enable react-hooks/exhaustive-deps */ /* eslint-enable react-hooks/exhaustive-deps */
if (loggedIn) { if (loggedIn) {
return <Redirect to="/" />; return <Redirect to="/" />
} }
const { error, error_description: errorDescription, code } = searchParams; const {error, error_description: errorDescription, code} = searchParams
if (error) { if (error) {
return ( return (
<Page small title={t("LoginRedirectPage.loginError")}> <Page small title={t('LoginRedirectPage.loginError')}>
<LoginError errorText={errorDescription || error} /> <LoginError errorText={errorDescription || error} />
</Page> </Page>
); )
} }
return <ExchangeAuthCode code={code} />; return <ExchangeAuthCode code={code} />
}); })
function LoginError({ errorText }: { errorText: string }) { function LoginError({errorText}: {errorText: string}) {
const { t } = useTranslation(); const {t} = useTranslation()
return ( return (
<Message icon error> <Message icon error>
<Icon name="warning sign" /> <Icon name="warning sign" />
<Message.Content> <Message.Content>
<Message.Header>{t("LoginRedirectPage.loginError")}</Message.Header> <Message.Header>{t('LoginRedirectPage.loginError')}</Message.Header>
{t("LoginRedirectPage.loginErrorText", { error: errorText })} {t('LoginRedirectPage.loginErrorText', {error: errorText})}
</Message.Content> </Message.Content>
</Message> </Message>
); )
} }
function ExchangeAuthCode({ code }) { function ExchangeAuthCode({code}) {
const { t } = useTranslation(); const {t} = useTranslation()
const result = useObservable( const result = useObservable(
(_$, args$) => (_$, args$) =>
args$.pipe( args$.pipe(
@ -73,31 +70,31 @@ function ExchangeAuthCode({ code }) {
), ),
null, null,
[code] [code]
); )
let content; let content
if (result === null) { if (result === null) {
content = ( content = (
<Message icon info> <Message icon info>
<Icon name="circle notched" loading /> <Icon name="circle notched" loading />
<Message.Content> <Message.Content>
<Message.Header>{t("LoginRedirectPage.loggingIn")}</Message.Header> <Message.Header>{t('LoginRedirectPage.loggingIn')}</Message.Header>
{t("LoginRedirectPage.hangTight")} {t('LoginRedirectPage.hangTight')}
</Message.Content> </Message.Content>
</Message> </Message>
); )
} else if (result === true) { } else if (result === true) {
content = <Redirect to="/" />; content = <Redirect to="/" />
} else { } else {
const { error, error_description: errorDescription } = result; const {error, error_description: errorDescription} = result
content = <LoginError errorText={errorDescription || error} />; content = <LoginError errorText={errorDescription || error} />
} }
return ( return (
<Page small title="Login"> <Page small title="Login">
{content} {content}
</Page> </Page>
); )
} }
export default LoginRedirectPage; export default LoginRedirectPage

View file

@ -1,81 +1,65 @@
import React from "react"; import React from 'react'
import _ from "lodash"; import _ from 'lodash'
import { connect } from "react-redux"; import {connect} from 'react-redux'
import { Link } from "react-router-dom"; import {Link} from 'react-router-dom'
import { import {List, Select, Input, Divider, Label, Checkbox, Header} from 'semantic-ui-react'
List, import {useTranslation} from 'react-i18next'
Select,
Input,
Divider,
Label,
Checkbox,
Header,
} from "semantic-ui-react";
import { useTranslation } from "react-i18next";
import { import {
MapConfig, MapConfig,
setMapConfigFlag as setMapConfigFlagAction, setMapConfigFlag as setMapConfigFlagAction,
initialState as defaultMapConfig, initialState as defaultMapConfig,
} from "reducers/mapConfig"; } from 'reducers/mapConfig'
import { colorByDistance, colorByCount, viridisSimpleHtml } from "mapstyles"; import {colorByDistance, colorByCount, viridisSimpleHtml} from 'mapstyles'
import { ColorMapLegend, DiscreteColorMapLegend } from "components"; import {ColorMapLegend, DiscreteColorMapLegend} from 'components'
import styles from "./styles.module.less"; import styles from './styles.module.less'
const BASEMAP_STYLE_OPTIONS = ["positron", "bright"]; const BASEMAP_STYLE_OPTIONS = ['positron', 'bright']
const ROAD_ATTRIBUTE_OPTIONS = [ const ROAD_ATTRIBUTE_OPTIONS = [
"distance_overtaker_mean", 'distance_overtaker_mean',
"distance_overtaker_min", 'distance_overtaker_min',
"distance_overtaker_max", 'distance_overtaker_max',
"distance_overtaker_median", 'distance_overtaker_median',
"overtaking_event_count", 'overtaking_event_count',
"usage_count", 'usage_count',
"zone", 'zone',
]; ]
const DATE_FILTER_MODES = ["none", "range", "threshold"]; const DATE_FILTER_MODES = ['none', 'range', 'threshold']
type User = Object; type User = Object
function LayerSidebar({ function LayerSidebar({
mapConfig, mapConfig,
login, login,
setMapConfigFlag, setMapConfigFlag,
}: { }: {
login: User | null; login: User | null
mapConfig: MapConfig; mapConfig: MapConfig
setMapConfigFlag: (flag: string, value: unknown) => void; setMapConfigFlag: (flag: string, value: unknown) => void
}) { }) {
const { t } = useTranslation(); const {t} = useTranslation()
const { const {
baseMap: { style }, baseMap: {style},
obsRoads: { show: showRoads, showUntagged, attribute, maxCount }, obsRoads: {show: showRoads, showUntagged, attribute, maxCount},
obsEvents: { show: showEvents }, obsEvents: {show: showEvents},
obsRegions: { show: showRegions }, obsRegions: {show: showRegions},
filters: { filters: {currentUser: filtersCurrentUser, dateMode, startDate, endDate, thresholdAfter},
currentUser: filtersCurrentUser, } = mapConfig
dateMode,
startDate,
endDate,
thresholdAfter,
},
} = mapConfig;
const openStreetMapCopyright = ( const openStreetMapCopyright = (
<List.Item className={styles.copyright}> <List.Item className={styles.copyright}>
{t("MapPage.sidebar.copyright.openStreetMap")}{" "} {t('MapPage.sidebar.copyright.openStreetMap')}{' '}
<Link to="/acknowledgements"> <Link to="/acknowledgements">{t('MapPage.sidebar.copyright.learnMore')}</Link>
{t("MapPage.sidebar.copyright.learnMore")}
</Link>
</List.Item> </List.Item>
); )
return ( return (
<div> <div>
<List relaxed> <List relaxed>
<List.Item> <List.Item>
<List.Header>{t("MapPage.sidebar.baseMap.style.label")}</List.Header> <List.Header>{t('MapPage.sidebar.baseMap.style.label')}</List.Header>
<Select <Select
options={BASEMAP_STYLE_OPTIONS.map((value) => ({ options={BASEMAP_STYLE_OPTIONS.map((value) => ({
value, value,
@ -83,9 +67,7 @@ function LayerSidebar({
text: t(`MapPage.sidebar.baseMap.style.${value}`), text: t(`MapPage.sidebar.baseMap.style.${value}`),
}))} }))}
value={style} value={style}
onChange={(_e, { value }) => onChange={(_e, {value}) => setMapConfigFlag('baseMap.style', value)}
setMapConfigFlag("baseMap.style", value)
}
/> />
</List.Item> </List.Item>
{openStreetMapCopyright} {openStreetMapCopyright}
@ -95,34 +77,30 @@ function LayerSidebar({
toggle toggle
size="small" size="small"
id="obsRegions.show" id="obsRegions.show"
style={{ float: "right" }} style={{float: 'right'}}
checked={showRegions} checked={showRegions}
onChange={() => setMapConfigFlag("obsRegions.show", !showRegions)} onChange={() => setMapConfigFlag('obsRegions.show', !showRegions)}
/> />
<label htmlFor="obsRegions.show"> <label htmlFor="obsRegions.show">
<Header as="h4">{t("MapPage.sidebar.obsRegions.title")}</Header> <Header as="h4">{t('MapPage.sidebar.obsRegions.title')}</Header>
</label> </label>
</List.Item> </List.Item>
{showRegions && ( {showRegions && (
<> <>
<List.Item> <List.Item>{t('MapPage.sidebar.obsRegions.colorByEventCount')}</List.Item>
{t("MapPage.sidebar.obsRegions.colorByEventCount")}
</List.Item>
<List.Item> <List.Item>
<ColorMapLegend <ColorMapLegend
twoTicks twoTicks
map={[ map={[
[0, "#00897B00"], [0, '#00897B00'],
[5000, "#00897BFF"], [5000, '#00897BFF'],
]} ]}
digits={0} digits={0}
/> />
</List.Item> </List.Item>
<List.Item className={styles.copyright}> <List.Item className={styles.copyright}>
{t("MapPage.sidebar.copyright.boundaries")}{" "} {t('MapPage.sidebar.copyright.boundaries')}{' '}
<Link to="/acknowledgements"> <Link to="/acknowledgements">{t('MapPage.sidebar.copyright.learnMore')}</Link>
{t("MapPage.sidebar.copyright.learnMore")}
</Link>
</List.Item> </List.Item>
</> </>
)} )}
@ -132,12 +110,12 @@ function LayerSidebar({
toggle toggle
size="small" size="small"
id="obsRoads.show" id="obsRoads.show"
style={{ float: "right" }} style={{float: 'right'}}
checked={showRoads} checked={showRoads}
onChange={() => setMapConfigFlag("obsRoads.show", !showRoads)} onChange={() => setMapConfigFlag('obsRoads.show', !showRoads)}
/> />
<label htmlFor="obsRoads.show"> <label htmlFor="obsRoads.show">
<Header as="h4">{t("MapPage.sidebar.obsRoads.title")}</Header> <Header as="h4">{t('MapPage.sidebar.obsRoads.title')}</Header>
</label> </label>
</List.Item> </List.Item>
{showRoads && ( {showRoads && (
@ -145,16 +123,12 @@ function LayerSidebar({
<List.Item> <List.Item>
<Checkbox <Checkbox
checked={showUntagged} checked={showUntagged}
onChange={() => onChange={() => setMapConfigFlag('obsRoads.showUntagged', !showUntagged)}
setMapConfigFlag("obsRoads.showUntagged", !showUntagged) label={t('MapPage.sidebar.obsRoads.showUntagged.label')}
}
label={t("MapPage.sidebar.obsRoads.showUntagged.label")}
/> />
</List.Item> </List.Item>
<List.Item> <List.Item>
<List.Header> <List.Header>{t('MapPage.sidebar.obsRoads.attribute.label')}</List.Header>
{t("MapPage.sidebar.obsRoads.attribute.label")}
</List.Header>
<Select <Select
fluid fluid
options={ROAD_ATTRIBUTE_OPTIONS.map((value) => ({ options={ROAD_ATTRIBUTE_OPTIONS.map((value) => ({
@ -163,74 +137,50 @@ function LayerSidebar({
text: t(`MapPage.sidebar.obsRoads.attribute.${value}`), text: t(`MapPage.sidebar.obsRoads.attribute.${value}`),
}))} }))}
value={attribute} value={attribute}
onChange={(_e, { value }) => onChange={(_e, {value}) => setMapConfigFlag('obsRoads.attribute', value)}
setMapConfigFlag("obsRoads.attribute", value)
}
/> />
</List.Item> </List.Item>
{attribute.endsWith("_count") ? ( {attribute.endsWith('_count') ? (
<> <>
<List.Item> <List.Item>
<List.Header> <List.Header>{t('MapPage.sidebar.obsRoads.maxCount.label')}</List.Header>
{t("MapPage.sidebar.obsRoads.maxCount.label")}
</List.Header>
<Input <Input
fluid fluid
type="number" type="number"
value={maxCount} value={maxCount}
onChange={(_e, { value }) => onChange={(_e, {value}) => setMapConfigFlag('obsRoads.maxCount', value)}
setMapConfigFlag("obsRoads.maxCount", value)
}
/> />
</List.Item> </List.Item>
<List.Item> <List.Item>
<ColorMapLegend <ColorMapLegend
map={_.chunk( map={_.chunk(
colorByCount( colorByCount('obsRoads.maxCount', mapConfig.obsRoads.maxCount, viridisSimpleHtml).slice(3),
"obsRoads.maxCount",
mapConfig.obsRoads.maxCount,
viridisSimpleHtml
).slice(3),
2 2
)} )}
twoTicks twoTicks
/> />
</List.Item> </List.Item>
</> </>
) : attribute.endsWith("zone") ? ( ) : attribute.endsWith('zone') ? (
<> <>
<List.Item> <List.Item>
<Label <Label size="small" style={{background: 'blue', color: 'white'}}>
size="small" {t('general.zone.urban')} (1.5&nbsp;m)
style={{ background: "blue", color: "white" }}
>
{t("general.zone.urban")} (1.5&nbsp;m)
</Label> </Label>
<Label <Label size="small" style={{background: 'cyan', color: 'black'}}>
size="small" {t('general.zone.rural')}(2&nbsp;m)
style={{ background: "cyan", color: "black" }}
>
{t("general.zone.rural")}(2&nbsp;m)
</Label> </Label>
</List.Item> </List.Item>
</> </>
) : ( ) : (
<> <>
<List.Item> <List.Item>
<List.Header> <List.Header>{_.upperFirst(t('general.zone.urban'))}</List.Header>
{_.upperFirst(t("general.zone.urban"))} <DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][5].slice(2)} />
</List.Header>
<DiscreteColorMapLegend
map={colorByDistance("distance_overtaker")[3][5].slice(2)}
/>
</List.Item> </List.Item>
<List.Item> <List.Item>
<List.Header> <List.Header>{_.upperFirst(t('general.zone.rural'))}</List.Header>
{_.upperFirst(t("general.zone.rural"))} <DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][3].slice(2)} />
</List.Header>
<DiscreteColorMapLegend
map={colorByDistance("distance_overtaker")[3][3].slice(2)}
/>
</List.Item> </List.Item>
</> </>
)} )}
@ -243,40 +193,36 @@ function LayerSidebar({
toggle toggle
size="small" size="small"
id="obsEvents.show" id="obsEvents.show"
style={{ float: "right" }} style={{float: 'right'}}
checked={showEvents} checked={showEvents}
onChange={() => setMapConfigFlag("obsEvents.show", !showEvents)} onChange={() => setMapConfigFlag('obsEvents.show', !showEvents)}
/> />
<label htmlFor="obsEvents.show"> <label htmlFor="obsEvents.show">
<Header as="h4">{t("MapPage.sidebar.obsEvents.title")}</Header> <Header as="h4">{t('MapPage.sidebar.obsEvents.title')}</Header>
</label> </label>
</List.Item> </List.Item>
{showEvents && ( {showEvents && (
<> <>
<List.Item> <List.Item>
<List.Header>{_.upperFirst(t("general.zone.urban"))}</List.Header> <List.Header>{_.upperFirst(t('general.zone.urban'))}</List.Header>
<DiscreteColorMapLegend <DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][5].slice(2)} />
map={colorByDistance("distance_overtaker")[3][5].slice(2)}
/>
</List.Item> </List.Item>
<List.Item> <List.Item>
<List.Header>{_.upperFirst(t("general.zone.rural"))}</List.Header> <List.Header>{_.upperFirst(t('general.zone.rural'))}</List.Header>
<DiscreteColorMapLegend <DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][3].slice(2)} />
map={colorByDistance("distance_overtaker")[3][3].slice(2)}
/>
</List.Item> </List.Item>
</> </>
)} )}
<Divider /> <Divider />
<List.Item> <List.Item>
<Header as="h4">{t("MapPage.sidebar.filters.title")}</Header> <Header as="h4">{t('MapPage.sidebar.filters.title')}</Header>
</List.Item> </List.Item>
{login && ( {login && (
<> <>
<List.Item> <List.Item>
<Header as="h5">{t("MapPage.sidebar.filters.userData")}</Header> <Header as="h5">{t('MapPage.sidebar.filters.userData')}</Header>
</List.Item> </List.Item>
<List.Item> <List.Item>
@ -285,15 +231,13 @@ function LayerSidebar({
size="small" size="small"
id="filters.currentUser" id="filters.currentUser"
checked={filtersCurrentUser} checked={filtersCurrentUser}
onChange={() => onChange={() => setMapConfigFlag('filters.currentUser', !filtersCurrentUser)}
setMapConfigFlag("filters.currentUser", !filtersCurrentUser) label={t('MapPage.sidebar.filters.currentUser')}
}
label={t("MapPage.sidebar.filters.currentUser")}
/> />
</List.Item> </List.Item>
<List.Item> <List.Item>
<Header as="h5">{t("MapPage.sidebar.filters.dateRange")}</Header> <Header as="h5">{t('MapPage.sidebar.filters.dateRange')}</Header>
</List.Item> </List.Item>
<List.Item> <List.Item>
@ -304,14 +248,12 @@ function LayerSidebar({
key: value, key: value,
text: t(`MapPage.sidebar.filters.dateMode.${value}`), text: t(`MapPage.sidebar.filters.dateMode.${value}`),
}))} }))}
value={dateMode ?? "none"} value={dateMode ?? 'none'}
onChange={(_e, { value }) => onChange={(_e, {value}) => setMapConfigFlag('filters.dateMode', value)}
setMapConfigFlag("filters.dateMode", value)
}
/> />
</List.Item> </List.Item>
{dateMode == "range" && ( {dateMode == 'range' && (
<List.Item> <List.Item>
<Input <Input
type="date" type="date"
@ -319,16 +261,14 @@ function LayerSidebar({
step="7" step="7"
size="small" size="small"
id="filters.startDate" id="filters.startDate"
onChange={(_e, { value }) => onChange={(_e, {value}) => setMapConfigFlag('filters.startDate', value)}
setMapConfigFlag("filters.startDate", value)
}
value={startDate ?? null} value={startDate ?? null}
label={t("MapPage.sidebar.filters.start")} label={t('MapPage.sidebar.filters.start')}
/> />
</List.Item> </List.Item>
)} )}
{dateMode == "range" && ( {dateMode == 'range' && (
<List.Item> <List.Item>
<Input <Input
type="date" type="date"
@ -336,16 +276,14 @@ function LayerSidebar({
step="7" step="7"
size="small" size="small"
id="filters.endDate" id="filters.endDate"
onChange={(_e, { value }) => onChange={(_e, {value}) => setMapConfigFlag('filters.endDate', value)}
setMapConfigFlag("filters.endDate", value)
}
value={endDate ?? null} value={endDate ?? null}
label={t("MapPage.sidebar.filters.end")} label={t('MapPage.sidebar.filters.end')}
/> />
</List.Item> </List.Item>
)} )}
{dateMode == "threshold" && ( {dateMode == 'threshold' && (
<List.Item> <List.Item>
<Input <Input
type="date" type="date"
@ -354,42 +292,33 @@ function LayerSidebar({
size="small" size="small"
id="filters.startDate" id="filters.startDate"
value={startDate ?? null} value={startDate ?? null}
onChange={(_e, { value }) => onChange={(_e, {value}) => setMapConfigFlag('filters.startDate', value)}
setMapConfigFlag("filters.startDate", value) label={t('MapPage.sidebar.filters.threshold')}
}
label={t("MapPage.sidebar.filters.threshold")}
/> />
</List.Item> </List.Item>
)} )}
{dateMode == "threshold" && ( {dateMode == 'threshold' && (
<List.Item> <List.Item>
<span> <span>
{t("MapPage.sidebar.filters.before")}{" "} {t('MapPage.sidebar.filters.before')}{' '}
<Checkbox <Checkbox
toggle toggle
size="small" size="small"
checked={thresholdAfter ?? false} checked={thresholdAfter ?? false}
onChange={() => onChange={() => setMapConfigFlag('filters.thresholdAfter', !thresholdAfter)}
setMapConfigFlag(
"filters.thresholdAfter",
!thresholdAfter
)
}
id="filters.thresholdAfter" id="filters.thresholdAfter"
/>{" "} />{' '}
{t("MapPage.sidebar.filters.after")} {t('MapPage.sidebar.filters.after')}
</span> </span>
</List.Item> </List.Item>
)} )}
</> </>
)} )}
{!login && ( {!login && <List.Item>{t('MapPage.sidebar.filters.needsLogin')}</List.Item>}
<List.Item>{t("MapPage.sidebar.filters.needsLogin")}</List.Item>
)}
</List> </List>
</div> </div>
); )
} }
export default connect( export default connect(
@ -402,6 +331,6 @@ export default connect(
), ),
login: state.login, login: state.login,
}), }),
{ setMapConfigFlag: setMapConfigFlagAction } {setMapConfigFlag: setMapConfigFlagAction}
// //
)(LayerSidebar); )(LayerSidebar)

View file

@ -1,296 +1,253 @@
import React, { useState, useCallback, useMemo, useRef } from "react"; import React, {useState, useCallback, useMemo, useRef} from 'react'
import _ from "lodash"; import _ from 'lodash'
import { connect } from "react-redux"; import {connect} from 'react-redux'
import { Button } from "semantic-ui-react"; import {Button} from 'semantic-ui-react'
import { Layer, Source } from "react-map-gl"; import {Layer, Source} from 'react-map-gl'
import produce from "immer"; import produce from 'immer'
import classNames from "classnames"; import classNames from 'classnames'
import api from "api"; import api from 'api'
import type { Location } from "types"; import type {Location} from 'types'
import { Page, Map } from "components"; import {Page, Map} from 'components'
import { useConfig } from "config"; import {useConfig} from 'config'
import { import {colorByDistance, colorByCount, getRegionLayers, borderByZone, isValidAttribute} from 'mapstyles'
colorByDistance, import {useMapConfig} from 'reducers/mapConfig'
colorByCount,
getRegionLayers,
borderByZone,
isValidAttribute,
} from "mapstyles";
import { useMapConfig } from "reducers/mapConfig";
import RoadInfo, { RoadInfoType } from "./RoadInfo"; import RoadInfo, {RoadInfoType} from './RoadInfo'
import RegionInfo from "./RegionInfo"; import RegionInfo from './RegionInfo'
import LayerSidebar from "./LayerSidebar"; import LayerSidebar from './LayerSidebar'
import styles from "./styles.module.less"; import styles from './styles.module.less'
const untaggedRoadsLayer = { const untaggedRoadsLayer = {
id: "obs_roads_untagged", id: 'obs_roads_untagged',
type: "line", type: 'line',
source: "obs", source: 'obs',
"source-layer": "obs_roads", 'source-layer': 'obs_roads',
minzoom: 12, minzoom: 12,
filter: ["!", ["to-boolean", ["get", "distance_overtaker_mean"]]], filter: ['!', ['to-boolean', ['get', 'distance_overtaker_mean']]],
layout: { layout: {
"line-cap": "round", 'line-cap': 'round',
"line-join": "round", 'line-join': 'round',
}, },
paint: { paint: {
"line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 12, 2, 17, 2], 'line-width': ['interpolate', ['exponential', 1.5], ['zoom'], 12, 2, 17, 2],
"line-color": "#ABC", 'line-color': '#ABC',
// "line-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1], // "line-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1],
"line-offset": [ 'line-offset': [
"interpolate", 'interpolate',
["exponential", 1.5], ['exponential', 1.5],
["zoom"], ['zoom'],
12, 12,
["get", "offset_direction"], ['get', 'offset_direction'],
19, 19,
["*", ["get", "offset_direction"], 8], ['*', ['get', 'offset_direction'], 8],
], ],
}, },
}; }
const getUntaggedRoadsLayer = (colorAttribute) => const getUntaggedRoadsLayer = (colorAttribute) =>
produce(untaggedRoadsLayer, (draft) => { produce(untaggedRoadsLayer, (draft) => {
draft.filter = ["!", isValidAttribute(colorAttribute)]; draft.filter = ['!', isValidAttribute(colorAttribute)]
}); })
const getRoadsLayer = (colorAttribute, maxCount) => const getRoadsLayer = (colorAttribute, maxCount) =>
produce(untaggedRoadsLayer, (draft) => { produce(untaggedRoadsLayer, (draft) => {
draft.id = "obs_roads_normal"; draft.id = 'obs_roads_normal'
draft.filter = isValidAttribute(colorAttribute); draft.filter = isValidAttribute(colorAttribute)
draft.minzoom = 10; draft.minzoom = 10
draft.paint["line-width"][6] = 6; // scale bigger on zoom draft.paint['line-width'][6] = 6 // scale bigger on zoom
draft.paint["line-color"] = colorAttribute.startsWith("distance_") draft.paint['line-color'] = colorAttribute.startsWith('distance_')
? colorByDistance(colorAttribute) ? colorByDistance(colorAttribute)
: colorAttribute.endsWith("_count") : colorAttribute.endsWith('_count')
? colorByCount(colorAttribute, maxCount) ? colorByCount(colorAttribute, maxCount)
: colorAttribute.endsWith("zone") : colorAttribute.endsWith('zone')
? borderByZone() ? borderByZone()
: "#DDD"; : '#DDD'
// draft.paint["line-opacity"][3] = 12; // draft.paint["line-opacity"][3] = 12;
// draft.paint["line-opacity"][5] = 13; // draft.paint["line-opacity"][5] = 13;
}); })
const getEventsLayer = () => ({ const getEventsLayer = () => ({
id: "obs_events", id: 'obs_events',
type: "circle", type: 'circle',
source: "obs", source: 'obs',
"source-layer": "obs_events", 'source-layer': 'obs_events',
paint: { paint: {
"circle-radius": ["interpolate", ["linear"], ["zoom"], 14, 3, 17, 8], 'circle-radius': ['interpolate', ['linear'], ['zoom'], 14, 3, 17, 8],
"circle-opacity": ["interpolate",["linear"],["zoom"],8,0.1,9,0.3,10,0.5,11,1], 'circle-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.1, 9, 0.3, 10, 0.5, 11, 1],
"circle-color": colorByDistance("distance_overtaker"), 'circle-color': colorByDistance('distance_overtaker'),
}, },
minzoom: 8, minzoom: 8,
}); })
const getEventsTextLayer = () => ({ const getEventsTextLayer = () => ({
id: "obs_events_text", id: 'obs_events_text',
type: "symbol", type: 'symbol',
minzoom: 18, minzoom: 18,
source: "obs", source: 'obs',
"source-layer": "obs_events", 'source-layer': 'obs_events',
layout: { layout: {
"text-field": [ 'text-field': [
"number-format", 'number-format',
["get", "distance_overtaker"], ['get', 'distance_overtaker'],
{ "min-fraction-digits": 2, "max-fraction-digits": 2 }, {'min-fraction-digits': 2, 'max-fraction-digits': 2},
], ],
"text-allow-overlap": true, 'text-allow-overlap': true,
"text-size": 14, 'text-size': 14,
"text-keep-upright": false, 'text-keep-upright': false,
"text-anchor": "left", 'text-anchor': 'left',
"text-radial-offset": 1, 'text-radial-offset': 1,
"text-rotate": ["-", 90, ["*", ["get", "course"], 180 / Math.PI]], 'text-rotate': ['-', 90, ['*', ['get', 'course'], 180 / Math.PI]],
"text-rotation-alignment": "map", 'text-rotation-alignment': 'map',
}, },
paint: { paint: {
"text-halo-color": "rgba(255, 255, 255, 1)", 'text-halo-color': 'rgba(255, 255, 255, 1)',
"text-halo-width": 1, 'text-halo-width': 1,
"text-opacity": ["interpolate", ["linear"], ["zoom"], 15, 0, 15.3, 1], 'text-opacity': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.3, 1],
}, },
}); })
interface RegionInfo { interface RegionInfo {
properties: { properties: {
admin_level: number; admin_level: number
name: string; name: string
overtaking_event_count: number; overtaking_event_count: number
}; }
} }
type Details = type Details = {type: 'road'; road: RoadInfoType} | {type: 'region'; region: RegionInfo}
| { type: "road"; road: RoadInfoType }
| { type: "region"; region: RegionInfo };
function MapPage({ login }) { function MapPage({login}) {
const { obsMapSource, banner } = useConfig() || {}; const {obsMapSource, banner} = useConfig() || {}
const [details, setDetails] = useState<null | Details>(null); const [details, setDetails] = useState<null | Details>(null)
const onCloseDetails = useCallback(() => setDetails(null), [setDetails]); const onCloseDetails = useCallback(() => setDetails(null), [setDetails])
const mapConfig = useMapConfig(); const mapConfig = useMapConfig()
const viewportRef = useRef(); const viewportRef = useRef()
const mapInfoPortal = useRef(); const mapInfoPortal = useRef()
const onViewportChange = useCallback( const onViewportChange = useCallback(
(viewport) => { (viewport) => {
viewportRef.current = viewport; viewportRef.current = viewport
}, },
[viewportRef] [viewportRef]
); )
const onClick = useCallback( const onClick = useCallback(
async (e) => { async (e) => {
// check if we clicked inside the mapInfoBox, if so, early exit // check if we clicked inside the mapInfoBox, if so, early exit
let node = e.target; let node = e.target
while (node) { while (node) {
if ( if ([styles.mapInfoBox, styles.mapToolbar].some((className) => node?.classList?.contains(className))) {
[styles.mapInfoBox, styles.mapToolbar].some((className) => return
node?.classList?.contains(className)
)
) {
return;
} }
node = node.parentNode; node = node.parentNode
} }
const { zoom } = viewportRef.current; const {zoom} = viewportRef.current
if (zoom < 10) { if (zoom < 10) {
const clickedRegion = e.features?.find( const clickedRegion = e.features?.find((f) => f.source === 'obs' && f.sourceLayer === 'obs_regions')
(f) => f.source === "obs" && f.sourceLayer === "obs_regions" setDetails(clickedRegion ? {type: 'region', region: clickedRegion} : null)
);
setDetails(
clickedRegion ? { type: "region", region: clickedRegion } : null
);
} else { } else {
const road = await api.get("/mapdetails/road", { const road = await api.get('/mapdetails/road', {
query: { query: {
longitude: e.lngLat[0], longitude: e.lngLat[0],
latitude: e.lngLat[1], latitude: e.lngLat[1],
radius: 100, radius: 100,
}, },
}); })
setDetails(road?.road ? { type: "road", road } : null); setDetails(road?.road ? {type: 'road', road} : null)
} }
}, },
[setDetails] [setDetails]
); )
const [layerSidebar, setLayerSidebar] = useState(true); const [layerSidebar, setLayerSidebar] = useState(true)
const { const {
obsRoads: { attribute, maxCount }, obsRoads: {attribute, maxCount},
} = mapConfig; } = mapConfig
const layers = []; const layers = []
const untaggedRoadsLayerCustom = useMemo( const untaggedRoadsLayerCustom = useMemo(() => getUntaggedRoadsLayer(attribute), [attribute])
() => getUntaggedRoadsLayer(attribute),
[attribute]
);
if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) { if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) {
layers.push(untaggedRoadsLayerCustom); layers.push(untaggedRoadsLayerCustom)
} }
const roadsLayer = useMemo( const roadsLayer = useMemo(() => getRoadsLayer(attribute, maxCount), [attribute, maxCount])
() => getRoadsLayer(attribute, maxCount),
[attribute, maxCount]
);
if (mapConfig.obsRoads.show) { if (mapConfig.obsRoads.show) {
layers.push(roadsLayer); layers.push(roadsLayer)
} }
const regionLayers = useMemo(() => getRegionLayers(), []); const regionLayers = useMemo(() => getRegionLayers(), [])
if (mapConfig.obsRegions.show) { if (mapConfig.obsRegions.show) {
layers.push(...regionLayers); layers.push(...regionLayers)
} }
const eventsLayer = useMemo(() => getEventsLayer(), []); const eventsLayer = useMemo(() => getEventsLayer(), [])
const eventsTextLayer = useMemo(() => getEventsTextLayer(), []); const eventsTextLayer = useMemo(() => getEventsTextLayer(), [])
if (mapConfig.obsEvents.show) { if (mapConfig.obsEvents.show) {
layers.push(eventsLayer); layers.push(eventsLayer)
layers.push(eventsTextLayer); layers.push(eventsTextLayer)
} }
const onToggleLayerSidebarButtonClick = useCallback( const onToggleLayerSidebarButtonClick = useCallback(
(e) => { (e) => {
e.stopPropagation(); e.stopPropagation()
e.preventDefault(); e.preventDefault()
console.log("toggl;e"); console.log('toggl;e')
setLayerSidebar((v) => !v); setLayerSidebar((v) => !v)
}, },
[setLayerSidebar] [setLayerSidebar]
); )
if (!obsMapSource) { if (!obsMapSource) {
return null; return null
} }
const tiles = obsMapSource?.tiles?.map((tileUrl: string) => { const tiles = obsMapSource?.tiles?.map((tileUrl: string) => {
const query = new URLSearchParams(); const query = new URLSearchParams()
if (login) { if (login) {
if (mapConfig.filters.currentUser) { if (mapConfig.filters.currentUser) {
query.append("user", login.id); query.append('user', login.id)
} }
if (mapConfig.filters.dateMode === "range") { if (mapConfig.filters.dateMode === 'range') {
if (mapConfig.filters.startDate) { if (mapConfig.filters.startDate) {
query.append("start", mapConfig.filters.startDate); query.append('start', mapConfig.filters.startDate)
} }
if (mapConfig.filters.endDate) { if (mapConfig.filters.endDate) {
query.append("end", mapConfig.filters.endDate); query.append('end', mapConfig.filters.endDate)
} }
} else if (mapConfig.filters.dateMode === "threshold") { } else if (mapConfig.filters.dateMode === 'threshold') {
if (mapConfig.filters.startDate) { if (mapConfig.filters.startDate) {
query.append( query.append(mapConfig.filters.thresholdAfter ? 'start' : 'end', mapConfig.filters.startDate)
mapConfig.filters.thresholdAfter ? "start" : "end",
mapConfig.filters.startDate
);
} }
} }
} }
const queryString = String(query); const queryString = String(query)
return tileUrl + (queryString ? "?" : "") + queryString; return tileUrl + (queryString ? '?' : '') + queryString
}); })
const hasFilters: boolean = const hasFilters: boolean = login && (mapConfig.filters.currentUser || mapConfig.filters.dateMode !== 'none')
login &&
(mapConfig.filters.currentUser || mapConfig.filters.dateMode !== "none");
return ( return (
<Page fullScreen title="Map"> <Page fullScreen title="Map">
<div <div className={classNames(styles.mapContainer, banner ? styles.hasBanner : null)} ref={mapInfoPortal}>
className={classNames(
styles.mapContainer,
banner ? styles.hasBanner : null
)}
ref={mapInfoPortal}
>
{layerSidebar && ( {layerSidebar && (
<div className={styles.mapSidebar}> <div className={styles.mapSidebar}>
<LayerSidebar /> <LayerSidebar />
</div> </div>
)} )}
<div className={styles.map}> <div className={styles.map}>
<Map <Map viewportFromUrl onClick={onClick} hasToolbar onViewportChange={onViewportChange}>
viewportFromUrl
onClick={onClick}
hasToolbar
onViewportChange={onViewportChange}
>
<div className={styles.mapToolbar}> <div className={styles.mapToolbar}>
<Button <Button primary icon="bars" active={layerSidebar} onClick={onToggleLayerSidebarButtonClick} />
primary
icon="bars"
active={layerSidebar}
onClick={onToggleLayerSidebarButtonClick}
/>
</div> </div>
<Source id="obs" {...obsMapSource} tiles={tiles}> <Source id="obs" {...obsMapSource} tiles={tiles}>
{layers.map((layer) => ( {layers.map((layer) => (
@ -298,27 +255,23 @@ function MapPage({ login }) {
))} ))}
</Source> </Source>
{details?.type === "road" && details?.road?.road && ( {details?.type === 'road' && details?.road?.road && (
<RoadInfo <RoadInfo
roadInfo={details.road} roadInfo={details.road}
mapInfoPortal={mapInfoPortal.current} mapInfoPortal={mapInfoPortal.current}
onClose={onCloseDetails} onClose={onCloseDetails}
{...{ hasFilters }} {...{hasFilters}}
/> />
)} )}
{details?.type === "region" && details?.region && ( {details?.type === 'region' && details?.region && (
<RegionInfo <RegionInfo region={details.region} mapInfoPortal={mapInfoPortal.current} onClose={onCloseDetails} />
region={details.region}
mapInfoPortal={mapInfoPortal.current}
onClose={onCloseDetails}
/>
)} )}
</Map> </Map>
</div> </div>
</div> </div>
</Page> </Page>
); )
} }
export default connect((state) => ({ login: state.login }))(MapPage); export default connect((state) => ({login: state.login}))(MapPage)

View file

@ -1,5 +1,5 @@
import React, { useCallback, useMemo, useState } from "react"; import React, {useCallback, useMemo, useState} from 'react'
import { connect } from "react-redux"; import {connect} from 'react-redux'
import { import {
Accordion, Accordion,
Button, Button,
@ -14,74 +14,65 @@ import {
SemanticCOLORS, SemanticCOLORS,
SemanticICONS, SemanticICONS,
Table, Table,
} from "semantic-ui-react"; } from 'semantic-ui-react'
import { useObservable } from "rxjs-hooks"; import {useObservable} from 'rxjs-hooks'
import { Link } from "react-router-dom"; import {Link} from 'react-router-dom'
import { of, from, concat, BehaviorSubject, combineLatest } from "rxjs"; import {of, from, concat, BehaviorSubject, combineLatest} from 'rxjs'
import { map, switchMap, distinctUntilChanged } from "rxjs/operators"; import {map, switchMap, distinctUntilChanged} from 'rxjs/operators'
import _ from "lodash"; import _ from 'lodash'
import { useTranslation } from "react-i18next"; import {useTranslation} from 'react-i18next'
import type { ProcessingStatus, Track, UserDevice } from "types"; import type {ProcessingStatus, Track, UserDevice} from 'types'
import { Page, FormattedDate, Visibility } from "components"; import {Page, FormattedDate, Visibility} from 'components'
import api from "api"; import api from 'api'
import { useCallbackRef, formatDistance, formatDuration } from "utils"; import {useCallbackRef, formatDistance, formatDuration} from 'utils'
import download from "downloadjs"; import download from 'downloadjs'
const COLOR_BY_STATUS: Record<ProcessingStatus, SemanticCOLORS> = { const COLOR_BY_STATUS: Record<ProcessingStatus, SemanticCOLORS> = {
error: "red", error: 'red',
complete: "green", complete: 'green',
created: "grey", created: 'grey',
queued: "orange", queued: 'orange',
processing: "orange", processing: 'orange',
}; }
const ICON_BY_STATUS: Record<ProcessingStatus, SemanticICONS> = { const ICON_BY_STATUS: Record<ProcessingStatus, SemanticICONS> = {
error: "warning sign", error: 'warning sign',
complete: "check circle outline", complete: 'check circle outline',
created: "bolt", created: 'bolt',
queued: "bolt", queued: 'bolt',
processing: "bolt", processing: 'bolt',
}; }
function ProcessingStatusLabel({ status }: { status: ProcessingStatus }) { function ProcessingStatusLabel({status}: {status: ProcessingStatus}) {
const { t } = useTranslation(); const {t} = useTranslation()
return ( return (
<span title={t(`TracksPage.processing.${status}`)}> <span title={t(`TracksPage.processing.${status}`)}>
<Icon color={COLOR_BY_STATUS[status]} name={ICON_BY_STATUS[status]} /> <Icon color={COLOR_BY_STATUS[status]} name={ICON_BY_STATUS[status]} />
</span> </span>
); )
} }
function SortableHeader({ function SortableHeader({children, setOrderBy, orderBy, reversed, setReversed, name, ...props}) {
children,
setOrderBy,
orderBy,
reversed,
setReversed,
name,
...props
}) {
const toggleSort = (e) => { const toggleSort = (e) => {
e.preventDefault(); e.preventDefault()
e.stopPropagation(); e.stopPropagation()
if (orderBy === name) { if (orderBy === name) {
if (!reversed) { if (!reversed) {
setReversed(true); setReversed(true)
} else { } else {
setReversed(false); setReversed(false)
setOrderBy(null); setOrderBy(null)
} }
} else { } else {
setReversed(false); setReversed(false)
setOrderBy(name); setOrderBy(name)
} }
}; }
let icon = let icon = orderBy === name ? (reversed ? 'sort descending' : 'sort ascending') : null
orderBy === name ? (reversed ? "sort descending" : "sort ascending") : null;
return ( return (
<Table.HeaderCell {...props}> <Table.HeaderCell {...props}>
@ -90,22 +81,22 @@ function SortableHeader({
<Icon name={icon} /> <Icon name={icon} />
</div> </div>
</Table.HeaderCell> </Table.HeaderCell>
); )
} }
type Filters = { type Filters = {
userDeviceId?: null | number; userDeviceId?: null | number
visibility?: null | boolean; visibility?: null | boolean
}; }
function TrackFilters({ function TrackFilters({
filters, filters,
setFilters, setFilters,
deviceNames, deviceNames,
}: { }: {
filters: Filters; filters: Filters
setFilters: (f: Filters) => void; setFilters: (f: Filters) => void
deviceNames: null | Record<number, string>; deviceNames: null | Record<number, string>
}) { }) {
return ( return (
<List horizontal> <List horizontal>
@ -115,19 +106,15 @@ function TrackFilters({
selection selection
clearable clearable
options={[ options={[
{ value: 0, key: "__none__", text: "All my devices" }, {value: 0, key: '__none__', text: 'All my devices'},
..._.sortBy(Object.entries(deviceNames ?? {}), 1).map( ..._.sortBy(Object.entries(deviceNames ?? {}), 1).map(([deviceId, deviceName]: [string, string]) => ({
([deviceId, deviceName]: [string, string]) => ({ value: Number(deviceId),
value: Number(deviceId), key: deviceId,
key: deviceId, text: deviceName,
text: deviceName, })),
})
),
]} ]}
value={filters?.userDeviceId ?? 0} value={filters?.userDeviceId ?? 0}
onChange={(_e, { value }) => onChange={(_e, {value}) => setFilters({...filters, userDeviceId: (value as number) || null})}
setFilters({ ...filters, userDeviceId: (value as number) || null })
}
/> />
</List.Item> </List.Item>
@ -137,54 +124,48 @@ function TrackFilters({
selection selection
clearable clearable
options={[ options={[
{ value: "none", key: "any", text: "Any" }, {value: 'none', key: 'any', text: 'Any'},
{ value: true, key: "public", text: "Public" }, {value: true, key: 'public', text: 'Public'},
{ value: false, key: "private", text: "Private" }, {value: false, key: 'private', text: 'Private'},
]} ]}
value={filters?.visibility ?? "none"} value={filters?.visibility ?? 'none'}
onChange={(_e, { value }) => onChange={(_e, {value}) =>
setFilters({ setFilters({
...filters, ...filters,
visibility: value === "none" ? null : (value as boolean), visibility: value === 'none' ? null : (value as boolean),
}) })
} }
/> />
</List.Item> </List.Item>
</List> </List>
); )
} }
function TracksTable({ title }) { function TracksTable({title}) {
const [orderBy, setOrderBy] = useState("recordedAt"); const [orderBy, setOrderBy] = useState('recordedAt')
const [reversed, setReversed] = useState(true); const [reversed, setReversed] = useState(true)
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false)
const [filters, setFilters] = useState<Filters>({}); const [filters, setFilters] = useState<Filters>({})
const [selectedTracks, setSelectedTracks] = useState<Record<string, boolean>>( const [selectedTracks, setSelectedTracks] = useState<Record<string, boolean>>({})
{}
);
const toggleTrackSelection = useCallbackRef( const toggleTrackSelection = useCallbackRef((slug: string, selected?: boolean) => {
(slug: string, selected?: boolean) => { const newSelected = selected ?? !selectedTracks[slug]
const newSelected = selected ?? !selectedTracks[slug]; setSelectedTracks(_.pickBy({...selectedTracks, [slug]: newSelected}, _.identity))
setSelectedTracks( })
_.pickBy({ ...selectedTracks, [slug]: newSelected }, _.identity)
);
}
);
const query = _.pickBy( const query = _.pickBy(
{ {
limit: 1000, limit: 1000,
offset: 0, offset: 0,
order_by: orderBy, order_by: orderBy,
reversed: reversed ? "true" : "false", reversed: reversed ? 'true' : 'false',
user_device_id: filters?.userDeviceId, user_device_id: filters?.userDeviceId,
public: filters?.visibility, public: filters?.visibility,
}, },
(x) => x != null (x) => x != null
); )
const forceUpdate$ = useMemo(() => new BehaviorSubject(null), []); const forceUpdate$ = useMemo(() => new BehaviorSubject(null), [])
const tracks: Track[] | null = useObservable( const tracks: Track[] | null = useObservable(
(_$, inputs$) => (_$, inputs$) =>
combineLatest([ combineLatest([
@ -193,125 +174,91 @@ function TracksTable({ title }) {
distinctUntilChanged(_.isEqual) distinctUntilChanged(_.isEqual)
), ),
forceUpdate$, forceUpdate$,
]).pipe( ]).pipe(switchMap(([query]) => concat(of(null), from(api.get('/tracks/feed', {query}).then((r) => r.tracks))))),
switchMap(([query]) =>
concat(
of(null),
from(api.get("/tracks/feed", { query }).then((r) => r.tracks))
)
)
),
null, null,
[query] [query]
); )
const deviceNames: null | Record<number, string> = useObservable(() => const deviceNames: null | Record<number, string> = useObservable(() =>
from(api.get("/user/devices")).pipe( from(api.get('/user/devices')).pipe(
map((response: UserDevice[]) => map((response: UserDevice[]) =>
Object.fromEntries( Object.fromEntries(response.map((device) => [device.id, device.displayName || device.identifier]))
response.map((device) => [
device.id,
device.displayName || device.identifier,
])
)
) )
) )
); )
const { t } = useTranslation(); const {t} = useTranslation()
const p = { orderBy, setOrderBy, reversed, setReversed }; const p = {orderBy, setOrderBy, reversed, setReversed}
const selectedCount = Object.keys(selectedTracks).length; const selectedCount = Object.keys(selectedTracks).length
const noneSelected = selectedCount === 0; const noneSelected = selectedCount === 0
const allSelected = selectedCount === tracks?.length; const allSelected = selectedCount === tracks?.length
const selectAll = () => { const selectAll = () => {
setSelectedTracks( setSelectedTracks(Object.fromEntries(tracks?.map((t) => [t.slug, true]) ?? []))
Object.fromEntries(tracks?.map((t) => [t.slug, true]) ?? []) }
);
};
const selectNone = () => { const selectNone = () => {
setSelectedTracks({}); setSelectedTracks({})
}; }
const bulkAction = async (action: string) => { const bulkAction = async (action: string) => {
const response = await api.post("/tracks/bulk", { const response = await api.post('/tracks/bulk', {
body: { body: {
action, action,
tracks: Object.keys(selectedTracks), tracks: Object.keys(selectedTracks),
}, },
returnResponse: true, returnResponse: true,
}); })
if (action === "download") { if (action === 'download') {
const contentType = const contentType = response.headers.get('content-type') ?? 'application/x-gtar'
response.headers.get("content-type") ?? "application/x-gtar";
const filename = const filename = response.headers.get('content-disposition')?.match(/filename="([^"]+)"/)?.[1] ?? 'tracks.tar.bz2'
response.headers download(await response.blob(), filename, contentType)
.get("content-disposition")
?.match(/filename="([^"]+)"/)?.[1] ?? "tracks.tar.bz2";
download(await response.blob(), filename, contentType);
} }
setShowBulkDelete(false); setShowBulkDelete(false)
setSelectedTracks({}); setSelectedTracks({})
forceUpdate$.next(null); forceUpdate$.next(null)
}; }
const [showBulkDelete, setShowBulkDelete] = useState(false); const [showBulkDelete, setShowBulkDelete] = useState(false)
return ( return (
<> <>
<div style={{ float: "right" }}> <div style={{float: 'right'}}>
<Dropdown disabled={noneSelected} text="Bulk actions" floating button> <Dropdown disabled={noneSelected} text="Bulk actions" floating button>
<Dropdown.Menu> <Dropdown.Menu>
<Dropdown.Header> <Dropdown.Header>Selection of {selectedCount} tracks</Dropdown.Header>
Selection of {selectedCount} tracks <Dropdown.Item onClick={() => bulkAction('makePrivate')}>Make private</Dropdown.Item>
</Dropdown.Header> <Dropdown.Item onClick={() => bulkAction('makePublic')}>Make public</Dropdown.Item>
<Dropdown.Item onClick={() => bulkAction("makePrivate")}> <Dropdown.Item onClick={() => bulkAction('reprocess')}>Reprocess</Dropdown.Item>
Make private <Dropdown.Item onClick={() => bulkAction('download')}>Download</Dropdown.Item>
</Dropdown.Item> <Dropdown.Item onClick={() => setShowBulkDelete(true)}>Delete</Dropdown.Item>
<Dropdown.Item onClick={() => bulkAction("makePublic")}>
Make public
</Dropdown.Item>
<Dropdown.Item onClick={() => bulkAction("reprocess")}>
Reprocess
</Dropdown.Item>
<Dropdown.Item onClick={() => bulkAction("download")}>
Download
</Dropdown.Item>
<Dropdown.Item onClick={() => setShowBulkDelete(true)}>
Delete
</Dropdown.Item>
</Dropdown.Menu> </Dropdown.Menu>
</Dropdown> </Dropdown>
<Link component={UploadButton} to="/upload" /> <Link component={UploadButton} to="/upload" />
</div> </div>
<Header as="h1">{title}</Header> <Header as="h1">{title}</Header>
<div style={{ clear: "both" }}> <div style={{clear: 'both'}}>
<Loader content={t("general.loading")} active={tracks == null} /> <Loader content={t('general.loading')} active={tracks == null} />
<Accordion> <Accordion>
<Accordion.Title <Accordion.Title active={showFilters} index={0} onClick={() => setShowFilters(!showFilters)}>
active={showFilters}
index={0}
onClick={() => setShowFilters(!showFilters)}
>
<Icon name="dropdown" /> <Icon name="dropdown" />
Filters Filters
</Accordion.Title> </Accordion.Title>
<Accordion.Content active={showFilters}> <Accordion.Content active={showFilters}>
<TrackFilters {...{ filters, setFilters, deviceNames }} /> <TrackFilters {...{filters, setFilters, deviceNames}} />
</Accordion.Content> </Accordion.Content>
</Accordion> </Accordion>
<Confirm <Confirm
open={showBulkDelete} open={showBulkDelete}
onCancel={() => setShowBulkDelete(false)} onCancel={() => setShowBulkDelete(false)}
onConfirm={() => bulkAction("delete")} onConfirm={() => bulkAction('delete')}
content={`Are you sure you want to delete ${selectedCount} tracks?`} content={`Are you sure you want to delete ${selectedCount} tracks?`}
confirmButton={t("general.delete")} confirmButton={t('general.delete')}
cancelButton={t("general.cancel")} cancelButton={t('general.cancel')}
/> />
<Table compact> <Table compact>
@ -356,11 +303,9 @@ function TracksTable({ title }) {
/> />
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
{track.processingStatus == null ? null : ( {track.processingStatus == null ? null : <ProcessingStatusLabel status={track.processingStatus} />}
<ProcessingStatusLabel status={track.processingStatus} />
)}
<Item.Header as={Link} to={`/tracks/${track.slug}`}> <Item.Header as={Link} to={`/tracks/${track.slug}`}>
{track.title || t("general.unnamedTrack")} {track.title || t('general.unnamedTrack')}
</Item.Header> </Item.Header>
</Table.Cell> </Table.Cell>
@ -368,62 +313,48 @@ function TracksTable({ title }) {
<FormattedDate date={track.recordedAt} /> <FormattedDate date={track.recordedAt} />
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>{track.public == null ? null : <Visibility public={track.public} />}</Table.Cell>
{track.public == null ? null : (
<Visibility public={track.public} />
)}
</Table.Cell>
<Table.Cell textAlign="right"> <Table.Cell textAlign="right">{formatDistance(track.length)}</Table.Cell>
{formatDistance(track.length)}
</Table.Cell>
<Table.Cell textAlign="right"> <Table.Cell textAlign="right">{formatDuration(track.duration)}</Table.Cell>
{formatDuration(track.duration)}
</Table.Cell>
<Table.Cell> <Table.Cell>{track.userDeviceId ? deviceNames?.[track.userDeviceId] ?? '...' : null}</Table.Cell>
{track.userDeviceId
? deviceNames?.[track.userDeviceId] ?? "..."
: null}
</Table.Cell>
</Table.Row> </Table.Row>
))} ))}
</Table.Body> </Table.Body>
</Table> </Table>
</div> </div>
</> </>
); )
} }
function UploadButton({ navigate, ...props }) { function UploadButton({navigate, ...props}) {
const { t } = useTranslation(); const {t} = useTranslation()
const onClick = useCallback( const onClick = useCallback(
(e) => { (e) => {
e.preventDefault(); e.preventDefault()
navigate(); navigate()
}, },
[navigate] [navigate]
); )
return ( return (
<Button onClick={onClick} {...props} color="green"> <Button onClick={onClick} {...props} color="green">
{t("TracksPage.upload")} {t('TracksPage.upload')}
</Button> </Button>
); )
} }
const MyTracksPage = connect((state) => ({ login: (state as any).login }))( const MyTracksPage = connect((state) => ({login: (state as any).login}))(function MyTracksPage({login}) {
function MyTracksPage({ login }) { const {t} = useTranslation()
const { t } = useTranslation();
const title = t("TracksPage.titleUser"); const title = t('TracksPage.titleUser')
return ( return (
<Page title={title}> <Page title={title}>
<TracksTable {...{ title }} /> <TracksTable {...{title}} />
</Page> </Page>
); )
} })
);
export default MyTracksPage; export default MyTracksPage

View file

@ -1,12 +1,12 @@
import React from 'react' import React from 'react'
import {Button, Header} from 'semantic-ui-react' import {Button, Header} from 'semantic-ui-react'
import {useHistory} from 'react-router-dom' import {useHistory} from 'react-router-dom'
import { useTranslation } from "react-i18next"; import {useTranslation} from 'react-i18next'
import {Page} from '../components' import {Page} from '../components'
export default function NotFoundPage() { export default function NotFoundPage() {
const { t } = useTranslation(); const {t} = useTranslation()
const history = useHistory() const history = useHistory()
return ( return (
<Page title={t('NotFoundPage.title')}> <Page title={t('NotFoundPage.title')}>

View file

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

View file

@ -27,7 +27,7 @@ function EditField({value, onEdit}) {
}, [setEditing, setTempValue, value, cancelTimeout]) }, [setEditing, setTempValue, value, cancelTimeout])
const confirm = useCallback(() => { const confirm = useCallback(() => {
console.log("confirmed") console.log('confirmed')
cancelTimeout() cancelTimeout()
setEditing(false) setEditing(false)
onEdit(tempValue) onEdit(tempValue)

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import React from "react"; import React from 'react'
import _ from "lodash"; import _ from 'lodash'
import { connect } from "react-redux"; import {connect} from 'react-redux'
import { import {
Divider, Divider,
Message, Message,
@ -14,31 +14,31 @@ import {
TextArea, TextArea,
Checkbox, Checkbox,
Header, Header,
} from "semantic-ui-react"; } from 'semantic-ui-react'
import { useHistory, useParams, Link } from "react-router-dom"; import {useHistory, useParams, Link} from 'react-router-dom'
import { concat, of, from } from "rxjs"; import {concat, of, from} from 'rxjs'
import { pluck, distinctUntilChanged, map, switchMap } from "rxjs/operators"; import {pluck, distinctUntilChanged, map, switchMap} from 'rxjs/operators'
import { useObservable } from "rxjs-hooks"; import {useObservable} from 'rxjs-hooks'
import { findInput } from "utils"; import {findInput} from 'utils'
import { useForm, Controller } from "react-hook-form"; import {useForm, Controller} from 'react-hook-form'
import { useTranslation, Trans as Translate } from "react-i18next"; import {useTranslation, Trans as Translate} from 'react-i18next'
import Markdown from "react-markdown"; import Markdown from 'react-markdown'
import api from "api"; import api from 'api'
import { Page, FileUploadField } from "components"; import {Page, FileUploadField} from 'components'
import type { Track } from "types"; import type {Track} from 'types'
import { FileUploadStatus } from "pages/UploadPage"; import {FileUploadStatus} from 'pages/UploadPage'
function ReplaceTrackData({ slug }) { function ReplaceTrackData({slug}) {
const { t } = useTranslation(); const {t} = useTranslation()
const [file, setFile] = React.useState(null); const [file, setFile] = React.useState(null)
const [result, setResult] = React.useState(null); const [result, setResult] = React.useState(null)
const onComplete = React.useCallback((_id, r) => setResult(r), [setResult]); const onComplete = React.useCallback((_id, r) => setResult(r), [setResult])
return ( return (
<> <>
<Header as="h2">{t("TrackEditor.replaceTrackData")}</Header> <Header as="h2">{t('TrackEditor.replaceTrackData')}</Header>
{!file ? ( {!file ? (
<FileUploadField onSelect={setFile} /> <FileUploadField onSelect={setFile} />
) : result ? ( ) : result ? (
@ -48,167 +48,146 @@ function ReplaceTrackData({ slug }) {
</Translate> </Translate>
</Message> </Message>
) : ( ) : (
<FileUploadStatus {...{ file, onComplete, slug }} /> <FileUploadStatus {...{file, onComplete, slug}} />
)} )}
</> </>
); )
} }
const TrackEditor = connect((state) => ({ login: state.login }))( const TrackEditor = connect((state) => ({login: state.login}))(function TrackEditor({login}) {
function TrackEditor({ login }) { const {t} = useTranslation()
const { t } = useTranslation(); const [busy, setBusy] = React.useState(false)
const [busy, setBusy] = React.useState(false); const {register, control, handleSubmit} = useForm()
const { register, control, handleSubmit } = useForm(); const {slug} = useParams()
const { slug } = useParams(); const history = useHistory()
const history = useHistory();
const track: null | Track = useObservable( const track: null | Track = useObservable(
(_$, args$) => { (_$, args$) => {
const slug$ = args$.pipe(pluck(0), distinctUntilChanged()); const slug$ = args$.pipe(pluck(0), distinctUntilChanged())
return slug$.pipe( return slug$.pipe(
map((slug) => `/tracks/${slug}`), map((slug) => `/tracks/${slug}`),
switchMap((url) => concat(of(null), from(api.get(url)))), switchMap((url) => concat(of(null), from(api.get(url)))),
pluck("track") pluck('track')
); )
}, },
null, null,
[slug] [slug]
); )
const loading = busy || track == null; const loading = busy || track == null
const isAuthor = login?.id === track?.author?.id; const isAuthor = login?.id === track?.author?.id
// Navigate to track detials if we are not the author // Navigate to track detials if we are not the author
React.useEffect(() => { React.useEffect(() => {
if (!login || (track && !isAuthor)) { if (!login || (track && !isAuthor)) {
history.replace(`/tracks/${slug}`); history.replace(`/tracks/${slug}`)
} }
}, [slug, login, track, isAuthor, history]); }, [slug, login, track, isAuthor, history])
const onSubmit = React.useMemo( const onSubmit = React.useMemo(
() => () =>
handleSubmit(async (values) => { handleSubmit(async (values) => {
setBusy(true); setBusy(true)
try { try {
await api.put(`/tracks/${slug}`, { await api.put(`/tracks/${slug}`, {
body: { body: {
track: _.pickBy(values, (v) => typeof v !== "undefined"), track: _.pickBy(values, (v) => typeof v !== 'undefined'),
}, },
}); })
history.push(`/tracks/${slug}`); history.push(`/tracks/${slug}`)
} finally { } finally {
setBusy(false); setBusy(false)
} }
}), }),
[slug, handleSubmit, history] [slug, handleSubmit, history]
); )
const [confirmDelete, setConfirmDelete] = React.useState(false); const [confirmDelete, setConfirmDelete] = React.useState(false)
const onDelete = React.useCallback(async () => { const onDelete = React.useCallback(async () => {
setBusy(true); setBusy(true)
try { try {
await api.delete(`/tracks/${slug}`); await api.delete(`/tracks/${slug}`)
history.push("/tracks"); history.push('/tracks')
} finally { } finally {
setConfirmDelete(false); setConfirmDelete(false)
setBusy(false); setBusy(false)
} }
}, [setBusy, setConfirmDelete, slug, history]); }, [setBusy, setConfirmDelete, slug, history])
const trackTitle: string = track?.title || t("general.unnamedTrack"); const trackTitle: string = track?.title || t('general.unnamedTrack')
const title = t("TrackEditor.title", { trackTitle }); const title = t('TrackEditor.title', {trackTitle})
return ( return (
<Page title={title}> <Page title={title}>
<Grid centered relaxed divided stackable> <Grid centered relaxed divided stackable>
<Grid.Row> <Grid.Row>
<Grid.Column width={10}> <Grid.Column width={10}>
<Header as="h2">{title}</Header> <Header as="h2">{title}</Header>
<Form loading={loading} key={track?.slug} onSubmit={onSubmit}> <Form loading={loading} key={track?.slug} onSubmit={onSubmit}>
<Ref innerRef={findInput(register)}> <Ref innerRef={findInput(register)}>
<Form.Input <Form.Input label="Title" name="title" defaultValue={track?.title} style={{fontSize: '120%'}} />
label="Title" </Ref>
name="title"
defaultValue={track?.title} <Form.Field>
style={{ fontSize: "120%" }} <label>{t('TrackEditor.description.label')}</label>
/> <Ref innerRef={register}>
<TextArea name="description" rows={4} defaultValue={track?.description} />
</Ref> </Ref>
</Form.Field>
<Form.Field> <Form.Field>
<label>{t("TrackEditor.description.label")}</label> <label>
<Ref innerRef={register}> {t('TrackEditor.visibility.label')}
<TextArea <Popup
name="description" wide="very"
rows={4} content={<Markdown>{t('TrackEditor.visibility.description')}</Markdown>}
defaultValue={track?.description} trigger={<Icon name="warning sign" style={{marginLeft: 8}} color="orange" />}
/>
</Ref>
</Form.Field>
<Form.Field>
<label>
{t("TrackEditor.visibility.label")}
<Popup
wide="very"
content={
<Markdown>
{t("TrackEditor.visibility.description")}
</Markdown>
}
trigger={
<Icon
name="warning sign"
style={{ marginLeft: 8 }}
color="orange"
/>
}
/>
</label>
<Controller
name="public"
control={control}
defaultValue={track?.public}
render={(props) => (
<Checkbox
name="public"
label={t("TrackEditor.visibility.checkboxLabel")}
checked={props.value}
onChange={(_, { checked }) => props.onChange(checked)}
/>
)}
/> />
</Form.Field> </label>
<Button type="submit">{t("general.save")}</Button>
</Form>
</Grid.Column>
<Grid.Column width={6}>
<ReplaceTrackData slug={slug} />
<Divider /> <Controller
name="public"
control={control}
defaultValue={track?.public}
render={(props) => (
<Checkbox
name="public"
label={t('TrackEditor.visibility.checkboxLabel')}
checked={props.value}
onChange={(_, {checked}) => props.onChange(checked)}
/>
)}
/>
</Form.Field>
<Button type="submit">{t('general.save')}</Button>
</Form>
</Grid.Column>
<Grid.Column width={6}>
<ReplaceTrackData slug={slug} />
<Header as="h2">{t("TrackEditor.dangerZone.title")}</Header> <Divider />
<Markdown>{t("TrackEditor.dangerZone.description")}</Markdown>
<Button color="red" onClick={() => setConfirmDelete(true)}> <Header as="h2">{t('TrackEditor.dangerZone.title')}</Header>
{t("general.delete")} <Markdown>{t('TrackEditor.dangerZone.description')}</Markdown>
</Button>
<Confirm
open={confirmDelete}
onCancel={() => setConfirmDelete(false)}
onConfirm={onDelete}
content={t("TrackEditor.dangerZone.confirmDelete")}
confirmButton={t("general.delete")}
cancelButton={t("general.cancel")}
/>
</Grid.Column>
</Grid.Row>
</Grid>
</Page>
);
}
);
export default TrackEditor; <Button color="red" onClick={() => setConfirmDelete(true)}>
{t('general.delete')}
</Button>
<Confirm
open={confirmDelete}
onCancel={() => setConfirmDelete(false)}
onConfirm={onDelete}
content={t('TrackEditor.dangerZone.confirmDelete')}
confirmButton={t('general.delete')}
cancelButton={t('general.cancel')}
/>
</Grid.Column>
</Grid.Row>
</Grid>
</Page>
)
})
export default TrackEditor

View file

@ -1,10 +1,10 @@
import React from 'react' import React from 'react'
import {Link} from 'react-router-dom' import {Link} from 'react-router-dom'
import {Icon, Popup, Button, Dropdown} from 'semantic-ui-react' import {Icon, Popup, Button, Dropdown} from 'semantic-ui-react'
import { useTranslation } from "react-i18next"; import {useTranslation} from 'react-i18next'
export default function TrackActions({slug, isAuthor, onDownload}) { export default function TrackActions({slug, isAuthor, onDownload}) {
const { t } = useTranslation(); const {t} = useTranslation()
return ( return (
<> <>
@ -25,7 +25,11 @@ export default function TrackActions({slug, isAuthor, onDownload}) {
<Dropdown text={t('TrackPage.actions.download')} button> <Dropdown text={t('TrackPage.actions.download')} button>
<Dropdown.Menu> <Dropdown.Menu>
<Dropdown.Item text={t('TrackPage.actions.original')}onClick={() => onDownload('original.csv')} disabled={!isAuthor} /> <Dropdown.Item
text={t('TrackPage.actions.original')}
onClick={() => onDownload('original.csv')}
disabled={!isAuthor}
/>
<Dropdown.Item text={t('TrackPage.actions.gpx')} onClick={() => onDownload('track.gpx')} /> <Dropdown.Item text={t('TrackPage.actions.gpx')} onClick={() => onDownload('track.gpx')} />
</Dropdown.Menu> </Dropdown.Menu>
</Dropdown> </Dropdown>

View file

@ -1,57 +1,34 @@
import React from "react"; import React from 'react'
import { import {Message, Segment, Form, Button, Loader, Header, Comment} from 'semantic-ui-react'
Message, import Markdown from 'react-markdown'
Segment, import {useTranslation} from 'react-i18next'
Form,
Button,
Loader,
Header,
Comment,
} from "semantic-ui-react";
import Markdown from "react-markdown";
import { useTranslation } from "react-i18next";
import { Avatar, FormattedDate } from "components"; import {Avatar, FormattedDate} from 'components'
function CommentForm({ onSubmit }) { function CommentForm({onSubmit}) {
const { t } = useTranslation(); const {t} = useTranslation()
const [body, setBody] = React.useState(""); const [body, setBody] = React.useState('')
const onSubmitComment = React.useCallback(() => { const onSubmitComment = React.useCallback(() => {
onSubmit({ body }); onSubmit({body})
setBody(""); setBody('')
}, [onSubmit, body]); }, [onSubmit, body])
return ( return (
<Form reply onSubmit={onSubmitComment}> <Form reply onSubmit={onSubmitComment}>
<Form.TextArea <Form.TextArea rows={4} value={body} onChange={(e) => setBody(e.target.value)} />
rows={4} <Button content={t('TrackPage.comments.post')} labelPosition="left" icon="edit" primary />
value={body}
onChange={(e) => setBody(e.target.value)}
/>
<Button
content={t("TrackPage.comments.post")}
labelPosition="left"
icon="edit"
primary
/>
</Form> </Form>
); )
} }
export default function TrackComments({ export default function TrackComments({comments, onSubmit, onDelete, login, hideLoader}) {
comments, const {t} = useTranslation()
onSubmit,
onDelete,
login,
hideLoader,
}) {
const { t } = useTranslation();
return ( return (
<> <>
<Comment.Group> <Comment.Group>
<Header as="h2" dividing> <Header as="h2" dividing>
{t("TrackPage.comments.title")} {t('TrackPage.comments.title')}
</Header> </Header>
<Loader active={!hideLoader && comments == null} inline /> <Loader active={!hideLoader && comments == null} inline />
@ -60,9 +37,7 @@ export default function TrackComments({
<Comment key={comment.id}> <Comment key={comment.id}>
<Avatar user={comment.author} /> <Avatar user={comment.author} />
<Comment.Content> <Comment.Content>
<Comment.Author as="a"> <Comment.Author as="a">{comment.author.displayName}</Comment.Author>
{comment.author.displayName}
</Comment.Author>
<Comment.Metadata> <Comment.Metadata>
<div> <div>
<FormattedDate date={comment.createdAt} relative /> <FormattedDate date={comment.createdAt} relative />
@ -75,11 +50,11 @@ export default function TrackComments({
<Comment.Actions> <Comment.Actions>
<Comment.Action <Comment.Action
onClick={(e) => { onClick={(e) => {
onDelete(comment.id); onDelete(comment.id)
e.preventDefault(); e.preventDefault()
}} }}
> >
{t("general.delete")} {t('general.delete')}
</Comment.Action> </Comment.Action>
</Comment.Actions> </Comment.Actions>
)} )}
@ -87,12 +62,10 @@ export default function TrackComments({
</Comment> </Comment>
))} ))}
{comments != null && !comments.length && ( {comments != null && !comments.length && <Message>{t('TrackPage.comments.empty')}</Message>}
<Message>{t("TrackPage.comments.empty")}</Message>
)}
{login && comments != null && <CommentForm onSubmit={onSubmit} />} {login && comments != null && <CommentForm onSubmit={onSubmit} />}
</Comment.Group> </Comment.Group>
</> </>
); )
} }

View file

@ -1,66 +1,38 @@
import React from "react"; import React from 'react'
import _ from "lodash"; import _ from 'lodash'
import { List, Header, Grid } from "semantic-ui-react"; import {List, Header, Grid} from 'semantic-ui-react'
import { Duration } from "luxon"; 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"; import {formatDistance, formatDuration} from 'utils'
export default function TrackDetails({ track, isAuthor }) { export default function TrackDetails({track, isAuthor}) {
const { t } = useTranslation(); const {t} = useTranslation()
const items = [ const items = [
track.public != null && track.public != null && isAuthor && [t('TrackPage.details.visibility'), <Visibility public={track.public} />],
isAuthor && [
t("TrackPage.details.visibility"),
<Visibility public={track.public} />,
],
track.uploadedByUserAgent != null && [ track.uploadedByUserAgent != null && [t('TrackPage.details.uploadedWith'), track.uploadedByUserAgent],
t("TrackPage.details.uploadedWith"),
track.uploadedByUserAgent,
],
track.duration != null && [ track.duration != null && [t('TrackPage.details.duration'), formatDuration(track.duration)],
t("TrackPage.details.duration"),
formatDuration(track.duration),
],
track.createdAt != null && [ track.createdAt != null && [t('TrackPage.details.uploadedDate'), <FormattedDate date={track.createdAt} />],
t("TrackPage.details.uploadedDate"),
<FormattedDate date={track.createdAt} />,
],
track?.recordedAt != null && [ track?.recordedAt != null && [t('TrackPage.details.recordedDate'), <FormattedDate date={track?.recordedAt} />],
t("TrackPage.details.recordedDate"),
<FormattedDate date={track?.recordedAt} />,
],
track?.numEvents != null && [ track?.numEvents != null && [t('TrackPage.details.numEvents'), track?.numEvents],
t("TrackPage.details.numEvents"),
track?.numEvents,
],
track?.length != null && [ track?.length != null && [t('TrackPage.details.length'), formatDistance(track?.length)],
t("TrackPage.details.length"),
formatDistance(track?.length),
],
track?.processingStatus != null && track?.processingStatus != null &&
track?.processingStatus != "error" && [ track?.processingStatus != 'error' && [t('TrackPage.details.processingStatus'), track.processingStatus],
t("TrackPage.details.processingStatus"),
track.processingStatus,
],
track.originalFileName != null && [ track.originalFileName != null && [t('TrackPage.details.originalFileName'), <code>{track.originalFileName}</code>],
t("TrackPage.details.originalFileName"), ].filter(Boolean)
<code>{track.originalFileName}</code>,
],
].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}>
@ -78,5 +50,5 @@ export default function TrackDetails({ track, isAuthor }) {
))} ))}
</Grid.Row> </Grid.Row>
</Grid> </Grid>
); )
} }

View file

@ -1,5 +1,5 @@
import React from "react"; import React from 'react'
import { connect } from "react-redux"; import {connect} from 'react-redux'
import { import {
List, List,
Dropdown, Dropdown,
@ -12,354 +12,323 @@ import {
Message, Message,
Confirm, Confirm,
Container, Container,
} from "semantic-ui-react"; } from 'semantic-ui-react'
import { useParams, useHistory } from "react-router-dom"; import {useParams, useHistory} from 'react-router-dom'
import { concat, combineLatest, of, from, Subject } from "rxjs"; import {concat, combineLatest, of, from, Subject} from 'rxjs'
import { import {pluck, distinctUntilChanged, map, switchMap, startWith, catchError} from 'rxjs/operators'
pluck, import {useObservable} from 'rxjs-hooks'
distinctUntilChanged, import Markdown from 'react-markdown'
map, import {useTranslation} from 'react-i18next'
switchMap,
startWith,
catchError,
} from "rxjs/operators";
import { useObservable } from "rxjs-hooks";
import Markdown from "react-markdown";
import { useTranslation } from "react-i18next";
import api from "api"; import api from 'api'
import { Page } from "components"; import {Page} from 'components'
import type { Track, TrackData, TrackComment } from "types"; import type {Track, TrackData, TrackComment} from 'types'
import { trackLayer, trackLayerRaw } from "../../mapstyles"; import {trackLayer, trackLayerRaw} from '../../mapstyles'
import TrackActions from "./TrackActions"; import TrackActions from './TrackActions'
import TrackComments from "./TrackComments"; import TrackComments from './TrackComments'
import TrackDetails from "./TrackDetails"; import TrackDetails from './TrackDetails'
import TrackMap from "./TrackMap"; import TrackMap from './TrackMap'
import styles from "./TrackPage.module.less"; import styles from './TrackPage.module.less'
function useTriggerSubject() { function useTriggerSubject() {
const subject$ = React.useMemo(() => new Subject(), []); const subject$ = React.useMemo(() => new Subject(), [])
const trigger = React.useCallback(() => subject$.next(null), [subject$]); const trigger = React.useCallback(() => subject$.next(null), [subject$])
return [trigger, subject$]; return [trigger, subject$]
} }
function TrackMapSettings({ function TrackMapSettings({showTrack, setShowTrack, pointsMode, setPointsMode, side, setSide}) {
showTrack, const {t} = useTranslation()
setShowTrack,
pointsMode,
setPointsMode,
side,
setSide,
}) {
const { t } = useTranslation();
return ( return (
<> <>
<Header as="h4">{t("TrackPage.mapSettings.title")}</Header> <Header as="h4">{t('TrackPage.mapSettings.title')}</Header>
<List> <List>
<List.Item> <List.Item>
<Checkbox <Checkbox checked={showTrack} onChange={(e, d) => setShowTrack(d.checked)} />{' '}
checked={showTrack} {t('TrackPage.mapSettings.showTrack')}
onChange={(e, d) => setShowTrack(d.checked)} <div style={{marginTop: 8}}>
/>{" "}
{t("TrackPage.mapSettings.showTrack")}
<div style={{ marginTop: 8 }}>
<span <span
style={{ style={{
borderTop: "3px dashed " + trackLayerRaw.paint["line-color"], borderTop: '3px dashed ' + trackLayerRaw.paint['line-color'],
height: 0, height: 0,
width: 24, width: 24,
display: "inline-block", display: 'inline-block',
verticalAlign: "middle", verticalAlign: 'middle',
marginRight: 4, marginRight: 4,
}} }}
/> />
{t("TrackPage.mapSettings.gpsTrack")} {t('TrackPage.mapSettings.gpsTrack')}
</div> </div>
<div> <div>
<span <span
style={{ style={{
borderTop: "6px solid " + trackLayerRaw.paint["line-color"], borderTop: '6px solid ' + trackLayerRaw.paint['line-color'],
height: 6, height: 6,
width: 24, width: 24,
display: "inline-block", display: 'inline-block',
verticalAlign: "middle", verticalAlign: 'middle',
marginRight: 4, marginRight: 4,
}} }}
/> />
{t("TrackPage.mapSettings.snappedTrack")} {t('TrackPage.mapSettings.snappedTrack')}
</div> </div>
</List.Item> </List.Item>
<List.Item> <List.Item>
<List.Header> {t("TrackPage.mapSettings.points")} </List.Header> <List.Header> {t('TrackPage.mapSettings.points')} </List.Header>
<Dropdown <Dropdown
selection selection
value={pointsMode} value={pointsMode}
onChange={(e, d) => setPointsMode(d.value)} onChange={(e, d) => setPointsMode(d.value)}
options={[ options={[
{ key: "none", value: "none", text: "None" }, {key: 'none', value: 'none', text: 'None'},
{ {
key: "overtakingEvents", key: 'overtakingEvents',
value: "overtakingEvents", value: 'overtakingEvents',
text: t("TrackPage.mapSettings.confirmedPoints"), text: t('TrackPage.mapSettings.confirmedPoints'),
}, },
{ {
key: "measurements", key: 'measurements',
value: "measurements", value: 'measurements',
text: t("TrackPage.mapSettings.allPoints"), text: t('TrackPage.mapSettings.allPoints'),
}, },
]} ]}
/> />
</List.Item> </List.Item>
<List.Item> <List.Item>
<List.Header>{t("TrackPage.mapSettings.side")}</List.Header> <List.Header>{t('TrackPage.mapSettings.side')}</List.Header>
<Dropdown <Dropdown
selection selection
value={side} value={side}
onChange={(e, d) => setSide(d.value)} onChange={(e, d) => setSide(d.value)}
options={[ options={[
{ {
key: "overtaker", key: 'overtaker',
value: "overtaker", value: 'overtaker',
text: t("TrackPage.mapSettings.overtakerSide"), text: t('TrackPage.mapSettings.overtakerSide'),
}, },
{ {
key: "stationary", key: 'stationary',
value: "stationary", value: 'stationary',
text: t("TrackPage.mapSettings.stationarySide"), text: t('TrackPage.mapSettings.stationarySide'),
}, },
]} ]}
/> />
</List.Item> </List.Item>
</List> </List>
</> </>
); )
} }
const TrackPage = connect((state) => ({ login: state.login }))( const TrackPage = connect((state) => ({login: state.login}))(function TrackPage({login}) {
function TrackPage({ login }) { const {slug} = useParams()
const { slug } = useParams(); const {t} = useTranslation()
const { t } = useTranslation();
const [reloadComments, reloadComments$] = useTriggerSubject(); const [reloadComments, reloadComments$] = useTriggerSubject()
const history = useHistory(); const history = useHistory()
const data: { const data: {
track: null | Track; track: null | Track
trackData: null | TrackData; trackData: null | TrackData
comments: null | TrackComment[]; comments: null | TrackComment[]
} | null = useObservable( } | null = useObservable(
(_$, args$) => { (_$, args$) => {
const slug$ = args$.pipe(pluck(0), distinctUntilChanged()); const slug$ = args$.pipe(pluck(0), distinctUntilChanged())
const track$ = slug$.pipe( const track$ = slug$.pipe(
map((slug) => `/tracks/${slug}`), map((slug) => `/tracks/${slug}`),
switchMap((url) => switchMap((url) =>
concat( concat(
of(null), of(null),
from(api.get(url)).pipe(
catchError(() => {
history.replace("/tracks");
})
)
)
),
pluck("track")
);
const trackData$ = slug$.pipe(
map((slug) => `/tracks/${slug}/data`),
switchMap((url) =>
concat(
of(undefined),
from(api.get(url)).pipe(
catchError(() => {
return of(null);
})
)
)
),
startWith(undefined) // show track infos before track data is loaded
);
const comments$ = concat(of(null), reloadComments$).pipe(
switchMap(() => slug$),
map((slug) => `/tracks/${slug}/comments`),
switchMap((url) =>
from(api.get(url)).pipe( from(api.get(url)).pipe(
catchError(() => { catchError(() => {
return of(null); history.replace('/tracks')
}) })
) )
), )
pluck("comments"), ),
startWith(null) // show track infos before comments are loaded pluck('track')
); )
return combineLatest([track$, trackData$, comments$]).pipe( const trackData$ = slug$.pipe(
map(([track, trackData, comments]) => ({ map((slug) => `/tracks/${slug}/data`),
track, switchMap((url) =>
trackData, concat(
comments, of(undefined),
})) from(api.get(url)).pipe(
); catchError(() => {
}, return of(null)
null, })
[slug] )
); )
),
startWith(undefined) // show track infos before track data is loaded
)
const onSubmitComment = React.useCallback( const comments$ = concat(of(null), reloadComments$).pipe(
async ({ body }) => { switchMap(() => slug$),
await api.post(`/tracks/${slug}/comments`, { map((slug) => `/tracks/${slug}/comments`),
body: { comment: { body } }, switchMap((url) =>
}); from(api.get(url)).pipe(
reloadComments(); catchError(() => {
}, return of(null)
[slug, reloadComments] })
); )
),
pluck('comments'),
startWith(null) // show track infos before comments are loaded
)
const onDeleteComment = React.useCallback( return combineLatest([track$, trackData$, comments$]).pipe(
async (id) => { map(([track, trackData, comments]) => ({
await api.delete(`/tracks/${slug}/comments/${id}`); track,
reloadComments(); trackData,
}, comments,
[slug, reloadComments] }))
); )
},
null,
[slug]
)
const [downloadError, setDownloadError] = React.useState(null); const onSubmitComment = React.useCallback(
const hideDownloadError = React.useCallback( async ({body}) => {
() => setDownloadError(null), await api.post(`/tracks/${slug}/comments`, {
[setDownloadError] body: {comment: {body}},
); })
const onDownload = React.useCallback( reloadComments()
async (filename) => { },
try { [slug, reloadComments]
await api.downloadFile(`/tracks/${slug}/download/${filename}`); )
} catch (err) {
if (/Failed to fetch/.test(String(err))) { const onDeleteComment = React.useCallback(
setDownloadError(t("TrackPage.downloadError")); async (id) => {
} else { await api.delete(`/tracks/${slug}/comments/${id}`)
setDownloadError(String(err)); reloadComments()
} },
[slug, reloadComments]
)
const [downloadError, setDownloadError] = React.useState(null)
const hideDownloadError = React.useCallback(() => setDownloadError(null), [setDownloadError])
const onDownload = React.useCallback(
async (filename) => {
try {
await api.downloadFile(`/tracks/${slug}/download/${filename}`)
} catch (err) {
if (/Failed to fetch/.test(String(err))) {
setDownloadError(t('TrackPage.downloadError'))
} else {
setDownloadError(String(err))
} }
}, }
[slug] },
); [slug]
)
const isAuthor = login?.id === data?.track?.author?.id; const isAuthor = login?.id === data?.track?.author?.id
const { track, trackData, comments } = data || {}; const {track, trackData, comments} = data || {}
const loading = track == null || trackData === undefined; const loading = track == null || trackData === undefined
const processing = ["processing", "queued", "created"].includes( const processing = ['processing', 'queued', 'created'].includes(track?.processingStatus)
track?.processingStatus const error = track?.processingStatus === 'error'
);
const error = track?.processingStatus === "error";
const [showTrack, setShowTrack] = React.useState(true); const [showTrack, setShowTrack] = React.useState(true)
const [pointsMode, setPointsMode] = React.useState("overtakingEvents"); // none|overtakingEvents|measurements const [pointsMode, setPointsMode] = React.useState('overtakingEvents') // none|overtakingEvents|measurements
const [side, setSide] = React.useState("overtaker"); // overtaker|stationary const [side, setSide] = React.useState('overtaker') // overtaker|stationary
const title = track ? track.title || t("general.unnamedTrack") : null; const title = track ? track.title || t('general.unnamedTrack') : null
return ( return (
<Page <Page
title={title} title={title}
stage={ stage={
<> <>
<Container> <Container>
{track && ( {track && (
<Segment basic> <Segment basic>
<div <div
style={{ style={{
display: "flex", display: 'flex',
alignItems: "baseline", alignItems: 'baseline',
marginBlockStart: 32, marginBlockStart: 32,
marginBlockEnd: 16, marginBlockEnd: 16,
}} }}
> >
<Header as="h1">{title}</Header> <Header as="h1">{title}</Header>
<div style={{ marginLeft: "auto" }}> <div style={{marginLeft: 'auto'}}>
<TrackActions {...{ isAuthor, onDownload, slug }} /> <TrackActions {...{isAuthor, onDownload, slug}} />
</div>
</div> </div>
</div>
<div style={{ marginBlockEnd: 16 }}> <div style={{marginBlockEnd: 16}}>
<TrackDetails {...{ track, isAuthor }} /> <TrackDetails {...{track, isAuthor}} />
</div> </div>
</Segment> </Segment>
)} )}
</Container> </Container>
<div className={styles.stage}> <div className={styles.stage}>
<Loader active={loading} /> <Loader active={loading} />
<Dimmer.Dimmable blurring dimmed={loading}> <Dimmer.Dimmable blurring dimmed={loading}>
<TrackMap <TrackMap {...{track, trackData, pointsMode, side, showTrack}} style={{height: '80vh'}} />
{...{ track, trackData, pointsMode, side, showTrack }} </Dimmer.Dimmable>
style={{ height: "80vh" }}
<div className={styles.details}>
<Segment>
<TrackMapSettings
{...{
showTrack,
setShowTrack,
pointsMode,
setPointsMode,
side,
setSide,
}}
/> />
</Dimmer.Dimmable> </Segment>
<div className={styles.details}> {processing && (
<Segment> <Message warning>
<TrackMapSettings <Message.Content>{t('TrackPage.processing')}</Message.Content>
{...{ </Message>
showTrack,
setShowTrack,
pointsMode,
setPointsMode,
side,
setSide,
}}
/>
</Segment>
{processing && (
<Message warning>
<Message.Content>
{t("TrackPage.processing")}
</Message.Content>
</Message>
)}
{error && (
<Message error>
<Message.Content>
{t("TrackPage.processingError")}
</Message.Content>
</Message>
)}
</div>
</div>
<Container>
{track?.description && (
<>
<Header as="h2" dividing>
{t("TrackPage.description")}
</Header>
<Markdown>{track.description}</Markdown>
</>
)} )}
<TrackComments {error && (
{...{ hideLoader: loading, comments, login }} <Message error>
onSubmit={onSubmitComment} <Message.Content>{t('TrackPage.processingError')}</Message.Content>
onDelete={onDeleteComment} </Message>
/> )}
</Container> </div>
</> </div>
}
>
<Confirm
open={downloadError != null}
cancelButton={false}
onConfirm={hideDownloadError}
header={t("TrackPage.downloadFailed")}
content={String(downloadError)}
confirmButton={t("general.ok")}
/>
</Page>
);
}
);
export default TrackPage; <Container>
{track?.description && (
<>
<Header as="h2" dividing>
{t('TrackPage.description')}
</Header>
<Markdown>{track.description}</Markdown>
</>
)}
<TrackComments
{...{hideLoader: loading, comments, login}}
onSubmit={onSubmitComment}
onDelete={onDeleteComment}
/>
</Container>
</>
}
>
<Confirm
open={downloadError != null}
cancelButton={false}
onConfirm={hideDownloadError}
header={t('TrackPage.downloadFailed')}
content={String(downloadError)}
confirmButton={t('general.ok')}
/>
</Page>
)
})
export default TrackPage

View file

@ -1,65 +1,49 @@
import React, { useCallback } from "react"; import React, {useCallback} from 'react'
import { connect } from "react-redux"; import {connect} from 'react-redux'
import { import {Button, Message, Item, Header, Loader, Pagination, Icon} from 'semantic-ui-react'
Button, import {useObservable} from 'rxjs-hooks'
Message, import {Link} from 'react-router-dom'
Item, import {of, from, concat} from 'rxjs'
Header, import {map, switchMap, distinctUntilChanged} from 'rxjs/operators'
Loader, import _ from 'lodash'
Pagination, import {useTranslation, Trans as Translate} from 'react-i18next'
Icon,
} from "semantic-ui-react";
import { useObservable } from "rxjs-hooks";
import { Link } from "react-router-dom";
import { of, from, concat } from "rxjs";
import { map, switchMap, distinctUntilChanged } from "rxjs/operators";
import _ from "lodash";
import { useTranslation, Trans as Translate } from "react-i18next";
import type { Track } from "types"; import type {Track} from 'types'
import { import {Avatar, Page, StripMarkdown, FormattedDate, Visibility} from 'components'
Avatar, import api from 'api'
Page, import {useQueryParam} from 'query'
StripMarkdown,
FormattedDate,
Visibility,
} from "components";
import api from "api";
import { useQueryParam } from "query";
function TrackList({ privateTracks }: { privateTracks: boolean }) { function TrackList({privateTracks}: {privateTracks: boolean}) {
const [page, setPage] = useQueryParam<number>("page", 1, Number); const [page, setPage] = useQueryParam<number>('page', 1, Number)
const pageSize = 10; const pageSize = 10
const data: { const data: {
tracks: Track[]; tracks: Track[]
trackCount: number; trackCount: number
} | null = useObservable( } | null = useObservable(
(_$, inputs$) => (_$, inputs$) =>
inputs$.pipe( inputs$.pipe(
map(([page, privateTracks]) => { map(([page, privateTracks]) => {
const url = "/tracks" + (privateTracks ? "/feed" : ""); const url = '/tracks' + (privateTracks ? '/feed' : '')
const query = { limit: pageSize, offset: pageSize * (page - 1) }; const query = {limit: pageSize, offset: pageSize * (page - 1)}
return { url, query }; return {url, query}
}), }),
distinctUntilChanged(_.isEqual), distinctUntilChanged(_.isEqual),
switchMap((request) => switchMap((request) => concat(of(null), from(api.get(request.url, {query: request.query}))))
concat(of(null), from(api.get(request.url, { query: request.query })))
)
), ),
null, null,
[page, privateTracks] [page, privateTracks]
); )
const { tracks, trackCount } = data || { tracks: [], trackCount: 0 }; const {tracks, trackCount} = data || {tracks: [], trackCount: 0}
const loading = !data; const loading = !data
const totalPages = Math.ceil(trackCount / pageSize); const totalPages = Math.ceil(trackCount / pageSize)
const { t } = useTranslation(); const {t} = useTranslation()
return ( return (
<div> <div>
<Loader content={t("general.loading")} active={loading} /> <Loader content={t('general.loading')} active={loading} />
{!loading && totalPages > 1 && ( {!loading && totalPages > 1 && (
<Pagination <Pagination
activePage={page} activePage={page}
@ -71,14 +55,14 @@ function TrackList({ privateTracks }: { privateTracks: boolean }) {
{tracks && tracks.length ? ( {tracks && tracks.length ? (
<Item.Group divided> <Item.Group divided>
{tracks.map((track: Track) => ( {tracks.map((track: Track) => (
<TrackListItem key={track.slug} {...{ track, privateTracks }} /> <TrackListItem key={track.slug} {...{track, privateTracks}} />
))} ))}
</Item.Group> </Item.Group>
) : ( ) : (
<NoPublicTracksMessage /> <NoPublicTracksMessage />
)} )}
</div> </div>
); )
} }
export function NoPublicTracksMessage() { export function NoPublicTracksMessage() {
@ -88,27 +72,27 @@ export function NoPublicTracksMessage() {
No public tracks yet. <Link to="/upload">Upload the first!</Link> No public tracks yet. <Link to="/upload">Upload the first!</Link>
</Translate> </Translate>
</Message> </Message>
); )
} }
function maxLength(t: string | null, max: number): string | null { function maxLength(t: string | null, max: number): string | null {
if (t && t.length > max) { if (t && t.length > max) {
return t.substring(0, max) + " ..."; return t.substring(0, max) + ' ...'
} else { } else {
return t; return t
} }
} }
const COLOR_BY_STATUS = { const COLOR_BY_STATUS = {
error: "red", error: 'red',
complete: "green", complete: 'green',
created: "gray", created: 'gray',
queued: "orange", queued: 'orange',
processing: "orange", processing: 'orange',
}; }
export function TrackListItem({ track, privateTracks = false }) { export function TrackListItem({track, privateTracks = false}) {
const { t } = useTranslation(); const {t} = useTranslation()
return ( return (
<Item key={track.slug}> <Item key={track.slug}>
@ -117,14 +101,10 @@ export function TrackListItem({ track, privateTracks = false }) {
</Item.Image> </Item.Image>
<Item.Content> <Item.Content>
<Item.Header as={Link} to={`/tracks/${track.slug}`}> <Item.Header as={Link} to={`/tracks/${track.slug}`}>
{track.title || t("general.unnamedTrack")} {track.title || t('general.unnamedTrack')}
</Item.Header> </Item.Header>
<Item.Meta> <Item.Meta>
{privateTracks ? null : ( {privateTracks ? null : <span>{t('TracksPage.createdBy', {author: track.author.displayName})}</span>}
<span>
{t("TracksPage.createdBy", { author: track.author.displayName })}
</span>
)}
<span> <span>
<FormattedDate date={track.createdAt} /> <FormattedDate date={track.createdAt} />
</span> </span>
@ -136,57 +116,44 @@ export function TrackListItem({ track, privateTracks = false }) {
<Item.Extra> <Item.Extra>
<Visibility public={track.public} /> <Visibility public={track.public} />
<span style={{ marginLeft: "1em" }}> <span style={{marginLeft: '1em'}}>
<Icon <Icon color={COLOR_BY_STATUS[track.processingStatus]} name="bolt" fitted />{' '}
color={COLOR_BY_STATUS[track.processingStatus]}
name="bolt"
fitted
/>{" "}
{t(`TracksPage.processing.${track.processingStatus}`)} {t(`TracksPage.processing.${track.processingStatus}`)}
</span> </span>
</Item.Extra> </Item.Extra>
)} )}
</Item.Content> </Item.Content>
</Item> </Item>
); )
} }
function UploadButton({ navigate, ...props }) { function UploadButton({navigate, ...props}) {
const { t } = useTranslation(); const {t} = useTranslation()
const onClick = useCallback( const onClick = useCallback(
(e) => { (e) => {
e.preventDefault(); e.preventDefault()
navigate(); navigate()
}, },
[navigate] [navigate]
); )
return ( return (
<Button <Button onClick={onClick} {...props} color="green" style={{float: 'right'}}>
onClick={onClick} {t('TracksPage.upload')}
{...props}
color="green"
style={{ float: "right" }}
>
{t("TracksPage.upload")}
</Button> </Button>
); )
} }
const TracksPage = connect((state) => ({ login: (state as any).login }))( const TracksPage = connect((state) => ({login: (state as any).login}))(function TracksPage({login, privateTracks}) {
function TracksPage({ login, privateTracks }) { const {t} = useTranslation()
const { t } = useTranslation(); const title = privateTracks ? t('TracksPage.titleUser') : t('TracksPage.titlePublic')
const title = privateTracks
? t("TracksPage.titleUser")
: t("TracksPage.titlePublic");
return ( return (
<Page title={title}> <Page title={title}>
<Header as="h2">{title}</Header> <Header as="h2">{title}</Header>
{privateTracks && <Link component={UploadButton} to="/upload" />} {privateTracks && <Link component={UploadButton} to="/upload" />}
<TrackList {...{ privateTracks }} /> <TrackList {...{privateTracks}} />
</Page> </Page>
); )
} })
);
export default TracksPage; export default TracksPage

View file

@ -1,46 +1,46 @@
import _ from "lodash"; import _ from 'lodash'
import React from "react"; import React from 'react'
import { Header, 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'
import { FileUploadField, Page } from "components"; import {FileUploadField, Page} from 'components'
import type { Track } from "types"; import type {Track} from 'types'
import api from "api"; import api from 'api'
import configPromise from "config"; import configPromise from 'config'
function isSameFile(a: File, b: File) { function isSameFile(a: File, b: File) {
return a.name === b.name && a.size === b.size; return a.name === b.name && a.size === b.size
} }
function formatFileSize(bytes: number) { function formatFileSize(bytes: number) {
if (bytes < 1024) { if (bytes < 1024) {
return `${bytes} bytes`; return `${bytes} bytes`
} }
bytes /= 1024; bytes /= 1024
if (bytes < 1024) { if (bytes < 1024) {
return `${bytes.toFixed(1)} KiB`; return `${bytes.toFixed(1)} KiB`
} }
bytes /= 1024; bytes /= 1024
if (bytes < 1024) { if (bytes < 1024) {
return `${bytes.toFixed(1)} MiB`; return `${bytes.toFixed(1)} MiB`
} }
bytes /= 1024; bytes /= 1024
return `${bytes.toFixed(1)} GiB`; return `${bytes.toFixed(1)} GiB`
} }
type FileUploadResult = type FileUploadResult =
| { | {
track: Track; track: Track
} }
| { | {
errors: Record<string, string>; errors: Record<string, string>
}; }
export function FileUploadStatus({ export function FileUploadStatus({
id, id,
@ -48,109 +48,101 @@ export function FileUploadStatus({
onComplete, onComplete,
slug, slug,
}: { }: {
id: string; id: string
file: File; file: File
onComplete: (id: string, result: FileUploadResult) => void; onComplete: (id: string, result: FileUploadResult) => void
slug?: string; slug?: string
}) { }) {
const [progress, setProgress] = React.useState(0); const [progress, setProgress] = React.useState(0)
React.useEffect( React.useEffect(
() => { () => {
let xhr; let xhr
async function _work() { async function _work() {
const formData = new FormData(); const formData = new FormData()
formData.append("body", file); formData.append('body', file)
xhr = new XMLHttpRequest(); xhr = new XMLHttpRequest()
xhr.withCredentials = true; xhr.withCredentials = true
const onProgress = (e) => { const onProgress = (e) => {
const progress = (e.loaded || 0) / (e.total || 1); const progress = (e.loaded || 0) / (e.total || 1)
setProgress(progress); setProgress(progress)
}; }
const onLoad = (e) => { const onLoad = (e) => {
onComplete(id, xhr.response); onComplete(id, xhr.response)
}; }
xhr.responseType = "json"; xhr.responseType = 'json'
xhr.onload = onLoad; xhr.onload = onLoad
xhr.upload.onprogress = onProgress; xhr.upload.onprogress = onProgress
const config = await configPromise; const config = await configPromise
if (slug) { if (slug) {
xhr.open("PUT", `${config.apiUrl}/tracks/${slug}`); xhr.open('PUT', `${config.apiUrl}/tracks/${slug}`)
} else { } else {
xhr.open("POST", `${config.apiUrl}/tracks`); xhr.open('POST', `${config.apiUrl}/tracks`)
} }
// const accessToken = await api.getValidAccessToken() // const accessToken = await api.getValidAccessToken()
// xhr.setRequestHeader('Authorization', accessToken) // xhr.setRequestHeader('Authorization', accessToken)
xhr.send(formData); xhr.send(formData)
} }
_work(); _work()
return () => xhr.abort(); return () => xhr.abort()
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[file] [file]
); )
const { t } = useTranslation(); const {t} = useTranslation()
return ( return (
<span> <span>
<Loader inline size="mini" active />{" "} <Loader inline size="mini" active />{' '}
{progress < 1 {progress < 1
? t("UploadPage.uploadProgress", { ? t('UploadPage.uploadProgress', {
progress: (progress * 100).toFixed(0), progress: (progress * 100).toFixed(0),
}) })
: t("UploadPage.processing")} : t('UploadPage.processing')}
</span> </span>
); )
} }
type FileEntry = { type FileEntry = {
id: string; id: string
file?: File | null; file?: File | null
size: number; size: number
name: string; name: string
result?: FileUploadResult; result?: FileUploadResult
}; }
export default function UploadPage() { export default function UploadPage() {
const [files, setFiles] = React.useState<FileEntry[]>([]); const [files, setFiles] = React.useState<FileEntry[]>([])
const onCompleteFileUpload = React.useCallback( const onCompleteFileUpload = React.useCallback(
(id, result) => { (id, result) => {
setFiles((files) => setFiles((files) => files.map((file) => (file.id === id ? {...file, result, file: null} : file)))
files.map((file) =>
file.id === id ? { ...file, result, file: null } : file
)
);
}, },
[setFiles] [setFiles]
); )
function onSelectFiles(fileList) { function onSelectFiles(fileList) {
const newFiles = Array.from(fileList).map((file) => ({ const newFiles = Array.from(fileList).map((file) => ({
id: "file-" + String(Math.floor(Math.random() * 1000000)), id: 'file-' + String(Math.floor(Math.random() * 1000000)),
file, file,
name: file.name, name: file.name,
size: file.size, size: file.size,
})); }))
setFiles( setFiles(files.filter((a) => !newFiles.some((b) => isSameFile(a, b))).concat(newFiles))
files
.filter((a) => !newFiles.some((b) => isSameFile(a, b)))
.concat(newFiles)
);
} }
const { t } = useTranslation(); const {t} = useTranslation()
const title = t("UploadPage.title"); const title = t('UploadPage.title')
return ( return (
<Page title={title}> <Page title={title}>
<Header as="h1">{title}</Header> <Header as="h1">{title}</Header>
@ -158,19 +150,15 @@ export default function UploadPage() {
<Table> <Table>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.HeaderCell> <Table.HeaderCell>{t('UploadPage.table.filename')}</Table.HeaderCell>
{t("UploadPage.table.filename")} <Table.HeaderCell>{t('UploadPage.table.size')}</Table.HeaderCell>
</Table.HeaderCell> <Table.HeaderCell>{t('UploadPage.table.statusTitle')}</Table.HeaderCell>
<Table.HeaderCell>{t("UploadPage.table.size")}</Table.HeaderCell>
<Table.HeaderCell>
{t("UploadPage.table.statusTitle")}
</Table.HeaderCell>
<Table.HeaderCell colSpan={2}></Table.HeaderCell> <Table.HeaderCell colSpan={2}></Table.HeaderCell>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{files.map(({ id, name, size, file, result }) => ( {files.map(({id, name, size, file, result}) => (
<Table.Row key={id}> <Table.Row key={id}>
<Table.Cell> <Table.Cell>
<Icon name="file" /> <Icon name="file" />
@ -181,9 +169,7 @@ export default function UploadPage() {
{result?.errors ? ( {result?.errors ? (
<List> <List>
{_.sortBy(Object.entries(result.errors)) {_.sortBy(Object.entries(result.errors))
.filter( .filter(([field, message]) => typeof message === 'string')
([field, message]) => typeof message === "string"
)
.map(([field, message]) => ( .map(([field, message]) => (
<List.Item key={field}> <List.Item key={field}>
<List.Icon name="warning sign" color="red" /> <List.Icon name="warning sign" color="red" />
@ -193,29 +179,17 @@ export default function UploadPage() {
</List> </List>
) : result ? ( ) : result ? (
<> <>
<Icon name="check" />{" "} <Icon name="check" /> {result.track?.title || t('general.unnamedTrack')}
{result.track?.title || t("general.unnamedTrack")}
</> </>
) : ( ) : (
<FileUploadStatus <FileUploadStatus {...{id, file}} onComplete={onCompleteFileUpload} />
{...{ id, file }}
onComplete={onCompleteFileUpload}
/>
)} )}
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
{result?.track ? ( {result?.track ? <Link to={`/tracks/${result.track.slug}`}>{t('general.show')}</Link> : null}
<Link to={`/tracks/${result.track.slug}`}>
{t("general.show")}
</Link>
) : null}
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
{result?.track ? ( {result?.track ? <Link to={`/tracks/${result.track.slug}/edit`}>{t('general.edit')}</Link> : null}
<Link to={`/tracks/${result.track.slug}/edit`}>
{t("general.edit")}
</Link>
) : null}
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
))} ))}
@ -225,5 +199,5 @@ export default function UploadPage() {
<FileUploadField onSelect={onSelectFiles} multiple /> <FileUploadField onSelect={onSelectFiles} multiple />
</Page> </Page>
); )
} }

View file

@ -1,13 +1,13 @@
export { default as AcknowledgementsPage } from "./AcknowledgementsPage"; export {default as AcknowledgementsPage} from './AcknowledgementsPage'
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 MyTracksPage } from "./MyTracksPage"; export {default as MyTracksPage} from './MyTracksPage'
export { default as UploadPage } from "./UploadPage"; export {default as UploadPage} from './UploadPage'

View file

@ -53,7 +53,7 @@ export function useQueryParam<T extends QueryValue>(
): [T, (newValue: T) => void] { ): [T, (newValue: T) => void] {
const history = useHistory() const history = useHistory()
useLocation() // to trigger a reload when the url changes useLocation() // to trigger a reload when the url changes
const {[name]: value = defaultValue} = (parseQuery(history.location.search) as unknown) as { const {[name]: value = defaultValue} = parseQuery(history.location.search) as unknown as {
[name: string]: T [name: string]: T
} }
const setter = useMemo( const setter = useMemo(

View file

@ -1,67 +1,62 @@
import type { FeatureCollection, Feature, LineString, Point } from "geojson"; import type {FeatureCollection, Feature, LineString, Point} from 'geojson'
export interface UserProfile { export interface UserProfile {
username: string; username: string
displayName: string; displayName: string
image?: string | null; image?: string | null
bio?: string | null; bio?: string | null
} }
export interface TrackData { export interface TrackData {
track: Feature<LineString>; track: Feature<LineString>
measurements: FeatureCollection; measurements: FeatureCollection
overtakingEvents: FeatureCollection; overtakingEvents: FeatureCollection
} }
export type ProcessingStatus = export type ProcessingStatus = 'error' | 'complete' | 'created' | 'queued' | 'processing'
| "error"
| "complete"
| "created"
| "queued"
| "processing";
export interface Track { export interface Track {
slug: string; slug: string
author: UserProfile; author: UserProfile
title: string; title: string
description?: string; description?: string
createdAt: string; createdAt: string
processingStatus?: ProcessingStatus; processingStatus?: ProcessingStatus
public?: boolean; public?: boolean
recordedAt?: Date; recordedAt?: Date
recordedUntil?: Date; recordedUntil?: Date
duration?: number; duration?: number
length?: number; length?: number
segments?: number; segments?: number
numEvents?: number; numEvents?: number
numMeasurements?: number; numMeasurements?: number
numValid?: number; numValid?: number
userDeviceId?: number; userDeviceId?: number
} }
export interface 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 interface TrackComment { export interface TrackComment {
id: string; id: string
body: string; body: string
createdAt: string; createdAt: string
author: UserProfile; author: UserProfile
} }
export interface Location { export interface Location {
longitude: number; longitude: number
latitude: number; latitude: number
} }
export interface UserDevice { export interface UserDevice {
id: number; id: number
identifier: string; identifier: string
displayName?: string; displayName?: string
} }

View file

@ -1,51 +1,49 @@
import { useRef, useCallback } from "react"; import {useRef, useCallback} from 'react'
import { Duration } from "luxon"; 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 const found = element ? element.querySelector('input, textarea, select, checkbox') : null
? element.querySelector("input, textarea, select, checkbox") register(found)
: 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) { export function formatDuration(seconds) {
return Duration.fromMillis((seconds ?? 0) * 1000).toFormat("h'h' mm'm'"); return Duration.fromMillis((seconds ?? 0) * 1000).toFormat("h'h' mm'm'")
} }
export function formatDistance(meters) { export function formatDistance(meters) {
if (meters == null) return null; if (meters == null) return null
if (meters < 0) return "-" + formatDistance(meters); if (meters < 0) return '-' + formatDistance(meters)
if (meters < 1000) { if (meters < 1000) {
return `${meters.toFixed(0)} m`; return `${meters.toFixed(0)} m`
} else { } else {
return `${(meters / 1000).toFixed(2)} km`; return `${(meters / 1000).toFixed(2)} km`
} }
} }

View file

@ -163,7 +163,7 @@ module.exports = function (webpackEnv) {
'/config.json': apiUrl, '/config.json': apiUrl,
'/api': apiUrl, '/api': apiUrl,
'/login': apiUrl, '/login': apiUrl,
'/tiles': apiUrl '/tiles': apiUrl,
}, },
}, },
module: { module: {
@ -210,10 +210,12 @@ module.exports = function (webpackEnv) {
{ {
test: /\.ya?ml$/, test: /\.ya?ml$/,
type: 'json', type: 'json',
use: [{ use: [
loader: 'yaml-loader', {
options: {asJSON: true}, loader: 'yaml-loader',
}], options: {asJSON: true},
},
],
}, },
{ {
test: /\.css$/i, test: /\.css$/i,