Format all JS/TS files

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

View File

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

View File

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

View File

@ -1,18 +1,18 @@
import React, {useMemo} from "react";
type ColorMap = [number, string][]
import React, {useMemo} from 'react'
import styles from './ColorMapLegend.module.less'
type ColorMap = [number, string][]
function* pairs(arr) {
for (let i = 1; i < arr.length; i++) {
yield [arr[i - 1], arr[i]];
yield [arr[i - 1], arr[i]]
}
}
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++) {
yield arrs.map(a => a[i]);
yield arrs.map((a) => a[i])
}
}
@ -25,10 +25,10 @@ export function DiscreteColorMapLegend({map}: {map: ColorMap}) {
min -= buffer
max += buffer
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 gradientUrl = `url(#${gradientId})`;
const gradientId = useMemo(() => `gradient${Math.floor(Math.random() * 1000000)}`, [])
const gradientUrl = `url(#${gradientId})`
const parts = Array.from(zip(stopPairs, colors))
@ -38,11 +38,10 @@ export function DiscreteColorMapLegend({map}: {map: ColorMap}) {
<defs>
<linearGradient id={gradientId} x1="0" x2="1" y1="0" y2="0">
{parts.map(([[left, right], color]) => (
<React.Fragment key={left}>
<stop offset={normalizeValue(left) * 100 + '%'} stopColor={color} />
<stop offset={normalizeValue(right) * 100 + '%'} stopColor={color} />
</React.Fragment>
<React.Fragment key={left}>
<stop offset={normalizeValue(left) * 100 + '%'} stopColor={color} />
<stop offset={normalizeValue(right) * 100 + '%'} stopColor={color} />
</React.Fragment>
))}
</linearGradient>
</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 max = map[map.length - 1][0]
const normalizeValue = (v) => (v - min) / (max - min)
const gradientId = useMemo(() => `gradient${Math.floor(Math.random() * 1000000)}`, []);
const gradientUrl = `url(#${gradientId})`;
const tickValues = twoTicks ? [map[0], map[map.length-1]] : map
const gradientId = useMemo(() => `gradient${Math.floor(Math.random() * 1000000)}`, [])
const gradientUrl = `url(#${gradientId})`
const tickValues = twoTicks ? [map[0], map[map.length - 1]] : map
return (
<div className={styles.colorMapLegend}>
<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 { Icon, Segment, Header, Button } from "semantic-ui-react";
import { useTranslation } from "react-i18next";
import React from 'react'
import {Icon, Segment, Header, Button} from 'semantic-ui-react'
import {useTranslation} from 'react-i18next'
import { FileDrop } from "components";
import {FileDrop} from 'components'
export default function FileUploadField({ onSelect: onSelect_, multiple }) {
const { t } = useTranslation();
const labelRef = React.useRef();
const [labelRefState, setLabelRefState] = React.useState();
export default function FileUploadField({onSelect: onSelect_, multiple}) {
const {t} = useTranslation()
const labelRef = React.useRef()
const [labelRefState, setLabelRefState] = React.useState()
const onSelect = multiple ? onSelect_ : (files) => onSelect_(files?.[0]);
const onSelect = multiple ? onSelect_ : (files) => onSelect_(files?.[0])
React.useLayoutEffect(
() => {
setLabelRefState(labelRef.current);
setLabelRefState(labelRef.current)
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[labelRef.current]
);
)
function onChangeField(e) {
e.preventDefault?.();
e.preventDefault?.()
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 (
@ -36,7 +36,7 @@ export default function FileUploadField({ onSelect: onSelect_, multiple }) {
style={{
width: 0,
height: 0,
position: "fixed",
position: 'fixed',
left: -1000,
top: -1000,
opacity: 0.001,
@ -48,34 +48,22 @@ export default function FileUploadField({ onSelect: onSelect_, multiple }) {
<label htmlFor="upload-field" ref={labelRef}>
{labelRefState && (
<FileDrop onDrop={onSelect} frame={labelRefState}>
{({
draggingOverFrame,
draggingOverTarget,
onDragOver,
onDragLeave,
onDrop,
onClick,
}) => (
{({draggingOverFrame, draggingOverTarget, onDragOver, onDragLeave, onDrop, onClick}) => (
<Segment
placeholder
{...{ onDragOver, onDragLeave, onDrop }}
{...{onDragOver, onDragLeave, onDrop}}
style={{
background:
draggingOverTarget || draggingOverFrame ? "#E0E0EE" : null,
transition: "background 0.2s",
background: draggingOverTarget || draggingOverFrame ? '#E0E0EE' : null,
transition: 'background 0.2s',
}}
>
<Header icon>
<Icon name="cloud upload" />
{multiple
? t("FileUploadField.dropOrClickMultiple")
: t("FileUploadField.dropOrClick")}
{multiple ? t('FileUploadField.dropOrClickMultiple') : t('FileUploadField.dropOrClick')}
</Header>
<Button primary as="span">
{multiple
? t("FileUploadField.uploadFiles")
: t("FileUploadField.uploadFile")}
{multiple ? t('FileUploadField.uploadFiles') : t('FileUploadField.uploadFile')}
</Button>
</Segment>
)}
@ -83,5 +71,5 @@ export default function FileUploadField({ onSelect: onSelect_, multiple }) {
)}
</label>
</>
);
)
}

View File

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

View File

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

View File

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

View File

@ -1,46 +1,46 @@
import React from "react";
import React from 'react'
export type MapSource = {
type: "vector";
tiles: string[];
minzoom: number;
maxzoom: number;
};
type: 'vector'
tiles: string[]
minzoom: number
maxzoom: number
}
export interface Config {
apiUrl: string;
apiUrl: string
mapHome: {
latitude: number;
longitude: number;
zoom: number;
};
obsMapSource?: MapSource;
imprintUrl?: string;
privacyPolicyUrl?: string;
termsUrl?: string;
latitude: number
longitude: number
zoom: number
}
obsMapSource?: MapSource
imprintUrl?: string
privacyPolicyUrl?: string
termsUrl?: string
banner?: {
text: string;
style?: "warning" | "info";
};
text: string
style?: 'warning' | 'info'
}
}
async function loadConfig(): Promise<Config> {
const response = await fetch(__webpack_public_path__ + "config.json");
const config = await response.json();
return config;
const response = await fetch(__webpack_public_path__ + 'config.json')
const config = await response.json()
return config
}
let _configPromise: Promise<Config> = loadConfig();
let _configCache: null | Config = null;
let _configPromise: Promise<Config> = loadConfig()
let _configCache: null | Config = null
export function useConfig() {
const [config, setConfig] = React.useState<Config>(_configCache);
const [config, setConfig] = React.useState<Config>(_configCache)
React.useEffect(() => {
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 i18next, { TOptions } from "i18next";
import { BehaviorSubject, combineLatest } from "rxjs";
import { map, distinctUntilChanged } from "rxjs/operators";
import HttpBackend, {
BackendOptions,
RequestCallback,
} from "i18next-http-backend";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import {useState, useEffect, useMemo} from 'react'
import i18next, {TOptions} from 'i18next'
import {BehaviorSubject, combineLatest} from 'rxjs'
import {map, distinctUntilChanged} from 'rxjs/operators'
import HttpBackend, {BackendOptions, RequestCallback} 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(
_options: BackendOptions,
url: string,
_payload: any,
callback: RequestCallback
) {
async function request(_options: BackendOptions, url: string, _payload: any, callback: RequestCallback) {
try {
const [lng] = url.split("/");
const locale = await import(`translations/${lng}.yaml`);
callback(null, { status: 200, data: locale });
const [lng] = url.split('/')
const locale = await import(`translations/${lng}.yaml`)
callback(null, {status: 200, data: locale})
} catch (e) {
console.error(`Unable to load locale at ${url}\n`, e);
callback(null, { status: 404, data: String(e) });
console.error(`Unable to load locale at ${url}\n`, 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 = {
fallbackLng: "en",
fallbackLng: 'en',
ns: ["common"],
defaultNS: "common",
ns: ['common'],
defaultNS: 'common',
whitelist: AVAILABLE_LOCALES,
// loading via webpack
backend: {
loadPath: "{{lng}}/{{ns}}",
loadPath: '{{lng}}/{{ns}}',
parse: (data: any) => data,
request,
},
load: "languageOnly",
load: 'languageOnly',
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
};
}
i18n
.use(HttpBackend)
.use(initReactI18next)
.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]) =>
combineLatest([stringAndData$, locale$.pipe(distinctUntilChanged())]).pipe(
map(([stringAndData]) => {
if (typeof stringAndData === "string") {
return i18n.t(stringAndData);
if (typeof stringAndData === 'string') {
return i18n.t(stringAndData)
} else {
const [string, data] = stringAndData;
return i18n.t(string, { data });
const [string, data] = stringAndData
return i18n.t(string, {data})
}
})
);
)
export const setLocale = (locale: AvailableLocales) => {
i18n.changeLanguage(locale);
locale$.next(locale);
};
export function useLocale() {
const [, reload] = useState();
useEffect(() => {
i18n.on("languageChanged", reload);
return () => {
i18n.off("languageChanged", reload);
};
}, []);
return i18n.language;
i18n.changeLanguage(locale)
locale$.next(locale)
}
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 produce from "immer";
import _ from 'lodash'
import produce from 'immer'
import bright from "./bright.json";
import positron from "./positron.json";
import bright from './bright.json'
import positron from './positron.json'
import viridisBase from "colormap/res/res/viridis";
import viridisBase from 'colormap/res/res/viridis'
export { bright, positron };
export const baseMapStyles = { bright, positron };
export {bright, positron}
export const baseMapStyles = {bright, positron}
function simplifyColormap(colormap, maxCount = 16) {
const result = [];
const step = Math.ceil(colormap.length / maxCount);
const result = []
const step = Math.ceil(colormap.length / maxCount)
for (let i = 0; i < colormap.length; i += step) {
result.push(colormap[i]);
result.push(colormap[i])
}
return result;
return result
}
function rgbArrayToColor(arr) {
return ["rgb", ...arr.map((v) => Math.round(v * 255))];
return ['rgb', ...arr.map((v) => Math.round(v * 255))]
}
function rgbArrayToHtml(arr) {
return (
"#" +
'#' +
arr
.map((v) => Math.round(v * 255).toString(16))
.map((v) => (v.length == 1 ? "0" : "") + v)
.join("")
);
.map((v) => (v.length == 1 ? '0' : '') + v)
.join('')
)
}
export function colormapToScale(colormap, value, min, max) {
return [
"interpolate-hcl",
["linear"],
'interpolate-hcl',
['linear'],
value,
...colormap.flatMap((v, i, a) => [
(i / (a.length - 1)) * (max - min) + min,
v,
]),
];
...colormap.flatMap((v, i, a) => [(i / (a.length - 1)) * (max - min) + min, v]),
]
}
export const viridis = simplifyColormap(viridisBase.map(rgbArrayToColor), 20);
export const viridisSimpleHtml = simplifyColormap(
viridisBase.map(rgbArrayToHtml),
10
);
export const grayscale = ["#FFFFFF", "#000000"];
export const reds = ["rgba( 255, 0, 0, 0)", "rgba( 255, 0, 0, 255)"];
export const viridis = simplifyColormap(viridisBase.map(rgbArrayToColor), 20)
export const viridisSimpleHtml = simplifyColormap(viridisBase.map(rgbArrayToHtml), 10)
export const grayscale = ['#FFFFFF', '#000000']
export const reds = ['rgba( 255, 0, 0, 0)', 'rgba( 255, 0, 0, 255)']
export function colorByCount(
attribute = "event_count",
maxCount,
colormap = viridis
) {
return colormapToScale(
colormap,
["case", isValidAttribute(attribute), ["get", attribute], 0],
0,
maxCount
);
export function colorByCount(attribute = 'event_count', 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) {
if (attribute.endsWith("zone")) {
return ["in", ["get", attribute], ["literal", ["rural", "urban"]]];
if (attribute.endsWith('zone')) {
return ['in', ['get', attribute], ['literal', ['rural', 'urban']]]
}
return ["to-boolean", ["get", attribute]];
return ['to-boolean', ['get', attribute]]
}
export function borderByZone() {
return ["match", ["get", "zone"], "rural", "cyan", "urban", "blue", "purple"];
return ['match', ['get', 'zone'], 'rural', 'cyan', 'urban', 'blue', 'purple']
}
export function colorByDistance(
attribute = "distance_overtaker_mean",
fallback = "#ABC",
zone = "urban"
) {
export function colorByDistance(attribute = 'distance_overtaker_mean', fallback = '#ABC', zone = 'urban') {
return [
"case",
["!", isValidAttribute(attribute)],
'case',
['!', isValidAttribute(attribute)],
fallback,
[
"match",
["get", "zone"],
"rural",
'match',
['get', 'zone'],
'rural',
[
"step",
["get", attribute],
"rgba(150, 0, 0, 1)",
steps["rural"][0],
"rgba(255, 0, 0, 1)",
steps["rural"][1],
"rgba(255, 220, 0, 1)",
steps["rural"][2],
"rgba(67, 200, 0, 1)",
steps["rural"][3],
"rgba(67, 150, 0, 1)",
'step',
['get', attribute],
'rgba(150, 0, 0, 1)',
steps['rural'][0],
'rgba(255, 0, 0, 1)',
steps['rural'][1],
'rgba(255, 220, 0, 1)',
steps['rural'][2],
'rgba(67, 200, 0, 1)',
steps['rural'][3],
'rgba(67, 150, 0, 1)',
],
"urban",
'urban',
[
"step",
["get", attribute],
"rgba(150, 0, 0, 1)",
steps["urban"][0],
"rgba(255, 0, 0, 1)",
steps["urban"][1],
"rgba(255, 220, 0, 1)",
steps["urban"][2],
"rgba(67, 200, 0, 1)",
steps["urban"][3],
"rgba(67, 150, 0, 1)",
'step',
['get', attribute],
'rgba(150, 0, 0, 1)',
steps['urban'][0],
'rgba(255, 0, 0, 1)',
steps['urban'][1],
'rgba(255, 220, 0, 1)',
steps['urban'][2],
'rgba(67, 200, 0, 1)',
steps['urban'][3],
'rgba(67, 150, 0, 1)',
],
[
"step",
["get", attribute],
"rgba(150, 0, 0, 1)",
steps["urban"][0],
"rgba(255, 0, 0, 1)",
steps["urban"][1],
"rgba(255, 220, 0, 1)",
steps["urban"][2],
"rgba(67, 200, 0, 1)",
steps["urban"][3],
"rgba(67, 150, 0, 1)",
'step',
['get', attribute],
'rgba(150, 0, 0, 1)',
steps['urban'][0],
'rgba(255, 0, 0, 1)',
steps['urban'][1],
'rgba(255, 220, 0, 1)',
steps['urban'][2],
'rgba(67, 200, 0, 1)',
steps['urban'][3],
'rgba(67, 150, 0, 1)',
],
],
];
]
}
export const trackLayer = {
type: "line",
type: 'line',
paint: {
"line-width": ["interpolate", ["linear"], ["zoom"], 14, 2, 17, 5],
"line-color": "#F06292",
"line-opacity": 0.6,
'line-width': ['interpolate', ['linear'], ['zoom'], 14, 2, 17, 5],
'line-color': '#F06292',
'line-opacity': 0.6,
},
};
}
export const getRegionLayers = (
adminLevel = 6,
baseColor = "#00897B",
maxValue = 5000
) => [
export const getRegionLayers = (adminLevel = 6, baseColor = '#00897B', maxValue = 5000) => [
{
id: "region",
type: "fill",
source: "obs",
"source-layer": "obs_regions",
id: 'region',
type: 'fill',
source: 'obs',
'source-layer': 'obs_regions',
minzoom: 0,
maxzoom: 10,
// filter: [">", "overtaking_event_count", 0],
paint: {
"fill-color": baseColor,
"fill-antialias": true,
"fill-opacity": [
"interpolate",
["linear"],
["log10", ["max",["get", "overtaking_event_count"],1]],
'fill-color': baseColor,
'fill-antialias': true,
'fill-opacity': [
'interpolate',
['linear'],
['log10', ['max', ['get', 'overtaking_event_count'], 1]],
0,
0,
Math.log10(maxValue),
@ -172,38 +149,38 @@ export const getRegionLayers = (
},
},
{
id: "region-border",
type: "line",
source: "obs",
"source-layer": "obs_regions",
id: 'region-border',
type: 'line',
source: 'obs',
'source-layer': 'obs_regions',
minzoom: 0,
maxzoom: 10,
// filter: [">", "overtaking_event_count", 0],
paint: {
"line-width": [
"interpolate",
["linear"],
["log10", ["max",["get", "overtaking_event_count"],1]],
'line-width': [
'interpolate',
['linear'],
['log10', ['max', ['get', 'overtaking_event_count'], 1]],
0,
0.2,
Math.log10(maxValue),
1.5,
],
"line-color": baseColor,
'line-color': baseColor,
},
layout: {
"line-join": "round",
"line-cap": "round",
'line-join': 'round',
'line-cap': 'round',
},
},
];
]
export const trackLayerRaw = produce(trackLayer, (draft) => {
// draft.paint['line-color'] = '#81D4FA'
draft.paint["line-width"][4] = 1;
draft.paint["line-width"][6] = 2;
draft.paint["line-dasharray"] = [3, 3];
delete draft.paint["line-opacity"];
});
draft.paint['line-width'][4] = 1
draft.paint['line-width'][6] = 2
draft.paint['line-dasharray'] = [3, 3]
delete draft.paint['line-opacity']
})
export const basemap = positron;
export const basemap = positron

View File

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

View File

@ -1,134 +1,121 @@
import React, { useState, useCallback, useMemo } from "react";
import { Source, Layer } from "react-map-gl";
import _ from "lodash";
import {
Button,
Form,
Dropdown,
Header,
Message,
Icon,
} from "semantic-ui-react";
import { useTranslation, Trans as Translate } from "react-i18next";
import Markdown from "react-markdown";
import React, {useState, useCallback, useMemo} from 'react'
import {Source, Layer} from 'react-map-gl'
import _ from 'lodash'
import {Button, Form, 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 { Page, Map } from "components";
import {useConfig} from 'config'
import {Page, Map} from 'components'
const BoundingBoxSelector = React.forwardRef(
({ value, name, onChange }, ref) => {
const { t } = useTranslation();
const [pointNum, setPointNum] = useState(0);
const [point0, setPoint0] = useState(null);
const [point1, setPoint1] = useState(null);
const BoundingBoxSelector = React.forwardRef(({value, name, onChange}, ref) => {
const {t} = useTranslation()
const [pointNum, setPointNum] = useState(0)
const [point0, setPoint0] = useState(null)
const [point1, setPoint1] = useState(null)
const onClick = (e) => {
if (pointNum == 0) {
setPoint0(e.lngLat);
} else {
setPoint1(e.lngLat);
}
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 onClick = (e) => {
if (pointNum == 0) {
setPoint0(e.lngLat)
} else {
setPoint1(e.lngLat)
}
setPointNum(1 - pointNum)
}
);
const MODES = ["events", "segments"];
const FORMATS = ["geojson", "shapefile"];
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']
const FORMATS = ['geojson', 'shapefile']
export default function ExportPage() {
const [mode, setMode] = useState("events");
const [bbox, setBbox] = useState("8.294678,49.651182,9.059601,50.108249");
const [fmt, setFmt] = useState("geojson");
const config = useConfig();
const { t } = useTranslation();
const [mode, setMode] = useState('events')
const [bbox, setBbox] = useState('8.294678,49.651182,9.059601,50.108249')
const [fmt, setFmt] = useState('geojson')
const config = useConfig()
const {t} = useTranslation()
return (
<Page title="Export">
<Header as="h2">{t("ExportPage.title")}</Header>
<Header as="h2">{t('ExportPage.title')}</Header>
<Message icon info>
<Icon name="info circle" />
<Message.Content>
<Markdown>{t("ExportPage.information")}</Markdown>
<Markdown>{t('ExportPage.information')}</Markdown>
</Message.Content>
</Message>
<Form>
<Form.Field>
<label>{t("ExportPage.mode.label")}</label>
<label>{t('ExportPage.mode.label')}</label>
<Dropdown
placeholder={t("ExportPage.mode.placeholder")}
placeholder={t('ExportPage.mode.placeholder')}
fluid
selection
options={MODES.map((value) => ({
@ -137,14 +124,14 @@ export default function ExportPage() {
value,
}))}
value={mode}
onChange={(_e, { value }) => setMode(value)}
onChange={(_e, {value}) => setMode(value)}
/>
</Form.Field>
<Form.Field>
<label>{t("ExportPage.format.label")}</label>
<label>{t('ExportPage.format.label')}</label>
<Dropdown
placeholder={t("ExportPage.format.placeholder")}
placeholder={t('ExportPage.format.placeholder')}
fluid
selection
options={FORMATS.map((value) => ({
@ -153,7 +140,7 @@ export default function ExportPage() {
value,
}))}
value={fmt}
onChange={(_e, { value }) => setFmt(value)}
onChange={(_e, {value}) => setFmt(value)}
/>
</Form.Field>
@ -170,5 +157,5 @@ export default function ExportPage() {
</Button>
</Form>
</Page>
);
)
}

View File

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

View File

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

View File

@ -1,296 +1,253 @@
import React, { useState, useCallback, useMemo, useRef } from "react";
import _ from "lodash";
import { connect } from "react-redux";
import { Button } from "semantic-ui-react";
import { Layer, Source } from "react-map-gl";
import produce from "immer";
import classNames from "classnames";
import React, {useState, useCallback, useMemo, useRef} from 'react'
import _ from 'lodash'
import {connect} from 'react-redux'
import {Button} from 'semantic-ui-react'
import {Layer, Source} from 'react-map-gl'
import produce from 'immer'
import classNames from 'classnames'
import api from "api";
import type { Location } from "types";
import { Page, Map } from "components";
import { useConfig } from "config";
import {
colorByDistance,
colorByCount,
getRegionLayers,
borderByZone,
isValidAttribute,
} from "mapstyles";
import { useMapConfig } from "reducers/mapConfig";
import api from 'api'
import type {Location} from 'types'
import {Page, Map} from 'components'
import {useConfig} from 'config'
import {colorByDistance, colorByCount, getRegionLayers, borderByZone, isValidAttribute} from 'mapstyles'
import {useMapConfig} from 'reducers/mapConfig'
import RoadInfo, { RoadInfoType } from "./RoadInfo";
import RegionInfo from "./RegionInfo";
import LayerSidebar from "./LayerSidebar";
import styles from "./styles.module.less";
import RoadInfo, {RoadInfoType} from './RoadInfo'
import RegionInfo from './RegionInfo'
import LayerSidebar from './LayerSidebar'
import styles from './styles.module.less'
const untaggedRoadsLayer = {
id: "obs_roads_untagged",
type: "line",
source: "obs",
"source-layer": "obs_roads",
id: 'obs_roads_untagged',
type: 'line',
source: 'obs',
'source-layer': 'obs_roads',
minzoom: 12,
filter: ["!", ["to-boolean", ["get", "distance_overtaker_mean"]]],
filter: ['!', ['to-boolean', ['get', 'distance_overtaker_mean']]],
layout: {
"line-cap": "round",
"line-join": "round",
'line-cap': 'round',
'line-join': 'round',
},
paint: {
"line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 12, 2, 17, 2],
"line-color": "#ABC",
'line-width': ['interpolate', ['exponential', 1.5], ['zoom'], 12, 2, 17, 2],
'line-color': '#ABC',
// "line-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1],
"line-offset": [
"interpolate",
["exponential", 1.5],
["zoom"],
'line-offset': [
'interpolate',
['exponential', 1.5],
['zoom'],
12,
["get", "offset_direction"],
['get', 'offset_direction'],
19,
["*", ["get", "offset_direction"], 8],
['*', ['get', 'offset_direction'], 8],
],
},
};
}
const getUntaggedRoadsLayer = (colorAttribute) =>
produce(untaggedRoadsLayer, (draft) => {
draft.filter = ["!", isValidAttribute(colorAttribute)];
});
draft.filter = ['!', isValidAttribute(colorAttribute)]
})
const getRoadsLayer = (colorAttribute, maxCount) =>
produce(untaggedRoadsLayer, (draft) => {
draft.id = "obs_roads_normal";
draft.filter = isValidAttribute(colorAttribute);
draft.minzoom = 10;
draft.paint["line-width"][6] = 6; // scale bigger on zoom
draft.paint["line-color"] = colorAttribute.startsWith("distance_")
draft.id = 'obs_roads_normal'
draft.filter = isValidAttribute(colorAttribute)
draft.minzoom = 10
draft.paint['line-width'][6] = 6 // scale bigger on zoom
draft.paint['line-color'] = colorAttribute.startsWith('distance_')
? colorByDistance(colorAttribute)
: colorAttribute.endsWith("_count")
: colorAttribute.endsWith('_count')
? colorByCount(colorAttribute, maxCount)
: colorAttribute.endsWith("zone")
: colorAttribute.endsWith('zone')
? borderByZone()
: "#DDD";
: '#DDD'
// draft.paint["line-opacity"][3] = 12;
// draft.paint["line-opacity"][5] = 13;
});
})
const getEventsLayer = () => ({
id: "obs_events",
type: "circle",
source: "obs",
"source-layer": "obs_events",
id: 'obs_events',
type: 'circle',
source: 'obs',
'source-layer': 'obs_events',
paint: {
"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-color": colorByDistance("distance_overtaker"),
'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-color': colorByDistance('distance_overtaker'),
},
minzoom: 8,
});
})
const getEventsTextLayer = () => ({
id: "obs_events_text",
type: "symbol",
id: 'obs_events_text',
type: 'symbol',
minzoom: 18,
source: "obs",
"source-layer": "obs_events",
source: 'obs',
'source-layer': 'obs_events',
layout: {
"text-field": [
"number-format",
["get", "distance_overtaker"],
{ "min-fraction-digits": 2, "max-fraction-digits": 2 },
'text-field': [
'number-format',
['get', 'distance_overtaker'],
{'min-fraction-digits': 2, 'max-fraction-digits': 2},
],
"text-allow-overlap": true,
"text-size": 14,
"text-keep-upright": false,
"text-anchor": "left",
"text-radial-offset": 1,
"text-rotate": ["-", 90, ["*", ["get", "course"], 180 / Math.PI]],
"text-rotation-alignment": "map",
'text-allow-overlap': true,
'text-size': 14,
'text-keep-upright': false,
'text-anchor': 'left',
'text-radial-offset': 1,
'text-rotate': ['-', 90, ['*', ['get', 'course'], 180 / Math.PI]],
'text-rotation-alignment': 'map',
},
paint: {
"text-halo-color": "rgba(255, 255, 255, 1)",
"text-halo-width": 1,
"text-opacity": ["interpolate", ["linear"], ["zoom"], 15, 0, 15.3, 1],
'text-halo-color': 'rgba(255, 255, 255, 1)',
'text-halo-width': 1,
'text-opacity': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.3, 1],
},
});
})
interface RegionInfo {
properties: {
admin_level: number;
name: string;
overtaking_event_count: number;
};
admin_level: number
name: string
overtaking_event_count: number
}
}
type Details =
| { type: "road"; road: RoadInfoType }
| { type: "region"; region: RegionInfo };
type Details = {type: 'road'; road: RoadInfoType} | {type: 'region'; region: RegionInfo}
function MapPage({ login }) {
const { obsMapSource, banner } = useConfig() || {};
const [details, setDetails] = useState<null | Details>(null);
function MapPage({login}) {
const {obsMapSource, banner} = useConfig() || {}
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 mapInfoPortal = useRef();
const viewportRef = useRef()
const mapInfoPortal = useRef()
const onViewportChange = useCallback(
(viewport) => {
viewportRef.current = viewport;
viewportRef.current = viewport
},
[viewportRef]
);
)
const onClick = useCallback(
async (e) => {
// check if we clicked inside the mapInfoBox, if so, early exit
let node = e.target;
let node = e.target
while (node) {
if (
[styles.mapInfoBox, styles.mapToolbar].some((className) =>
node?.classList?.contains(className)
)
) {
return;
if ([styles.mapInfoBox, styles.mapToolbar].some((className) => node?.classList?.contains(className))) {
return
}
node = node.parentNode;
node = node.parentNode
}
const { zoom } = viewportRef.current;
const {zoom} = viewportRef.current
if (zoom < 10) {
const clickedRegion = e.features?.find(
(f) => f.source === "obs" && f.sourceLayer === "obs_regions"
);
setDetails(
clickedRegion ? { type: "region", region: clickedRegion } : null
);
const clickedRegion = e.features?.find((f) => f.source === 'obs' && f.sourceLayer === 'obs_regions')
setDetails(clickedRegion ? {type: 'region', region: clickedRegion} : null)
} else {
const road = await api.get("/mapdetails/road", {
const road = await api.get('/mapdetails/road', {
query: {
longitude: e.lngLat[0],
latitude: e.lngLat[1],
radius: 100,
},
});
setDetails(road?.road ? { type: "road", road } : null);
})
setDetails(road?.road ? {type: 'road', road} : null)
}
},
[setDetails]
);
)
const [layerSidebar, setLayerSidebar] = useState(true);
const [layerSidebar, setLayerSidebar] = useState(true)
const {
obsRoads: { attribute, maxCount },
} = mapConfig;
obsRoads: {attribute, maxCount},
} = mapConfig
const layers = [];
const layers = []
const untaggedRoadsLayerCustom = useMemo(
() => getUntaggedRoadsLayer(attribute),
[attribute]
);
const untaggedRoadsLayerCustom = useMemo(() => getUntaggedRoadsLayer(attribute), [attribute])
if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) {
layers.push(untaggedRoadsLayerCustom);
layers.push(untaggedRoadsLayerCustom)
}
const roadsLayer = useMemo(
() => getRoadsLayer(attribute, maxCount),
[attribute, maxCount]
);
const roadsLayer = useMemo(() => getRoadsLayer(attribute, maxCount), [attribute, maxCount])
if (mapConfig.obsRoads.show) {
layers.push(roadsLayer);
layers.push(roadsLayer)
}
const regionLayers = useMemo(() => getRegionLayers(), []);
const regionLayers = useMemo(() => getRegionLayers(), [])
if (mapConfig.obsRegions.show) {
layers.push(...regionLayers);
layers.push(...regionLayers)
}
const eventsLayer = useMemo(() => getEventsLayer(), []);
const eventsTextLayer = useMemo(() => getEventsTextLayer(), []);
const eventsLayer = useMemo(() => getEventsLayer(), [])
const eventsTextLayer = useMemo(() => getEventsTextLayer(), [])
if (mapConfig.obsEvents.show) {
layers.push(eventsLayer);
layers.push(eventsTextLayer);
layers.push(eventsLayer)
layers.push(eventsTextLayer)
}
const onToggleLayerSidebarButtonClick = useCallback(
(e) => {
e.stopPropagation();
e.preventDefault();
console.log("toggl;e");
setLayerSidebar((v) => !v);
e.stopPropagation()
e.preventDefault()
console.log('toggl;e')
setLayerSidebar((v) => !v)
},
[setLayerSidebar]
);
)
if (!obsMapSource) {
return null;
return null
}
const tiles = obsMapSource?.tiles?.map((tileUrl: string) => {
const query = new URLSearchParams();
const query = new URLSearchParams()
if (login) {
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) {
query.append("start", mapConfig.filters.startDate);
query.append('start', mapConfig.filters.startDate)
}
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) {
query.append(
mapConfig.filters.thresholdAfter ? "start" : "end",
mapConfig.filters.startDate
);
query.append(mapConfig.filters.thresholdAfter ? 'start' : 'end', mapConfig.filters.startDate)
}
}
}
const queryString = String(query);
return tileUrl + (queryString ? "?" : "") + queryString;
});
const queryString = String(query)
return tileUrl + (queryString ? '?' : '') + queryString
})
const hasFilters: boolean =
login &&
(mapConfig.filters.currentUser || mapConfig.filters.dateMode !== "none");
const hasFilters: boolean = login && (mapConfig.filters.currentUser || mapConfig.filters.dateMode !== 'none')
return (
<Page fullScreen title="Map">
<div
className={classNames(
styles.mapContainer,
banner ? styles.hasBanner : null
)}
ref={mapInfoPortal}
>
<div className={classNames(styles.mapContainer, banner ? styles.hasBanner : null)} ref={mapInfoPortal}>
{layerSidebar && (
<div className={styles.mapSidebar}>
<LayerSidebar />
</div>
)}
<div className={styles.map}>
<Map
viewportFromUrl
onClick={onClick}
hasToolbar
onViewportChange={onViewportChange}
>
<Map viewportFromUrl onClick={onClick} hasToolbar onViewportChange={onViewportChange}>
<div className={styles.mapToolbar}>
<Button
primary
icon="bars"
active={layerSidebar}
onClick={onToggleLayerSidebarButtonClick}
/>
<Button primary icon="bars" active={layerSidebar} onClick={onToggleLayerSidebarButtonClick} />
</div>
<Source id="obs" {...obsMapSource} tiles={tiles}>
{layers.map((layer) => (
@ -298,27 +255,23 @@ function MapPage({ login }) {
))}
</Source>
{details?.type === "road" && details?.road?.road && (
{details?.type === 'road' && details?.road?.road && (
<RoadInfo
roadInfo={details.road}
mapInfoPortal={mapInfoPortal.current}
onClose={onCloseDetails}
{...{ hasFilters }}
{...{hasFilters}}
/>
)}
{details?.type === "region" && details?.region && (
<RegionInfo
region={details.region}
mapInfoPortal={mapInfoPortal.current}
onClose={onCloseDetails}
/>
{details?.type === 'region' && details?.region && (
<RegionInfo region={details.region} mapInfoPortal={mapInfoPortal.current} onClose={onCloseDetails} />
)}
</Map>
</div>
</div>
</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 { connect } from "react-redux";
import React, {useCallback, useMemo, useState} from 'react'
import {connect} from 'react-redux'
import {
Accordion,
Button,
@ -14,74 +14,65 @@ import {
SemanticCOLORS,
SemanticICONS,
Table,
} from "semantic-ui-react";
import { useObservable } from "rxjs-hooks";
import { Link } from "react-router-dom";
import { of, from, concat, BehaviorSubject, combineLatest } from "rxjs";
import { map, switchMap, distinctUntilChanged } from "rxjs/operators";
import _ from "lodash";
import { useTranslation } from "react-i18next";
} from 'semantic-ui-react'
import {useObservable} from 'rxjs-hooks'
import {Link} from 'react-router-dom'
import {of, from, concat, BehaviorSubject, combineLatest} from 'rxjs'
import {map, switchMap, distinctUntilChanged} from 'rxjs/operators'
import _ from 'lodash'
import {useTranslation} from 'react-i18next'
import type { ProcessingStatus, Track, UserDevice } from "types";
import { Page, FormattedDate, Visibility } from "components";
import api from "api";
import { useCallbackRef, formatDistance, formatDuration } from "utils";
import type {ProcessingStatus, Track, UserDevice} from 'types'
import {Page, FormattedDate, Visibility} from 'components'
import api from 'api'
import {useCallbackRef, formatDistance, formatDuration} from 'utils'
import download from "downloadjs";
import download from 'downloadjs'
const COLOR_BY_STATUS: Record<ProcessingStatus, SemanticCOLORS> = {
error: "red",
complete: "green",
created: "grey",
queued: "orange",
processing: "orange",
};
error: 'red',
complete: 'green',
created: 'grey',
queued: 'orange',
processing: 'orange',
}
const ICON_BY_STATUS: Record<ProcessingStatus, SemanticICONS> = {
error: "warning sign",
complete: "check circle outline",
created: "bolt",
queued: "bolt",
processing: "bolt",
};
error: 'warning sign',
complete: 'check circle outline',
created: 'bolt',
queued: 'bolt',
processing: 'bolt',
}
function ProcessingStatusLabel({ status }: { status: ProcessingStatus }) {
const { t } = useTranslation();
function ProcessingStatusLabel({status}: {status: ProcessingStatus}) {
const {t} = useTranslation()
return (
<span title={t(`TracksPage.processing.${status}`)}>
<Icon color={COLOR_BY_STATUS[status]} name={ICON_BY_STATUS[status]} />
</span>
);
)
}
function SortableHeader({
children,
setOrderBy,
orderBy,
reversed,
setReversed,
name,
...props
}) {
function SortableHeader({children, setOrderBy, orderBy, reversed, setReversed, name, ...props}) {
const toggleSort = (e) => {
e.preventDefault();
e.stopPropagation();
e.preventDefault()
e.stopPropagation()
if (orderBy === name) {
if (!reversed) {
setReversed(true);
setReversed(true)
} else {
setReversed(false);
setOrderBy(null);
setReversed(false)
setOrderBy(null)
}
} else {
setReversed(false);
setOrderBy(name);
setReversed(false)
setOrderBy(name)
}
};
}
let icon =
orderBy === name ? (reversed ? "sort descending" : "sort ascending") : null;
let icon = orderBy === name ? (reversed ? 'sort descending' : 'sort ascending') : null
return (
<Table.HeaderCell {...props}>
@ -90,22 +81,22 @@ function SortableHeader({
<Icon name={icon} />
</div>
</Table.HeaderCell>
);
)
}
type Filters = {
userDeviceId?: null | number;
visibility?: null | boolean;
};
userDeviceId?: null | number
visibility?: null | boolean
}
function TrackFilters({
filters,
setFilters,
deviceNames,
}: {
filters: Filters;
setFilters: (f: Filters) => void;
deviceNames: null | Record<number, string>;
filters: Filters
setFilters: (f: Filters) => void
deviceNames: null | Record<number, string>
}) {
return (
<List horizontal>
@ -115,19 +106,15 @@ function TrackFilters({
selection
clearable
options={[
{ value: 0, key: "__none__", text: "All my devices" },
..._.sortBy(Object.entries(deviceNames ?? {}), 1).map(
([deviceId, deviceName]: [string, string]) => ({
value: Number(deviceId),
key: deviceId,
text: deviceName,
})
),
{value: 0, key: '__none__', text: 'All my devices'},
..._.sortBy(Object.entries(deviceNames ?? {}), 1).map(([deviceId, deviceName]: [string, string]) => ({
value: Number(deviceId),
key: deviceId,
text: deviceName,
})),
]}
value={filters?.userDeviceId ?? 0}
onChange={(_e, { value }) =>
setFilters({ ...filters, userDeviceId: (value as number) || null })
}
onChange={(_e, {value}) => setFilters({...filters, userDeviceId: (value as number) || null})}
/>
</List.Item>
@ -137,54 +124,48 @@ function TrackFilters({
selection
clearable
options={[
{ value: "none", key: "any", text: "Any" },
{ value: true, key: "public", text: "Public" },
{ value: false, key: "private", text: "Private" },
{value: 'none', key: 'any', text: 'Any'},
{value: true, key: 'public', text: 'Public'},
{value: false, key: 'private', text: 'Private'},
]}
value={filters?.visibility ?? "none"}
onChange={(_e, { value }) =>
value={filters?.visibility ?? 'none'}
onChange={(_e, {value}) =>
setFilters({
...filters,
visibility: value === "none" ? null : (value as boolean),
visibility: value === 'none' ? null : (value as boolean),
})
}
/>
</List.Item>
</List>
);
)
}
function TracksTable({ title }) {
const [orderBy, setOrderBy] = useState("recordedAt");
const [reversed, setReversed] = useState(true);
const [showFilters, setShowFilters] = useState(false);
const [filters, setFilters] = useState<Filters>({});
const [selectedTracks, setSelectedTracks] = useState<Record<string, boolean>>(
{}
);
function TracksTable({title}) {
const [orderBy, setOrderBy] = useState('recordedAt')
const [reversed, setReversed] = useState(true)
const [showFilters, setShowFilters] = useState(false)
const [filters, setFilters] = useState<Filters>({})
const [selectedTracks, setSelectedTracks] = useState<Record<string, boolean>>({})
const toggleTrackSelection = useCallbackRef(
(slug: string, selected?: boolean) => {
const newSelected = selected ?? !selectedTracks[slug];
setSelectedTracks(
_.pickBy({ ...selectedTracks, [slug]: newSelected }, _.identity)
);
}
);
const toggleTrackSelection = useCallbackRef((slug: string, selected?: boolean) => {
const newSelected = selected ?? !selectedTracks[slug]
setSelectedTracks(_.pickBy({...selectedTracks, [slug]: newSelected}, _.identity))
})
const query = _.pickBy(
{
limit: 1000,
offset: 0,
order_by: orderBy,
reversed: reversed ? "true" : "false",
reversed: reversed ? 'true' : 'false',
user_device_id: filters?.userDeviceId,
public: filters?.visibility,
},
(x) => x != null
);
)
const forceUpdate$ = useMemo(() => new BehaviorSubject(null), []);
const forceUpdate$ = useMemo(() => new BehaviorSubject(null), [])
const tracks: Track[] | null = useObservable(
(_$, inputs$) =>
combineLatest([
@ -193,125 +174,91 @@ function TracksTable({ title }) {
distinctUntilChanged(_.isEqual)
),
forceUpdate$,
]).pipe(
switchMap(([query]) =>
concat(
of(null),
from(api.get("/tracks/feed", { query }).then((r) => r.tracks))
)
)
),
]).pipe(switchMap(([query]) => concat(of(null), from(api.get('/tracks/feed', {query}).then((r) => r.tracks))))),
null,
[query]
);
)
const deviceNames: null | Record<number, string> = useObservable(() =>
from(api.get("/user/devices")).pipe(
from(api.get('/user/devices')).pipe(
map((response: UserDevice[]) =>
Object.fromEntries(
response.map((device) => [
device.id,
device.displayName || device.identifier,
])
)
Object.fromEntries(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 noneSelected = selectedCount === 0;
const allSelected = selectedCount === tracks?.length;
const selectedCount = Object.keys(selectedTracks).length
const noneSelected = selectedCount === 0
const allSelected = selectedCount === tracks?.length
const selectAll = () => {
setSelectedTracks(
Object.fromEntries(tracks?.map((t) => [t.slug, true]) ?? [])
);
};
setSelectedTracks(Object.fromEntries(tracks?.map((t) => [t.slug, true]) ?? []))
}
const selectNone = () => {
setSelectedTracks({});
};
setSelectedTracks({})
}
const bulkAction = async (action: string) => {
const response = await api.post("/tracks/bulk", {
const response = await api.post('/tracks/bulk', {
body: {
action,
tracks: Object.keys(selectedTracks),
},
returnResponse: true,
});
if (action === "download") {
const contentType =
response.headers.get("content-type") ?? "application/x-gtar";
})
if (action === 'download') {
const contentType = response.headers.get('content-type') ?? 'application/x-gtar'
const filename =
response.headers
.get("content-disposition")
?.match(/filename="([^"]+)"/)?.[1] ?? "tracks.tar.bz2";
download(await response.blob(), filename, contentType);
const filename = response.headers.get('content-disposition')?.match(/filename="([^"]+)"/)?.[1] ?? 'tracks.tar.bz2'
download(await response.blob(), filename, contentType)
}
setShowBulkDelete(false);
setSelectedTracks({});
forceUpdate$.next(null);
};
const [showBulkDelete, setShowBulkDelete] = useState(false);
setShowBulkDelete(false)
setSelectedTracks({})
forceUpdate$.next(null)
}
const [showBulkDelete, setShowBulkDelete] = useState(false)
return (
<>
<div style={{ float: "right" }}>
<div style={{float: 'right'}}>
<Dropdown disabled={noneSelected} text="Bulk actions" floating button>
<Dropdown.Menu>
<Dropdown.Header>
Selection of {selectedCount} tracks
</Dropdown.Header>
<Dropdown.Item onClick={() => bulkAction("makePrivate")}>
Make private
</Dropdown.Item>
<Dropdown.Item onClick={() => bulkAction("makePublic")}>
Make public
</Dropdown.Item>
<Dropdown.Item onClick={() => bulkAction("reprocess")}>
Reprocess
</Dropdown.Item>
<Dropdown.Item onClick={() => bulkAction("download")}>
Download
</Dropdown.Item>
<Dropdown.Item onClick={() => setShowBulkDelete(true)}>
Delete
</Dropdown.Item>
<Dropdown.Header>Selection of {selectedCount} tracks</Dropdown.Header>
<Dropdown.Item onClick={() => bulkAction('makePrivate')}>Make private</Dropdown.Item>
<Dropdown.Item onClick={() => bulkAction('makePublic')}>Make public</Dropdown.Item>
<Dropdown.Item onClick={() => bulkAction('reprocess')}>Reprocess</Dropdown.Item>
<Dropdown.Item onClick={() => bulkAction('download')}>Download</Dropdown.Item>
<Dropdown.Item onClick={() => setShowBulkDelete(true)}>Delete</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
<Link component={UploadButton} to="/upload" />
</div>
<Header as="h1">{title}</Header>
<div style={{ clear: "both" }}>
<Loader content={t("general.loading")} active={tracks == null} />
<div style={{clear: 'both'}}>
<Loader content={t('general.loading')} active={tracks == null} />
<Accordion>
<Accordion.Title
active={showFilters}
index={0}
onClick={() => setShowFilters(!showFilters)}
>
<Accordion.Title active={showFilters} index={0} onClick={() => setShowFilters(!showFilters)}>
<Icon name="dropdown" />
Filters
</Accordion.Title>
<Accordion.Content active={showFilters}>
<TrackFilters {...{ filters, setFilters, deviceNames }} />
<TrackFilters {...{filters, setFilters, deviceNames}} />
</Accordion.Content>
</Accordion>
<Confirm
open={showBulkDelete}
onCancel={() => setShowBulkDelete(false)}
onConfirm={() => bulkAction("delete")}
onConfirm={() => bulkAction('delete')}
content={`Are you sure you want to delete ${selectedCount} tracks?`}
confirmButton={t("general.delete")}
cancelButton={t("general.cancel")}
confirmButton={t('general.delete')}
cancelButton={t('general.cancel')}
/>
<Table compact>
@ -356,11 +303,9 @@ function TracksTable({ title }) {
/>
</Table.Cell>
<Table.Cell>
{track.processingStatus == null ? null : (
<ProcessingStatusLabel status={track.processingStatus} />
)}
{track.processingStatus == null ? null : <ProcessingStatusLabel status={track.processingStatus} />}
<Item.Header as={Link} to={`/tracks/${track.slug}`}>
{track.title || t("general.unnamedTrack")}
{track.title || t('general.unnamedTrack')}
</Item.Header>
</Table.Cell>
@ -368,62 +313,48 @@ function TracksTable({ title }) {
<FormattedDate date={track.recordedAt} />
</Table.Cell>
<Table.Cell>
{track.public == null ? null : (
<Visibility public={track.public} />
)}
</Table.Cell>
<Table.Cell>{track.public == null ? null : <Visibility public={track.public} />}</Table.Cell>
<Table.Cell textAlign="right">
{formatDistance(track.length)}
</Table.Cell>
<Table.Cell textAlign="right">{formatDistance(track.length)}</Table.Cell>
<Table.Cell textAlign="right">
{formatDuration(track.duration)}
</Table.Cell>
<Table.Cell textAlign="right">{formatDuration(track.duration)}</Table.Cell>
<Table.Cell>
{track.userDeviceId
? deviceNames?.[track.userDeviceId] ?? "..."
: null}
</Table.Cell>
<Table.Cell>{track.userDeviceId ? deviceNames?.[track.userDeviceId] ?? '...' : null}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</div>
</>
);
)
}
function UploadButton({ navigate, ...props }) {
const { t } = useTranslation();
function UploadButton({navigate, ...props}) {
const {t} = useTranslation()
const onClick = useCallback(
(e) => {
e.preventDefault();
navigate();
e.preventDefault()
navigate()
},
[navigate]
);
)
return (
<Button onClick={onClick} {...props} color="green">
{t("TracksPage.upload")}
{t('TracksPage.upload')}
</Button>
);
)
}
const MyTracksPage = connect((state) => ({ login: (state as any).login }))(
function MyTracksPage({ login }) {
const { t } = useTranslation();
const MyTracksPage = connect((state) => ({login: (state as any).login}))(function MyTracksPage({login}) {
const {t} = useTranslation()
const title = t("TracksPage.titleUser");
const title = t('TracksPage.titleUser')
return (
<Page title={title}>
<TracksTable {...{ title }} />
</Page>
);
}
);
return (
<Page title={title}>
<TracksTable {...{title}} />
</Page>
)
})
export default MyTracksPage;
export default MyTracksPage

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import React from "react";
import { connect } from "react-redux";
import React from 'react'
import {connect} from 'react-redux'
import {
List,
Dropdown,
@ -12,354 +12,323 @@ import {
Message,
Confirm,
Container,
} from "semantic-ui-react";
import { useParams, useHistory } from "react-router-dom";
import { concat, combineLatest, of, from, Subject } from "rxjs";
import {
pluck,
distinctUntilChanged,
map,
switchMap,
startWith,
catchError,
} from "rxjs/operators";
import { useObservable } from "rxjs-hooks";
import Markdown from "react-markdown";
import { useTranslation } from "react-i18next";
} from 'semantic-ui-react'
import {useParams, useHistory} from 'react-router-dom'
import {concat, combineLatest, of, from, Subject} from 'rxjs'
import {pluck, distinctUntilChanged, map, 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 { Page } from "components";
import type { Track, TrackData, TrackComment } from "types";
import { trackLayer, trackLayerRaw } from "../../mapstyles";
import api from 'api'
import {Page} from 'components'
import type {Track, TrackData, TrackComment} from 'types'
import {trackLayer, trackLayerRaw} from '../../mapstyles'
import TrackActions from "./TrackActions";
import TrackComments from "./TrackComments";
import TrackDetails from "./TrackDetails";
import TrackMap from "./TrackMap";
import TrackActions from './TrackActions'
import TrackComments from './TrackComments'
import TrackDetails from './TrackDetails'
import TrackMap from './TrackMap'
import styles from "./TrackPage.module.less";
import styles from './TrackPage.module.less'
function useTriggerSubject() {
const subject$ = React.useMemo(() => new Subject(), []);
const trigger = React.useCallback(() => subject$.next(null), [subject$]);
return [trigger, subject$];
const subject$ = React.useMemo(() => new Subject(), [])
const trigger = React.useCallback(() => subject$.next(null), [subject$])
return [trigger, subject$]
}
function TrackMapSettings({
showTrack,
setShowTrack,
pointsMode,
setPointsMode,
side,
setSide,
}) {
const { t } = useTranslation();
function TrackMapSettings({showTrack, setShowTrack, pointsMode, setPointsMode, side, setSide}) {
const {t} = useTranslation()
return (
<>
<Header as="h4">{t("TrackPage.mapSettings.title")}</Header>
<Header as="h4">{t('TrackPage.mapSettings.title')}</Header>
<List>
<List.Item>
<Checkbox
checked={showTrack}
onChange={(e, d) => setShowTrack(d.checked)}
/>{" "}
{t("TrackPage.mapSettings.showTrack")}
<div style={{ marginTop: 8 }}>
<Checkbox checked={showTrack} onChange={(e, d) => setShowTrack(d.checked)} />{' '}
{t('TrackPage.mapSettings.showTrack')}
<div style={{marginTop: 8}}>
<span
style={{
borderTop: "3px dashed " + trackLayerRaw.paint["line-color"],
borderTop: '3px dashed ' + trackLayerRaw.paint['line-color'],
height: 0,
width: 24,
display: "inline-block",
verticalAlign: "middle",
display: 'inline-block',
verticalAlign: 'middle',
marginRight: 4,
}}
/>
{t("TrackPage.mapSettings.gpsTrack")}
{t('TrackPage.mapSettings.gpsTrack')}
</div>
<div>
<span
style={{
borderTop: "6px solid " + trackLayerRaw.paint["line-color"],
borderTop: '6px solid ' + trackLayerRaw.paint['line-color'],
height: 6,
width: 24,
display: "inline-block",
verticalAlign: "middle",
display: 'inline-block',
verticalAlign: 'middle',
marginRight: 4,
}}
/>
{t("TrackPage.mapSettings.snappedTrack")}
{t('TrackPage.mapSettings.snappedTrack')}
</div>
</List.Item>
<List.Item>
<List.Header> {t("TrackPage.mapSettings.points")} </List.Header>
<List.Header> {t('TrackPage.mapSettings.points')} </List.Header>
<Dropdown
selection
value={pointsMode}
onChange={(e, d) => setPointsMode(d.value)}
options={[
{ key: "none", value: "none", text: "None" },
{key: 'none', value: 'none', text: 'None'},
{
key: "overtakingEvents",
value: "overtakingEvents",
text: t("TrackPage.mapSettings.confirmedPoints"),
key: 'overtakingEvents',
value: 'overtakingEvents',
text: t('TrackPage.mapSettings.confirmedPoints'),
},
{
key: "measurements",
value: "measurements",
text: t("TrackPage.mapSettings.allPoints"),
key: 'measurements',
value: 'measurements',
text: t('TrackPage.mapSettings.allPoints'),
},
]}
/>
</List.Item>
<List.Item>
<List.Header>{t("TrackPage.mapSettings.side")}</List.Header>
<List.Header>{t('TrackPage.mapSettings.side')}</List.Header>
<Dropdown
selection
value={side}
onChange={(e, d) => setSide(d.value)}
options={[
{
key: "overtaker",
value: "overtaker",
text: t("TrackPage.mapSettings.overtakerSide"),
key: 'overtaker',
value: 'overtaker',
text: t('TrackPage.mapSettings.overtakerSide'),
},
{
key: "stationary",
value: "stationary",
text: t("TrackPage.mapSettings.stationarySide"),
key: 'stationary',
value: 'stationary',
text: t('TrackPage.mapSettings.stationarySide'),
},
]}
/>
</List.Item>
</List>
</>
);
)
}
const TrackPage = connect((state) => ({ login: state.login }))(
function TrackPage({ login }) {
const { slug } = useParams();
const { t } = useTranslation();
const TrackPage = connect((state) => ({login: state.login}))(function TrackPage({login}) {
const {slug} = useParams()
const {t} = useTranslation()
const [reloadComments, reloadComments$] = useTriggerSubject();
const history = useHistory();
const [reloadComments, reloadComments$] = useTriggerSubject()
const history = useHistory()
const data: {
track: null | Track;
trackData: null | TrackData;
comments: null | TrackComment[];
} | null = useObservable(
(_$, args$) => {
const slug$ = args$.pipe(pluck(0), distinctUntilChanged());
const track$ = slug$.pipe(
map((slug) => `/tracks/${slug}`),
switchMap((url) =>
concat(
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) =>
const data: {
track: null | Track
trackData: null | TrackData
comments: null | TrackComment[]
} | null = useObservable(
(_$, args$) => {
const slug$ = args$.pipe(pluck(0), distinctUntilChanged())
const track$ = slug$.pipe(
map((slug) => `/tracks/${slug}`),
switchMap((url) =>
concat(
of(null),
from(api.get(url)).pipe(
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(
map(([track, trackData, comments]) => ({
track,
trackData,
comments,
}))
);
},
null,
[slug]
);
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 onSubmitComment = React.useCallback(
async ({ body }) => {
await api.post(`/tracks/${slug}/comments`, {
body: { comment: { body } },
});
reloadComments();
},
[slug, reloadComments]
);
const comments$ = concat(of(null), reloadComments$).pipe(
switchMap(() => slug$),
map((slug) => `/tracks/${slug}/comments`),
switchMap((url) =>
from(api.get(url)).pipe(
catchError(() => {
return of(null)
})
)
),
pluck('comments'),
startWith(null) // show track infos before comments are loaded
)
const onDeleteComment = React.useCallback(
async (id) => {
await api.delete(`/tracks/${slug}/comments/${id}`);
reloadComments();
},
[slug, reloadComments]
);
return combineLatest([track$, trackData$, comments$]).pipe(
map(([track, trackData, comments]) => ({
track,
trackData,
comments,
}))
)
},
null,
[slug]
)
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));
}
const onSubmitComment = React.useCallback(
async ({body}) => {
await api.post(`/tracks/${slug}/comments`, {
body: {comment: {body}},
})
reloadComments()
},
[slug, reloadComments]
)
const onDeleteComment = React.useCallback(
async (id) => {
await api.delete(`/tracks/${slug}/comments/${id}`)
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 processing = ["processing", "queued", "created"].includes(
track?.processingStatus
);
const error = track?.processingStatus === "error";
const loading = track == null || trackData === undefined
const processing = ['processing', 'queued', 'created'].includes(track?.processingStatus)
const error = track?.processingStatus === 'error'
const [showTrack, setShowTrack] = React.useState(true);
const [pointsMode, setPointsMode] = React.useState("overtakingEvents"); // none|overtakingEvents|measurements
const [side, setSide] = React.useState("overtaker"); // overtaker|stationary
const [showTrack, setShowTrack] = React.useState(true)
const [pointsMode, setPointsMode] = React.useState('overtakingEvents') // none|overtakingEvents|measurements
const [side, setSide] = React.useState('overtaker') // overtaker|stationary
const title = track ? track.title || t("general.unnamedTrack") : null;
return (
<Page
title={title}
stage={
<>
<Container>
{track && (
<Segment basic>
<div
style={{
display: "flex",
alignItems: "baseline",
marginBlockStart: 32,
marginBlockEnd: 16,
}}
>
<Header as="h1">{title}</Header>
<div style={{ marginLeft: "auto" }}>
<TrackActions {...{ isAuthor, onDownload, slug }} />
</div>
const title = track ? track.title || t('general.unnamedTrack') : null
return (
<Page
title={title}
stage={
<>
<Container>
{track && (
<Segment basic>
<div
style={{
display: 'flex',
alignItems: 'baseline',
marginBlockStart: 32,
marginBlockEnd: 16,
}}
>
<Header as="h1">{title}</Header>
<div style={{marginLeft: 'auto'}}>
<TrackActions {...{isAuthor, onDownload, slug}} />
</div>
</div>
<div style={{ marginBlockEnd: 16 }}>
<TrackDetails {...{ track, isAuthor }} />
</div>
</Segment>
)}
</Container>
<div className={styles.stage}>
<Loader active={loading} />
<Dimmer.Dimmable blurring dimmed={loading}>
<TrackMap
{...{ track, trackData, pointsMode, side, showTrack }}
style={{ height: "80vh" }}
<div style={{marginBlockEnd: 16}}>
<TrackDetails {...{track, isAuthor}} />
</div>
</Segment>
)}
</Container>
<div className={styles.stage}>
<Loader active={loading} />
<Dimmer.Dimmable blurring dimmed={loading}>
<TrackMap {...{track, trackData, pointsMode, side, showTrack}} style={{height: '80vh'}} />
</Dimmer.Dimmable>
<div className={styles.details}>
<Segment>
<TrackMapSettings
{...{
showTrack,
setShowTrack,
pointsMode,
setPointsMode,
side,
setSide,
}}
/>
</Dimmer.Dimmable>
</Segment>
<div className={styles.details}>
<Segment>
<TrackMapSettings
{...{
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>
</>
{processing && (
<Message warning>
<Message.Content>{t('TrackPage.processing')}</Message.Content>
</Message>
)}
<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>
);
}
);
{error && (
<Message error>
<Message.Content>{t('TrackPage.processingError')}</Message.Content>
</Message>
)}
</div>
</div>
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 { connect } from "react-redux";
import {
Button,
Message,
Item,
Header,
Loader,
Pagination,
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 React, {useCallback} from 'react'
import {connect} from 'react-redux'
import {Button, Message, Item, Header, Loader, Pagination, 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 {
Avatar,
Page,
StripMarkdown,
FormattedDate,
Visibility,
} from "components";
import api from "api";
import { useQueryParam } from "query";
import type {Track} from 'types'
import {Avatar, Page, StripMarkdown, FormattedDate, Visibility} from 'components'
import api from 'api'
import {useQueryParam} from 'query'
function TrackList({ privateTracks }: { privateTracks: boolean }) {
const [page, setPage] = useQueryParam<number>("page", 1, Number);
function TrackList({privateTracks}: {privateTracks: boolean}) {
const [page, setPage] = useQueryParam<number>('page', 1, Number)
const pageSize = 10;
const pageSize = 10
const data: {
tracks: Track[];
trackCount: number;
tracks: Track[]
trackCount: number
} | null = useObservable(
(_$, inputs$) =>
inputs$.pipe(
map(([page, privateTracks]) => {
const url = "/tracks" + (privateTracks ? "/feed" : "");
const query = { limit: pageSize, offset: pageSize * (page - 1) };
return { url, query };
const url = '/tracks' + (privateTracks ? '/feed' : '')
const query = {limit: pageSize, offset: pageSize * (page - 1)}
return {url, query}
}),
distinctUntilChanged(_.isEqual),
switchMap((request) =>
concat(of(null), from(api.get(request.url, { query: request.query })))
)
switchMap((request) => concat(of(null), from(api.get(request.url, {query: request.query}))))
),
null,
[page, privateTracks]
);
)
const { tracks, trackCount } = data || { tracks: [], trackCount: 0 };
const loading = !data;
const totalPages = Math.ceil(trackCount / pageSize);
const { t } = useTranslation();
const {tracks, trackCount} = data || {tracks: [], trackCount: 0}
const loading = !data
const totalPages = Math.ceil(trackCount / pageSize)
const {t} = useTranslation()
return (
<div>
<Loader content={t("general.loading")} active={loading} />
<Loader content={t('general.loading')} active={loading} />
{!loading && totalPages > 1 && (
<Pagination
activePage={page}
@ -71,14 +55,14 @@ function TrackList({ privateTracks }: { privateTracks: boolean }) {
{tracks && tracks.length ? (
<Item.Group divided>
{tracks.map((track: Track) => (
<TrackListItem key={track.slug} {...{ track, privateTracks }} />
<TrackListItem key={track.slug} {...{track, privateTracks}} />
))}
</Item.Group>
) : (
<NoPublicTracksMessage />
)}
</div>
);
)
}
export function NoPublicTracksMessage() {
@ -88,27 +72,27 @@ export function NoPublicTracksMessage() {
No public tracks yet. <Link to="/upload">Upload the first!</Link>
</Translate>
</Message>
);
)
}
function maxLength(t: string | null, max: number): string | null {
if (t && t.length > max) {
return t.substring(0, max) + " ...";
return t.substring(0, max) + ' ...'
} else {
return t;
return t
}
}
const COLOR_BY_STATUS = {
error: "red",
complete: "green",
created: "gray",
queued: "orange",
processing: "orange",
};
error: 'red',
complete: 'green',
created: 'gray',
queued: 'orange',
processing: 'orange',
}
export function TrackListItem({ track, privateTracks = false }) {
const { t } = useTranslation();
export function TrackListItem({track, privateTracks = false}) {
const {t} = useTranslation()
return (
<Item key={track.slug}>
@ -117,14 +101,10 @@ export function TrackListItem({ track, privateTracks = false }) {
</Item.Image>
<Item.Content>
<Item.Header as={Link} to={`/tracks/${track.slug}`}>
{track.title || t("general.unnamedTrack")}
{track.title || t('general.unnamedTrack')}
</Item.Header>
<Item.Meta>
{privateTracks ? null : (
<span>
{t("TracksPage.createdBy", { author: track.author.displayName })}
</span>
)}
{privateTracks ? null : <span>{t('TracksPage.createdBy', {author: track.author.displayName})}</span>}
<span>
<FormattedDate date={track.createdAt} />
</span>
@ -136,57 +116,44 @@ export function TrackListItem({ track, privateTracks = false }) {
<Item.Extra>
<Visibility public={track.public} />
<span style={{ marginLeft: "1em" }}>
<Icon
color={COLOR_BY_STATUS[track.processingStatus]}
name="bolt"
fitted
/>{" "}
<span style={{marginLeft: '1em'}}>
<Icon color={COLOR_BY_STATUS[track.processingStatus]} name="bolt" fitted />{' '}
{t(`TracksPage.processing.${track.processingStatus}`)}
</span>
</Item.Extra>
)}
</Item.Content>
</Item>
);
)
}
function UploadButton({ navigate, ...props }) {
const { t } = useTranslation();
function UploadButton({navigate, ...props}) {
const {t} = useTranslation()
const onClick = useCallback(
(e) => {
e.preventDefault();
navigate();
e.preventDefault()
navigate()
},
[navigate]
);
)
return (
<Button
onClick={onClick}
{...props}
color="green"
style={{ float: "right" }}
>
{t("TracksPage.upload")}
<Button onClick={onClick} {...props} color="green" style={{float: 'right'}}>
{t('TracksPage.upload')}
</Button>
);
)
}
const TracksPage = connect((state) => ({ login: (state as any).login }))(
function TracksPage({ login, privateTracks }) {
const { t } = useTranslation();
const title = privateTracks
? t("TracksPage.titleUser")
: t("TracksPage.titlePublic");
const TracksPage = connect((state) => ({login: (state as any).login}))(function TracksPage({login, privateTracks}) {
const {t} = useTranslation()
const title = privateTracks ? t('TracksPage.titleUser') : t('TracksPage.titlePublic')
return (
<Page title={title}>
<Header as="h2">{title}</Header>
{privateTracks && <Link component={UploadButton} to="/upload" />}
<TrackList {...{ privateTracks }} />
</Page>
);
}
);
return (
<Page title={title}>
<Header as="h2">{title}</Header>
{privateTracks && <Link component={UploadButton} to="/upload" />}
<TrackList {...{privateTracks}} />
</Page>
)
})
export default TracksPage;
export default TracksPage

View File

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

View File

@ -1,13 +1,13 @@
export { default as AcknowledgementsPage } from "./AcknowledgementsPage";
export { default as ExportPage } from "./ExportPage";
export { default as HomePage } from "./HomePage";
export { default as LoginRedirectPage } from "./LoginRedirectPage";
export { default as LogoutPage } from "./LogoutPage";
export { default as MapPage } from "./MapPage";
export { default as NotFoundPage } from "./NotFoundPage";
export { default as SettingsPage } from "./SettingsPage";
export { default as TrackEditor } from "./TrackEditor";
export { default as TrackPage } from "./TrackPage";
export { default as TracksPage } from "./TracksPage";
export { default as MyTracksPage } from "./MyTracksPage";
export { default as UploadPage } from "./UploadPage";
export {default as AcknowledgementsPage} from './AcknowledgementsPage'
export {default as ExportPage} from './ExportPage'
export {default as HomePage} from './HomePage'
export {default as LoginRedirectPage} from './LoginRedirectPage'
export {default as LogoutPage} from './LogoutPage'
export {default as MapPage} from './MapPage'
export {default as NotFoundPage} from './NotFoundPage'
export {default as SettingsPage} from './SettingsPage'
export {default as TrackEditor} from './TrackEditor'
export {default as TrackPage} from './TrackPage'
export {default as TracksPage} from './TracksPage'
export {default as MyTracksPage} from './MyTracksPage'
export {default as UploadPage} from './UploadPage'

View File

@ -53,7 +53,7 @@ export function useQueryParam<T extends QueryValue>(
): [T, (newValue: T) => void] {
const history = useHistory()
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
}
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 {
username: string;
displayName: string;
image?: string | null;
bio?: string | null;
username: string
displayName: string
image?: string | null
bio?: string | null
}
export interface TrackData {
track: Feature<LineString>;
measurements: FeatureCollection;
overtakingEvents: FeatureCollection;
track: Feature<LineString>
measurements: FeatureCollection
overtakingEvents: FeatureCollection
}
export type ProcessingStatus =
| "error"
| "complete"
| "created"
| "queued"
| "processing";
export type ProcessingStatus = 'error' | 'complete' | 'created' | 'queued' | 'processing'
export interface Track {
slug: string;
author: UserProfile;
title: string;
description?: string;
createdAt: string;
processingStatus?: ProcessingStatus;
public?: boolean;
recordedAt?: Date;
recordedUntil?: Date;
duration?: number;
length?: number;
segments?: number;
numEvents?: number;
numMeasurements?: number;
numValid?: number;
userDeviceId?: number;
slug: string
author: UserProfile
title: string
description?: string
createdAt: string
processingStatus?: ProcessingStatus
public?: boolean
recordedAt?: Date
recordedUntil?: Date
duration?: number
length?: number
segments?: number
numEvents?: number
numMeasurements?: number
numValid?: number
userDeviceId?: number
}
export interface TrackPoint {
type: "Feature";
geometry: Point;
type: 'Feature'
geometry: Point
properties: {
distanceOvertaker: null | number;
distanceStationary: null | number;
};
distanceOvertaker: null | number
distanceStationary: null | number
}
}
export interface TrackComment {
id: string;
body: string;
createdAt: string;
author: UserProfile;
id: string
body: string
createdAt: string
author: UserProfile
}
export interface Location {
longitude: number;
latitude: number;
longitude: number
latitude: number
}
export interface UserDevice {
id: number;
identifier: string;
displayName?: string;
id: number
identifier: string
displayName?: string
}

View File

@ -1,51 +1,49 @@
import { useRef, useCallback } from "react";
import { Duration } from "luxon";
import {useRef, useCallback} from 'react'
import {Duration} from 'luxon'
// Wraps the register callback from useForm into a new ref function, such that
// any child of the provided element that is an input component will be
// registered.
export function findInput(register) {
return (element) => {
const found = element
? element.querySelector("input, textarea, select, checkbox")
: null;
register(found);
};
const found = element ? element.querySelector('input, textarea, select, checkbox') : null
register(found)
}
}
// Generates pairs from the input iterable
export function* pairwise(it) {
let lastValue;
let firstRound = true;
let lastValue
let firstRound = true
for (const i of it) {
if (firstRound) {
firstRound = false;
firstRound = false
} else {
yield [lastValue, i];
yield [lastValue, i]
}
lastValue = i;
lastValue = i
}
}
export function useCallbackRef(fn) {
const fnRef = useRef();
fnRef.current = fn;
return useCallback((...args) => fnRef.current(...args), []);
const fnRef = useRef()
fnRef.current = fn
return useCallback((...args) => fnRef.current(...args), [])
}
export function formatDuration(seconds) {
return Duration.fromMillis((seconds ?? 0) * 1000).toFormat("h'h' mm'm'");
return Duration.fromMillis((seconds ?? 0) * 1000).toFormat("h'h' mm'm'")
}
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) {
return `${meters.toFixed(0)} m`;
return `${meters.toFixed(0)} m`
} 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,
'/api': apiUrl,
'/login': apiUrl,
'/tiles': apiUrl
'/tiles': apiUrl,
},
},
module: {
@ -210,10 +210,12 @@ module.exports = function (webpackEnv) {
{
test: /\.ya?ml$/,
type: 'json',
use: [{
loader: 'yaml-loader',
options: {asJSON: true},
}],
use: [
{
loader: 'yaml-loader',
options: {asJSON: true},
},
],
},
{
test: /\.css$/i,