Format all JS/TS files
This commit is contained in:
parent
ba7de7582d
commit
278bcfc603
|
@ -1,24 +1,17 @@
|
||||||
import React from "react";
|
import React from 'react'
|
||||||
import classnames from "classnames";
|
import classnames from 'classnames'
|
||||||
import { connect } from "react-redux";
|
import {connect} from 'react-redux'
|
||||||
import {
|
import {List, Grid, Container, Menu, Header, Dropdown} from 'semantic-ui-react'
|
||||||
List,
|
import {BrowserRouter as Router, Switch, Route, Link} from 'react-router-dom'
|
||||||
Grid,
|
import {useObservable} from 'rxjs-hooks'
|
||||||
Container,
|
import {from} from 'rxjs'
|
||||||
Menu,
|
import {pluck} from 'rxjs/operators'
|
||||||
Header,
|
import {Helmet} from 'react-helmet'
|
||||||
Dropdown,
|
import {useTranslation} from 'react-i18next'
|
||||||
} from "semantic-ui-react";
|
|
||||||
import { BrowserRouter as Router, Switch, Route, Link } from "react-router-dom";
|
|
||||||
import { useObservable } from "rxjs-hooks";
|
|
||||||
import { from } from "rxjs";
|
|
||||||
import { pluck } from "rxjs/operators";
|
|
||||||
import { Helmet } from "react-helmet";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useConfig } from "config";
|
import {useConfig} from 'config'
|
||||||
import styles from "./App.module.less";
|
import styles from './App.module.less'
|
||||||
import { AVAILABLE_LOCALES, setLocale } from "i18n";
|
import {AVAILABLE_LOCALES, setLocale} from 'i18n'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AcknowledgementsPage,
|
AcknowledgementsPage,
|
||||||
|
@ -34,60 +27,50 @@ import {
|
||||||
TracksPage,
|
TracksPage,
|
||||||
UploadPage,
|
UploadPage,
|
||||||
MyTracksPage,
|
MyTracksPage,
|
||||||
} from "pages";
|
} from 'pages'
|
||||||
import { Avatar, LoginButton } from "components";
|
import {Avatar, LoginButton} from 'components'
|
||||||
import api from "api";
|
import api from 'api'
|
||||||
|
|
||||||
// This component removes the "navigate" prop before rendering a Menu.Item,
|
// This component removes the "navigate" prop before rendering a Menu.Item,
|
||||||
// which is a workaround for an annoying warning that is somehow caused by the
|
// which is a workaround for an annoying warning that is somehow caused by the
|
||||||
// <Link /> and <Menu.Item /> combination.
|
// <Link /> and <Menu.Item /> combination.
|
||||||
function MenuItemForLink({ navigate, ...props }) {
|
function MenuItemForLink({navigate, ...props}) {
|
||||||
return (
|
return (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
{...props}
|
{...props}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
navigate();
|
navigate()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
function DropdownItemForLink({ navigate, ...props }) {
|
function DropdownItemForLink({navigate, ...props}) {
|
||||||
return (
|
return (
|
||||||
<Dropdown.Item
|
<Dropdown.Item
|
||||||
{...props}
|
{...props}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
navigate();
|
navigate()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Banner({
|
function Banner({text, style = 'warning'}: {text: string; style: 'warning' | 'info'}) {
|
||||||
text,
|
return <div className={classnames(styles.banner, styles[style])}>{text}</div>
|
||||||
style = "warning",
|
|
||||||
}: {
|
|
||||||
text: string;
|
|
||||||
style: "warning" | "info";
|
|
||||||
}) {
|
|
||||||
return <div className={classnames(styles.banner, styles[style])}>{text}</div>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const App = connect((state) => ({ login: state.login }))(function App({
|
const App = connect((state) => ({login: state.login}))(function App({login}) {
|
||||||
login,
|
const {t} = useTranslation()
|
||||||
}) {
|
const config = useConfig()
|
||||||
const { t } = useTranslation();
|
const apiVersion = useObservable(() => from(api.get('/info')).pipe(pluck('version')))
|
||||||
const config = useConfig();
|
|
||||||
const apiVersion = useObservable(() =>
|
|
||||||
from(api.get("/info")).pipe(pluck("version"))
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasMap = Boolean(config?.obsMapSource);
|
const hasMap = Boolean(config?.obsMapSource)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
api.loadUser();
|
api.loadUser()
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
return config ? (
|
return config ? (
|
||||||
<Router basename={config.basename}>
|
<Router basename={config.basename}>
|
||||||
|
@ -98,59 +81,41 @@ const App = connect((state) => ({ login: state.login }))(function App({
|
||||||
{config?.banner && <Banner {...config.banner} />}
|
{config?.banner && <Banner {...config.banner} />}
|
||||||
<Menu className={styles.menu} stackable>
|
<Menu className={styles.menu} stackable>
|
||||||
<Container>
|
<Container>
|
||||||
<Link
|
<Link to="/" component={MenuItemForLink} header className={styles.pageTitle}>
|
||||||
to="/"
|
|
||||||
component={MenuItemForLink}
|
|
||||||
header
|
|
||||||
className={styles.pageTitle}
|
|
||||||
>
|
|
||||||
OpenBikeSensor
|
OpenBikeSensor
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{hasMap && (
|
{hasMap && (
|
||||||
<Link component={MenuItemForLink} to="/map" as="a">
|
<Link component={MenuItemForLink} to="/map" as="a">
|
||||||
{t("App.menu.map")}
|
{t('App.menu.map')}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Link component={MenuItemForLink} to="/tracks" as="a">
|
<Link component={MenuItemForLink} to="/tracks" as="a">
|
||||||
{t("App.menu.tracks")}
|
{t('App.menu.tracks')}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link component={MenuItemForLink} to="/export" as="a">
|
<Link component={MenuItemForLink} to="/export" as="a">
|
||||||
{t("App.menu.export")}
|
{t('App.menu.export')}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Menu.Menu position="right">
|
<Menu.Menu position="right">
|
||||||
{login ? (
|
{login ? (
|
||||||
<>
|
<>
|
||||||
<Link component={MenuItemForLink} to="/my/tracks" as="a">
|
<Link component={MenuItemForLink} to="/my/tracks" as="a">
|
||||||
{t("App.menu.myTracks")}
|
{t('App.menu.myTracks')}
|
||||||
</Link>
|
</Link>
|
||||||
<Dropdown
|
<Dropdown item trigger={<Avatar user={login} className={styles.avatar} />}>
|
||||||
item
|
|
||||||
trigger={<Avatar user={login} className={styles.avatar} />}
|
|
||||||
>
|
|
||||||
<Dropdown.Menu>
|
<Dropdown.Menu>
|
||||||
<Link
|
<Link
|
||||||
to="/upload"
|
to="/upload"
|
||||||
component={DropdownItemForLink}
|
component={DropdownItemForLink}
|
||||||
icon="cloud upload"
|
icon="cloud upload"
|
||||||
text={t("App.menu.uploadTracks")}
|
text={t('App.menu.uploadTracks')}
|
||||||
/>
|
|
||||||
<Link
|
|
||||||
to="/settings"
|
|
||||||
component={DropdownItemForLink}
|
|
||||||
icon="cog"
|
|
||||||
text={t("App.menu.settings")}
|
|
||||||
/>
|
/>
|
||||||
|
<Link to="/settings" component={DropdownItemForLink} icon="cog" text={t('App.menu.settings')} />
|
||||||
<Dropdown.Divider />
|
<Dropdown.Divider />
|
||||||
<Link
|
<Link to="/logout" component={DropdownItemForLink} icon="sign-out" text={t('App.menu.logout')} />
|
||||||
to="/logout"
|
|
||||||
component={DropdownItemForLink}
|
|
||||||
icon="sign-out"
|
|
||||||
text={t("App.menu.logout")}
|
|
||||||
/>
|
|
||||||
</Dropdown.Menu>
|
</Dropdown.Menu>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</>
|
</>
|
||||||
|
@ -216,14 +181,10 @@ const App = connect((state) => ({ login: state.login }))(function App({
|
||||||
<Grid columns={4} stackable>
|
<Grid columns={4} stackable>
|
||||||
<Grid.Row>
|
<Grid.Row>
|
||||||
<Grid.Column>
|
<Grid.Column>
|
||||||
<Header as="h5">{t("App.footer.aboutTheProject")}</Header>
|
<Header as="h5">{t('App.footer.aboutTheProject')}</Header>
|
||||||
<List>
|
<List>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<a
|
<a href="https://openbikesensor.org/" target="_blank" rel="noreferrer">
|
||||||
href="https://openbikesensor.org/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
openbikesensor.org
|
openbikesensor.org
|
||||||
</a>
|
</a>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
|
@ -231,94 +192,66 @@ const App = connect((state) => ({ login: state.login }))(function App({
|
||||||
</Grid.Column>
|
</Grid.Column>
|
||||||
|
|
||||||
<Grid.Column>
|
<Grid.Column>
|
||||||
<Header as="h5">{t("App.footer.getInvolved")}</Header>
|
<Header as="h5">{t('App.footer.getInvolved')}</Header>
|
||||||
<List>
|
<List>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<a
|
<a href="https://forum.openbikesensor.org/" target="_blank" rel="noreferrer">
|
||||||
href="https://forum.openbikesensor.org/"
|
{t('App.footer.getHelpInForum')}
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{t("App.footer.getHelpInForum")}
|
|
||||||
</a>
|
</a>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<a
|
<a href="https://github.com/openbikesensor/portal/issues/new" target="_blank" rel="noreferrer">
|
||||||
href="https://github.com/openbikesensor/portal/issues/new"
|
{t('App.footer.reportAnIssue')}
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{t("App.footer.reportAnIssue")}
|
|
||||||
</a>
|
</a>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<a
|
<a href="https://github.com/openbikesensor/portal" target="_blank" rel="noreferrer">
|
||||||
href="https://github.com/openbikesensor/portal"
|
{t('App.footer.development')}
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{t("App.footer.development")}
|
|
||||||
</a>
|
</a>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
</List>
|
</List>
|
||||||
</Grid.Column>
|
</Grid.Column>
|
||||||
|
|
||||||
<Grid.Column>
|
<Grid.Column>
|
||||||
<Header as="h5">{t("App.footer.thisInstallation")}</Header>
|
<Header as="h5">{t('App.footer.thisInstallation')}</Header>
|
||||||
<List>
|
<List>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<a
|
<a href={config?.privacyPolicyUrl} target="_blank" rel="noreferrer">
|
||||||
href={config?.privacyPolicyUrl}
|
{t('App.footer.privacyPolicy')}
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{t("App.footer.privacyPolicy")}
|
|
||||||
</a>
|
</a>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<a
|
<a href={config?.imprintUrl} target="_blank" rel="noreferrer">
|
||||||
href={config?.imprintUrl}
|
{t('App.footer.imprint')}
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{t("App.footer.imprint")}
|
|
||||||
</a>
|
</a>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
{config?.termsUrl && (
|
{config?.termsUrl && (
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<a
|
<a href={config?.termsUrl} target="_blank" rel="noreferrer">
|
||||||
href={config?.termsUrl}
|
{t('App.footer.terms')}
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{t("App.footer.terms")}
|
|
||||||
</a>
|
</a>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
)}
|
)}
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<a
|
<a
|
||||||
href={`https://github.com/openbikesensor/portal${
|
href={`https://github.com/openbikesensor/portal${
|
||||||
apiVersion ? `/releases/tag/${apiVersion}` : ""
|
apiVersion ? `/releases/tag/${apiVersion}` : ''
|
||||||
}`}
|
}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
{apiVersion
|
{apiVersion ? t('App.footer.version', {apiVersion}) : t('App.footer.versionLoading')}
|
||||||
? t("App.footer.version", { apiVersion })
|
|
||||||
: t("App.footer.versionLoading")}
|
|
||||||
</a>
|
</a>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
</List>
|
</List>
|
||||||
</Grid.Column>
|
</Grid.Column>
|
||||||
|
|
||||||
<Grid.Column>
|
<Grid.Column>
|
||||||
<Header as="h5">{t("App.footer.changeLanguage")}</Header>
|
<Header as="h5">{t('App.footer.changeLanguage')}</Header>
|
||||||
<List>
|
<List>
|
||||||
{AVAILABLE_LOCALES.map((locale) => (
|
{AVAILABLE_LOCALES.map((locale) => (
|
||||||
<List.Item key={locale}>
|
<List.Item key={locale}>
|
||||||
<a onClick={() => setLocale(locale)}>
|
<a onClick={() => setLocale(locale)}>{t(`locales.${locale}`)}</a>
|
||||||
{t(`locales.${locale}`)}
|
|
||||||
</a>
|
|
||||||
</List.Item>
|
</List.Item>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
|
@ -328,7 +261,7 @@ const App = connect((state) => ({ login: state.login }))(function App({
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
</Router>
|
</Router>
|
||||||
) : null;
|
) : null
|
||||||
});
|
})
|
||||||
|
|
||||||
export default App;
|
export default App
|
||||||
|
|
|
@ -1,42 +1,39 @@
|
||||||
import React from "react";
|
import React from 'react'
|
||||||
import { Comment } from "semantic-ui-react";
|
import {Comment} from 'semantic-ui-react'
|
||||||
import classnames from "classnames";
|
import classnames from 'classnames'
|
||||||
|
|
||||||
import "./styles.less";
|
import './styles.less'
|
||||||
|
|
||||||
function hashCode(s) {
|
function hashCode(s) {
|
||||||
let hash = 0;
|
let hash = 0
|
||||||
for (let i = 0; i < s.length; i++) {
|
for (let i = 0; i < s.length; i++) {
|
||||||
hash = (hash << 5) - hash + s.charCodeAt(i);
|
hash = (hash << 5) - hash + s.charCodeAt(i)
|
||||||
hash |= 0;
|
hash |= 0
|
||||||
}
|
}
|
||||||
return hash;
|
return hash
|
||||||
}
|
}
|
||||||
|
|
||||||
function getColor(s) {
|
function getColor(s) {
|
||||||
const h = Math.floor(hashCode(s)) % 360;
|
const h = Math.floor(hashCode(s)) % 360
|
||||||
return `hsl(${h}, 50%, 50%)`;
|
return `hsl(${h}, 50%, 50%)`
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Avatar({ user, className }) {
|
export default function Avatar({user, className}) {
|
||||||
const { image, displayName } = user || {};
|
const {image, displayName} = user || {}
|
||||||
|
|
||||||
if (image) {
|
if (image) {
|
||||||
return <Comment.Avatar src={image} className={className} />;
|
return <Comment.Avatar src={image} className={className} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!displayName) {
|
if (!displayName) {
|
||||||
return <div className={classnames(className, "avatar", "empty-avatar")} />;
|
return <div className={classnames(className, 'avatar', 'empty-avatar')} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const color = getColor(displayName);
|
const color = getColor(displayName)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={classnames(className, 'avatar', 'text-avatar')} style={{background: color}}>
|
||||||
className={classnames(className, "avatar", "text-avatar")}
|
|
||||||
style={{ background: color }}
|
|
||||||
>
|
|
||||||
{displayName && <span>{displayName[0]}</span>}
|
{displayName && <span>{displayName[0]}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react'
|
||||||
import ReactEChartsCore from 'echarts-for-react/lib/core';
|
import ReactEChartsCore from 'echarts-for-react/lib/core'
|
||||||
|
|
||||||
import * as echarts from 'echarts/core';
|
import * as echarts from 'echarts/core'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
// LineChart,
|
// LineChart,
|
||||||
|
@ -26,7 +26,7 @@ import {
|
||||||
// ThemeRiverChart,
|
// ThemeRiverChart,
|
||||||
// SunburstChart,
|
// SunburstChart,
|
||||||
// CustomChart,
|
// CustomChart,
|
||||||
} from 'echarts/charts';
|
} from 'echarts/charts'
|
||||||
|
|
||||||
// import components, all suffixed with Component
|
// import components, all suffixed with Component
|
||||||
import {
|
import {
|
||||||
|
@ -60,25 +60,18 @@ import {
|
||||||
// AriaComponent,
|
// AriaComponent,
|
||||||
// TransformComponent,
|
// TransformComponent,
|
||||||
DatasetComponent,
|
DatasetComponent,
|
||||||
} from 'echarts/components';
|
} from 'echarts/components'
|
||||||
|
|
||||||
// Import renderer, note that introducing the CanvasRenderer or SVGRenderer is a required step
|
// Import renderer, note that introducing the CanvasRenderer or SVGRenderer is a required step
|
||||||
import {
|
import {
|
||||||
CanvasRenderer,
|
CanvasRenderer,
|
||||||
// SVGRenderer,
|
// SVGRenderer,
|
||||||
} from 'echarts/renderers';
|
} from 'echarts/renderers'
|
||||||
|
|
||||||
// Register the required components
|
// Register the required components
|
||||||
echarts.use(
|
echarts.use([TitleComponent, TooltipComponent, GridComponent, BarChart, CanvasRenderer])
|
||||||
[TitleComponent, TooltipComponent, GridComponent, BarChart, CanvasRenderer]
|
|
||||||
);
|
|
||||||
|
|
||||||
// The usage of ReactEChartsCore are same with above.
|
// The usage of ReactEChartsCore are same with above.
|
||||||
export default function Chart(props) {
|
export default function Chart(props) {
|
||||||
return <ReactEChartsCore
|
return <ReactEChartsCore echarts={echarts} notMerge lazyUpdate {...props} />
|
||||||
echarts={echarts}
|
|
||||||
notMerge
|
|
||||||
lazyUpdate
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
import React, {useMemo} from "react";
|
import React, {useMemo} from 'react'
|
||||||
|
|
||||||
type ColorMap = [number, string][]
|
|
||||||
|
|
||||||
import styles from './ColorMapLegend.module.less'
|
import styles from './ColorMapLegend.module.less'
|
||||||
|
|
||||||
|
type ColorMap = [number, string][]
|
||||||
|
|
||||||
function* pairs(arr) {
|
function* pairs(arr) {
|
||||||
for (let i = 1; i < arr.length; i++) {
|
for (let i = 1; i < arr.length; i++) {
|
||||||
yield [arr[i - 1], arr[i]];
|
yield [arr[i - 1], arr[i]]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function* zip(...arrs) {
|
function* zip(...arrs) {
|
||||||
const l = Math.min(...arrs.map(a => a.length));
|
const l = Math.min(...arrs.map((a) => a.length))
|
||||||
for (let i = 0; i < l; i++) {
|
for (let i = 0; i < l; i++) {
|
||||||
yield arrs.map(a => a[i]);
|
yield arrs.map((a) => a[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -25,10 +25,10 @@ export function DiscreteColorMapLegend({map}: {map: ColorMap}) {
|
||||||
min -= buffer
|
min -= buffer
|
||||||
max += buffer
|
max += buffer
|
||||||
const normalizeValue = (v) => (v - min) / (max - min)
|
const normalizeValue = (v) => (v - min) / (max - min)
|
||||||
const stopPairs = Array.from(pairs([min, ...stops, max]));
|
const stopPairs = Array.from(pairs([min, ...stops, max]))
|
||||||
|
|
||||||
const gradientId = useMemo(() => `gradient${Math.floor(Math.random() * 1000000)}`, []);
|
const gradientId = useMemo(() => `gradient${Math.floor(Math.random() * 1000000)}`, [])
|
||||||
const gradientUrl = `url(#${gradientId})`;
|
const gradientUrl = `url(#${gradientId})`
|
||||||
|
|
||||||
const parts = Array.from(zip(stopPairs, colors))
|
const parts = Array.from(zip(stopPairs, colors))
|
||||||
|
|
||||||
|
@ -38,11 +38,10 @@ export function DiscreteColorMapLegend({map}: {map: ColorMap}) {
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={gradientId} x1="0" x2="1" y1="0" y2="0">
|
<linearGradient id={gradientId} x1="0" x2="1" y1="0" y2="0">
|
||||||
{parts.map(([[left, right], color]) => (
|
{parts.map(([[left, right], color]) => (
|
||||||
<React.Fragment key={left}>
|
<React.Fragment key={left}>
|
||||||
<stop offset={normalizeValue(left) * 100 + '%'} stopColor={color} />
|
<stop offset={normalizeValue(left) * 100 + '%'} stopColor={color} />
|
||||||
<stop offset={normalizeValue(right) * 100 + '%'} stopColor={color} />
|
<stop offset={normalizeValue(right) * 100 + '%'} stopColor={color} />
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|
||||||
))}
|
))}
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
|
@ -59,13 +58,21 @@ export function DiscreteColorMapLegend({map}: {map: ColorMap}) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ColorMapLegend({map, twoTicks = false, digits=2}: {map: ColorMap, twoTicks?: boolean, digits?: number}) {
|
export default function ColorMapLegend({
|
||||||
|
map,
|
||||||
|
twoTicks = false,
|
||||||
|
digits = 2,
|
||||||
|
}: {
|
||||||
|
map: ColorMap
|
||||||
|
twoTicks?: boolean
|
||||||
|
digits?: number
|
||||||
|
}) {
|
||||||
const min = map[0][0]
|
const min = map[0][0]
|
||||||
const max = map[map.length - 1][0]
|
const max = map[map.length - 1][0]
|
||||||
const normalizeValue = (v) => (v - min) / (max - min)
|
const normalizeValue = (v) => (v - min) / (max - min)
|
||||||
const gradientId = useMemo(() => `gradient${Math.floor(Math.random() * 1000000)}`, []);
|
const gradientId = useMemo(() => `gradient${Math.floor(Math.random() * 1000000)}`, [])
|
||||||
const gradientUrl = `url(#${gradientId})`;
|
const gradientUrl = `url(#${gradientId})`
|
||||||
const tickValues = twoTicks ? [map[0], map[map.length-1]] : map
|
const tickValues = twoTicks ? [map[0], map[map.length - 1]] : map
|
||||||
return (
|
return (
|
||||||
<div className={styles.colorMapLegend}>
|
<div className={styles.colorMapLegend}>
|
||||||
<svg width="100%" height="20" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
<svg width="100%" height="20" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
|
@ -1,31 +1,31 @@
|
||||||
import React from "react";
|
import React from 'react'
|
||||||
import { Icon, Segment, Header, Button } from "semantic-ui-react";
|
import {Icon, Segment, Header, Button} from 'semantic-ui-react'
|
||||||
import { useTranslation } from "react-i18next";
|
import {useTranslation} from 'react-i18next'
|
||||||
|
|
||||||
import { FileDrop } from "components";
|
import {FileDrop} from 'components'
|
||||||
|
|
||||||
export default function FileUploadField({ onSelect: onSelect_, multiple }) {
|
export default function FileUploadField({onSelect: onSelect_, multiple}) {
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
const labelRef = React.useRef();
|
const labelRef = React.useRef()
|
||||||
const [labelRefState, setLabelRefState] = React.useState();
|
const [labelRefState, setLabelRefState] = React.useState()
|
||||||
|
|
||||||
const onSelect = multiple ? onSelect_ : (files) => onSelect_(files?.[0]);
|
const onSelect = multiple ? onSelect_ : (files) => onSelect_(files?.[0])
|
||||||
|
|
||||||
React.useLayoutEffect(
|
React.useLayoutEffect(
|
||||||
() => {
|
() => {
|
||||||
setLabelRefState(labelRef.current);
|
setLabelRefState(labelRef.current)
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[labelRef.current]
|
[labelRef.current]
|
||||||
);
|
)
|
||||||
|
|
||||||
function onChangeField(e) {
|
function onChangeField(e) {
|
||||||
e.preventDefault?.();
|
e.preventDefault?.()
|
||||||
|
|
||||||
if (e.target.files && e.target.files.length) {
|
if (e.target.files && e.target.files.length) {
|
||||||
onSelect(e.target.files);
|
onSelect(e.target.files)
|
||||||
}
|
}
|
||||||
e.target.value = ""; // reset the form field for uploading again
|
e.target.value = '' // reset the form field for uploading again
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -36,7 +36,7 @@ export default function FileUploadField({ onSelect: onSelect_, multiple }) {
|
||||||
style={{
|
style={{
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
position: "fixed",
|
position: 'fixed',
|
||||||
left: -1000,
|
left: -1000,
|
||||||
top: -1000,
|
top: -1000,
|
||||||
opacity: 0.001,
|
opacity: 0.001,
|
||||||
|
@ -48,34 +48,22 @@ export default function FileUploadField({ onSelect: onSelect_, multiple }) {
|
||||||
<label htmlFor="upload-field" ref={labelRef}>
|
<label htmlFor="upload-field" ref={labelRef}>
|
||||||
{labelRefState && (
|
{labelRefState && (
|
||||||
<FileDrop onDrop={onSelect} frame={labelRefState}>
|
<FileDrop onDrop={onSelect} frame={labelRefState}>
|
||||||
{({
|
{({draggingOverFrame, draggingOverTarget, onDragOver, onDragLeave, onDrop, onClick}) => (
|
||||||
draggingOverFrame,
|
|
||||||
draggingOverTarget,
|
|
||||||
onDragOver,
|
|
||||||
onDragLeave,
|
|
||||||
onDrop,
|
|
||||||
onClick,
|
|
||||||
}) => (
|
|
||||||
<Segment
|
<Segment
|
||||||
placeholder
|
placeholder
|
||||||
{...{ onDragOver, onDragLeave, onDrop }}
|
{...{onDragOver, onDragLeave, onDrop}}
|
||||||
style={{
|
style={{
|
||||||
background:
|
background: draggingOverTarget || draggingOverFrame ? '#E0E0EE' : null,
|
||||||
draggingOverTarget || draggingOverFrame ? "#E0E0EE" : null,
|
transition: 'background 0.2s',
|
||||||
transition: "background 0.2s",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Header icon>
|
<Header icon>
|
||||||
<Icon name="cloud upload" />
|
<Icon name="cloud upload" />
|
||||||
{multiple
|
{multiple ? t('FileUploadField.dropOrClickMultiple') : t('FileUploadField.dropOrClick')}
|
||||||
? t("FileUploadField.dropOrClickMultiple")
|
|
||||||
: t("FileUploadField.dropOrClick")}
|
|
||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
<Button primary as="span">
|
<Button primary as="span">
|
||||||
{multiple
|
{multiple ? t('FileUploadField.uploadFiles') : t('FileUploadField.uploadFile')}
|
||||||
? t("FileUploadField.uploadFiles")
|
|
||||||
: t("FileUploadField.uploadFile")}
|
|
||||||
</Button>
|
</Button>
|
||||||
</Segment>
|
</Segment>
|
||||||
)}
|
)}
|
||||||
|
@ -83,5 +71,5 @@ export default function FileUploadField({ onSelect: onSelect_, multiple }) {
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,5 +21,9 @@ export default function FormattedDate({date, relative = false}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const iso = dateTime.toISO()
|
const iso = dateTime.toISO()
|
||||||
return <time dateTime={iso} title={iso}>{str}</time>
|
return (
|
||||||
|
<time dateTime={iso} title={iso}>
|
||||||
|
{str}
|
||||||
|
</time>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import React from "react";
|
import React from 'react'
|
||||||
import classnames from "classnames";
|
import classnames from 'classnames'
|
||||||
import { Container } from "semantic-ui-react";
|
import {Container} from 'semantic-ui-react'
|
||||||
import { Helmet } from "react-helmet";
|
import {Helmet} from 'react-helmet'
|
||||||
|
|
||||||
import styles from "./Page.module.less";
|
import styles from './Page.module.less'
|
||||||
|
|
||||||
export default function Page({
|
export default function Page({
|
||||||
small,
|
small,
|
||||||
|
@ -12,11 +12,11 @@ export default function Page({
|
||||||
stage,
|
stage,
|
||||||
title,
|
title,
|
||||||
}: {
|
}: {
|
||||||
small?: boolean;
|
small?: boolean
|
||||||
children: ReactNode;
|
children: ReactNode
|
||||||
fullScreen?: boolean;
|
fullScreen?: boolean
|
||||||
stage?: ReactNode;
|
stage?: ReactNode
|
||||||
title?: string;
|
title?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -37,5 +37,5 @@ export default function Page({
|
||||||
{fullScreen ? children : <Container>{children}</Container>}
|
{fullScreen ? children : <Container>{children}</Container>}
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,76 +1,71 @@
|
||||||
import React, { useState, useCallback } from "react";
|
import React, {useState, useCallback} from 'react'
|
||||||
import { pickBy } from "lodash";
|
import {pickBy} from 'lodash'
|
||||||
import { Loader, Statistic, Segment, Header, Menu } from "semantic-ui-react";
|
import {Loader, Statistic, Segment, Header, Menu} from 'semantic-ui-react'
|
||||||
import { useObservable } from "rxjs-hooks";
|
import {useObservable} from 'rxjs-hooks'
|
||||||
import { of, from, concat, combineLatest } from "rxjs";
|
import {of, from, concat, combineLatest} from 'rxjs'
|
||||||
import { map, switchMap, distinctUntilChanged } from "rxjs/operators";
|
import {map, switchMap, distinctUntilChanged} from 'rxjs/operators'
|
||||||
import { Duration, DateTime } from "luxon";
|
import {Duration, DateTime} from 'luxon'
|
||||||
import { useTranslation } from "react-i18next";
|
import {useTranslation} from 'react-i18next'
|
||||||
|
|
||||||
import api from "api";
|
import api from 'api'
|
||||||
|
|
||||||
function formatDuration(seconds) {
|
function formatDuration(seconds) {
|
||||||
return (
|
return (
|
||||||
Duration.fromMillis((seconds ?? 0) * 1000)
|
Duration.fromMillis((seconds ?? 0) * 1000)
|
||||||
.as("hours")
|
.as('hours')
|
||||||
.toFixed(1) + " h"
|
.toFixed(1) + ' h'
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Stats({ user = null }: { user?: null | string }) {
|
export default function Stats({user = null}: {user?: null | string}) {
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
const [timeframe, setTimeframe] = useState("all_time");
|
const [timeframe, setTimeframe] = useState('all_time')
|
||||||
const onClick = useCallback(
|
const onClick = useCallback((_e, {name}) => setTimeframe(name), [setTimeframe])
|
||||||
(_e, { name }) => setTimeframe(name),
|
|
||||||
[setTimeframe]
|
|
||||||
);
|
|
||||||
|
|
||||||
const stats = useObservable(
|
const stats = useObservable(
|
||||||
(_$, inputs$) => {
|
(_$, inputs$) => {
|
||||||
const timeframe$ = inputs$.pipe(
|
const timeframe$ = inputs$.pipe(
|
||||||
map((inputs) => inputs[0]),
|
map((inputs) => inputs[0]),
|
||||||
distinctUntilChanged()
|
distinctUntilChanged()
|
||||||
);
|
)
|
||||||
|
|
||||||
const user$ = inputs$.pipe(
|
const user$ = inputs$.pipe(
|
||||||
map((inputs) => inputs[1]),
|
map((inputs) => inputs[1]),
|
||||||
distinctUntilChanged()
|
distinctUntilChanged()
|
||||||
);
|
)
|
||||||
|
|
||||||
return combineLatest(timeframe$, user$).pipe(
|
return combineLatest(timeframe$, user$).pipe(
|
||||||
map(([timeframe_, user_]) => {
|
map(([timeframe_, user_]) => {
|
||||||
const now = DateTime.now();
|
const now = DateTime.now()
|
||||||
|
|
||||||
let start, end;
|
let start, end
|
||||||
|
|
||||||
switch (timeframe_) {
|
switch (timeframe_) {
|
||||||
case "this_month":
|
case 'this_month':
|
||||||
start = now.startOf("month");
|
start = now.startOf('month')
|
||||||
end = now.endOf("month");
|
end = now.endOf('month')
|
||||||
break;
|
break
|
||||||
|
|
||||||
case "this_year":
|
case 'this_year':
|
||||||
start = now.startOf("year");
|
start = now.startOf('year')
|
||||||
end = now.endOf("year");
|
end = now.endOf('year')
|
||||||
break;
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
return pickBy({
|
return pickBy({
|
||||||
start: start?.toISODate(),
|
start: start?.toISODate(),
|
||||||
end: end?.toISODate(),
|
end: end?.toISODate(),
|
||||||
user: user_,
|
user: user_,
|
||||||
});
|
})
|
||||||
}),
|
}),
|
||||||
switchMap((query) =>
|
switchMap((query) => concat(of(null), from(api.get('/stats', {query}))))
|
||||||
concat(of(null), from(api.get("/stats", { query })))
|
)
|
||||||
)
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
[timeframe, user]
|
[timeframe, user]
|
||||||
);
|
)
|
||||||
|
|
||||||
const placeholder = t("Stats.placeholder");
|
const placeholder = t('Stats.placeholder')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -80,43 +75,31 @@ export default function Stats({ user = null }: { user?: null | string }) {
|
||||||
<Statistic.Group widths={2} size="tiny">
|
<Statistic.Group widths={2} size="tiny">
|
||||||
<Statistic>
|
<Statistic>
|
||||||
<Statistic.Value>
|
<Statistic.Value>
|
||||||
{stats
|
{stats ? `${Number(stats?.trackLength / 1000).toFixed(1)} km` : placeholder}
|
||||||
? `${Number(stats?.trackLength / 1000).toFixed(1)} km`
|
|
||||||
: placeholder}
|
|
||||||
</Statistic.Value>
|
</Statistic.Value>
|
||||||
<Statistic.Label>{t("Stats.totalTrackLength")}</Statistic.Label>
|
<Statistic.Label>{t('Stats.totalTrackLength')}</Statistic.Label>
|
||||||
</Statistic>
|
</Statistic>
|
||||||
<Statistic>
|
<Statistic>
|
||||||
<Statistic.Value>
|
<Statistic.Value>{stats ? formatDuration(stats?.trackDuration) : placeholder}</Statistic.Value>
|
||||||
{stats ? formatDuration(stats?.trackDuration) : placeholder}
|
<Statistic.Label>{t('Stats.timeRecorded')}</Statistic.Label>
|
||||||
</Statistic.Value>
|
|
||||||
<Statistic.Label>{t("Stats.timeRecorded")}</Statistic.Label>
|
|
||||||
</Statistic>
|
</Statistic>
|
||||||
<Statistic>
|
<Statistic>
|
||||||
<Statistic.Value>
|
<Statistic.Value>{stats?.numEvents ?? placeholder}</Statistic.Value>
|
||||||
{stats?.numEvents ?? placeholder}
|
<Statistic.Label>{t('Stats.eventsConfirmed')}</Statistic.Label>
|
||||||
</Statistic.Value>
|
|
||||||
<Statistic.Label>{t("Stats.eventsConfirmed")}</Statistic.Label>
|
|
||||||
</Statistic>
|
</Statistic>
|
||||||
<Statistic>
|
<Statistic>
|
||||||
<Statistic.Value>
|
<Statistic.Value>{stats?.trackCount ?? placeholder}</Statistic.Value>
|
||||||
{stats?.trackCount ?? placeholder}
|
<Statistic.Label>{t('Stats.tracksRecorded')}</Statistic.Label>
|
||||||
</Statistic.Value>
|
|
||||||
<Statistic.Label>{t("Stats.tracksRecorded")}</Statistic.Label>
|
|
||||||
</Statistic>
|
</Statistic>
|
||||||
{!user && (
|
{!user && (
|
||||||
<>
|
<>
|
||||||
<Statistic>
|
<Statistic>
|
||||||
<Statistic.Value>
|
<Statistic.Value>{stats?.userCount ?? placeholder}</Statistic.Value>
|
||||||
{stats?.userCount ?? placeholder}
|
<Statistic.Label>{t('Stats.membersJoined')}</Statistic.Label>
|
||||||
</Statistic.Value>
|
|
||||||
<Statistic.Label>{t("Stats.membersJoined")}</Statistic.Label>
|
|
||||||
</Statistic>
|
</Statistic>
|
||||||
<Statistic>
|
<Statistic>
|
||||||
<Statistic.Value>
|
<Statistic.Value>{stats?.deviceCount ?? placeholder}</Statistic.Value>
|
||||||
{stats?.deviceCount ?? placeholder}
|
<Statistic.Label>{t('Stats.deviceCount')}</Statistic.Label>
|
||||||
</Statistic.Value>
|
|
||||||
<Statistic.Label>{t("Stats.deviceCount")}</Statistic.Label>
|
|
||||||
</Statistic>
|
</Statistic>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -124,29 +107,17 @@ export default function Stats({ user = null }: { user?: null | string }) {
|
||||||
</Segment>
|
</Segment>
|
||||||
|
|
||||||
<Menu widths={3} attached="bottom" size="small">
|
<Menu widths={3} attached="bottom" size="small">
|
||||||
<Menu.Item
|
<Menu.Item name="this_month" active={timeframe === 'this_month'} onClick={onClick}>
|
||||||
name="this_month"
|
{t('Stats.thisMonth')}
|
||||||
active={timeframe === "this_month"}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{t("Stats.thisMonth")}
|
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item name="this_year" active={timeframe === 'this_year'} onClick={onClick}>
|
||||||
name="this_year"
|
{t('Stats.thisYear')}
|
||||||
active={timeframe === "this_year"}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{t("Stats.thisYear")}
|
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item name="all_time" active={timeframe === 'all_time'} onClick={onClick}>
|
||||||
name="all_time"
|
{t('Stats.allTime')}
|
||||||
active={timeframe === "all_time"}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{t("Stats.allTime")}
|
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,14 @@
|
||||||
import React from "react";
|
import React from 'react'
|
||||||
import { Icon } from "semantic-ui-react";
|
import {Icon} from 'semantic-ui-react'
|
||||||
import { useTranslation } from "react-i18next";
|
import {useTranslation} from 'react-i18next'
|
||||||
|
|
||||||
export default function Visibility({ public: public_ }: { public: boolean }) {
|
export default function Visibility({public: public_}: {public: boolean}) {
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
const icon = public_ ? (
|
const icon = public_ ? <Icon color="blue" name="eye" fitted /> : <Icon name="eye slash" fitted />
|
||||||
<Icon color="blue" name="eye" fitted />
|
const text = public_ ? t('general.public') : t('general.private')
|
||||||
) : (
|
|
||||||
<Icon name="eye slash" fitted />
|
|
||||||
);
|
|
||||||
const text = public_ ? t("general.public") : t("general.private");
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{icon} {text}
|
{icon} {text}
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,46 +1,46 @@
|
||||||
import React from "react";
|
import React from 'react'
|
||||||
|
|
||||||
export type MapSource = {
|
export type MapSource = {
|
||||||
type: "vector";
|
type: 'vector'
|
||||||
tiles: string[];
|
tiles: string[]
|
||||||
minzoom: number;
|
minzoom: number
|
||||||
maxzoom: number;
|
maxzoom: number
|
||||||
};
|
}
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
apiUrl: string;
|
apiUrl: string
|
||||||
mapHome: {
|
mapHome: {
|
||||||
latitude: number;
|
latitude: number
|
||||||
longitude: number;
|
longitude: number
|
||||||
zoom: number;
|
zoom: number
|
||||||
};
|
}
|
||||||
obsMapSource?: MapSource;
|
obsMapSource?: MapSource
|
||||||
imprintUrl?: string;
|
imprintUrl?: string
|
||||||
privacyPolicyUrl?: string;
|
privacyPolicyUrl?: string
|
||||||
termsUrl?: string;
|
termsUrl?: string
|
||||||
banner?: {
|
banner?: {
|
||||||
text: string;
|
text: string
|
||||||
style?: "warning" | "info";
|
style?: 'warning' | 'info'
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadConfig(): Promise<Config> {
|
async function loadConfig(): Promise<Config> {
|
||||||
const response = await fetch(__webpack_public_path__ + "config.json");
|
const response = await fetch(__webpack_public_path__ + 'config.json')
|
||||||
const config = await response.json();
|
const config = await response.json()
|
||||||
return config;
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
let _configPromise: Promise<Config> = loadConfig();
|
let _configPromise: Promise<Config> = loadConfig()
|
||||||
let _configCache: null | Config = null;
|
let _configCache: null | Config = null
|
||||||
|
|
||||||
export function useConfig() {
|
export function useConfig() {
|
||||||
const [config, setConfig] = React.useState<Config>(_configCache);
|
const [config, setConfig] = React.useState<Config>(_configCache)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!_configCache) {
|
if (!_configCache) {
|
||||||
_configPromise.then(setConfig);
|
_configPromise.then(setConfig)
|
||||||
}
|
}
|
||||||
}, []);
|
}, [])
|
||||||
return config;
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
export default _configPromise;
|
export default _configPromise
|
||||||
|
|
|
@ -1,95 +1,87 @@
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import {useState, useEffect, useMemo} from 'react'
|
||||||
import i18next, { TOptions } from "i18next";
|
import i18next, {TOptions} from 'i18next'
|
||||||
import { BehaviorSubject, combineLatest } from "rxjs";
|
import {BehaviorSubject, combineLatest} from 'rxjs'
|
||||||
import { map, distinctUntilChanged } from "rxjs/operators";
|
import {map, distinctUntilChanged} from 'rxjs/operators'
|
||||||
import HttpBackend, {
|
import HttpBackend, {BackendOptions, RequestCallback} from 'i18next-http-backend'
|
||||||
BackendOptions,
|
import {initReactI18next} from 'react-i18next'
|
||||||
RequestCallback,
|
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||||
} from "i18next-http-backend";
|
|
||||||
import { initReactI18next } from "react-i18next";
|
|
||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
|
||||||
|
|
||||||
export type AvailableLocales = "en" | "de" | "fr";
|
export type AvailableLocales = 'en' | 'de' | 'fr'
|
||||||
|
|
||||||
async function request(
|
async function request(_options: BackendOptions, url: string, _payload: any, callback: RequestCallback) {
|
||||||
_options: BackendOptions,
|
|
||||||
url: string,
|
|
||||||
_payload: any,
|
|
||||||
callback: RequestCallback
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
const [lng] = url.split("/");
|
const [lng] = url.split('/')
|
||||||
const locale = await import(`translations/${lng}.yaml`);
|
const locale = await import(`translations/${lng}.yaml`)
|
||||||
callback(null, { status: 200, data: locale });
|
callback(null, {status: 200, data: locale})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Unable to load locale at ${url}\n`, e);
|
console.error(`Unable to load locale at ${url}\n`, e)
|
||||||
callback(null, { status: 404, data: String(e) });
|
callback(null, {status: 404, data: String(e)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AVAILABLE_LOCALES: AvailableLocales[] = ["en", "de", "fr"];
|
export const AVAILABLE_LOCALES: AvailableLocales[] = ['en', 'de', 'fr']
|
||||||
|
|
||||||
const i18n = i18next.createInstance();
|
const i18n = i18next.createInstance()
|
||||||
|
|
||||||
const options: TOptions = {
|
const options: TOptions = {
|
||||||
fallbackLng: "en",
|
fallbackLng: 'en',
|
||||||
|
|
||||||
ns: ["common"],
|
ns: ['common'],
|
||||||
defaultNS: "common",
|
defaultNS: 'common',
|
||||||
whitelist: AVAILABLE_LOCALES,
|
whitelist: AVAILABLE_LOCALES,
|
||||||
|
|
||||||
// loading via webpack
|
// loading via webpack
|
||||||
backend: {
|
backend: {
|
||||||
loadPath: "{{lng}}/{{ns}}",
|
loadPath: '{{lng}}/{{ns}}',
|
||||||
parse: (data: any) => data,
|
parse: (data: any) => data,
|
||||||
request,
|
request,
|
||||||
},
|
},
|
||||||
|
|
||||||
load: "languageOnly",
|
load: 'languageOnly',
|
||||||
|
|
||||||
interpolation: {
|
interpolation: {
|
||||||
escapeValue: false, // not needed for react as it escapes by default
|
escapeValue: false, // not needed for react as it escapes by default
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
||||||
i18n
|
i18n
|
||||||
.use(HttpBackend)
|
.use(HttpBackend)
|
||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
.use(LanguageDetector)
|
.use(LanguageDetector)
|
||||||
.init({ ...options });
|
.init({...options})
|
||||||
|
|
||||||
const locale$ = new BehaviorSubject<AvailableLocales>("en");
|
const locale$ = new BehaviorSubject<AvailableLocales>('en')
|
||||||
|
|
||||||
export const translate = i18n.t.bind(i18n);
|
export const translate = i18n.t.bind(i18n)
|
||||||
|
|
||||||
export const translate$ = (stringAndData$: [string, any]) =>
|
export const translate$ = (stringAndData$: [string, any]) =>
|
||||||
combineLatest([stringAndData$, locale$.pipe(distinctUntilChanged())]).pipe(
|
combineLatest([stringAndData$, locale$.pipe(distinctUntilChanged())]).pipe(
|
||||||
map(([stringAndData]) => {
|
map(([stringAndData]) => {
|
||||||
if (typeof stringAndData === "string") {
|
if (typeof stringAndData === 'string') {
|
||||||
return i18n.t(stringAndData);
|
return i18n.t(stringAndData)
|
||||||
} else {
|
} else {
|
||||||
const [string, data] = stringAndData;
|
const [string, data] = stringAndData
|
||||||
return i18n.t(string, { data });
|
return i18n.t(string, {data})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
)
|
||||||
|
|
||||||
export const setLocale = (locale: AvailableLocales) => {
|
export const setLocale = (locale: AvailableLocales) => {
|
||||||
i18n.changeLanguage(locale);
|
i18n.changeLanguage(locale)
|
||||||
locale$.next(locale);
|
locale$.next(locale)
|
||||||
};
|
|
||||||
|
|
||||||
export function useLocale() {
|
|
||||||
const [, reload] = useState();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
i18n.on("languageChanged", reload);
|
|
||||||
return () => {
|
|
||||||
i18n.off("languageChanged", reload);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return i18n.language;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default i18n;
|
export function useLocale() {
|
||||||
|
const [, reload] = useState()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
i18n.on('languageChanged', reload)
|
||||||
|
return () => {
|
||||||
|
i18n.off('languageChanged', reload)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return i18n.language
|
||||||
|
}
|
||||||
|
|
||||||
|
export default i18n
|
||||||
|
|
|
@ -1,169 +1,146 @@
|
||||||
import _ from "lodash";
|
import _ from 'lodash'
|
||||||
import produce from "immer";
|
import produce from 'immer'
|
||||||
|
|
||||||
import bright from "./bright.json";
|
import bright from './bright.json'
|
||||||
import positron from "./positron.json";
|
import positron from './positron.json'
|
||||||
|
|
||||||
import viridisBase from "colormap/res/res/viridis";
|
import viridisBase from 'colormap/res/res/viridis'
|
||||||
|
|
||||||
export { bright, positron };
|
export {bright, positron}
|
||||||
export const baseMapStyles = { bright, positron };
|
export const baseMapStyles = {bright, positron}
|
||||||
|
|
||||||
function simplifyColormap(colormap, maxCount = 16) {
|
function simplifyColormap(colormap, maxCount = 16) {
|
||||||
const result = [];
|
const result = []
|
||||||
const step = Math.ceil(colormap.length / maxCount);
|
const step = Math.ceil(colormap.length / maxCount)
|
||||||
for (let i = 0; i < colormap.length; i += step) {
|
for (let i = 0; i < colormap.length; i += step) {
|
||||||
result.push(colormap[i]);
|
result.push(colormap[i])
|
||||||
}
|
}
|
||||||
return result;
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
function rgbArrayToColor(arr) {
|
function rgbArrayToColor(arr) {
|
||||||
return ["rgb", ...arr.map((v) => Math.round(v * 255))];
|
return ['rgb', ...arr.map((v) => Math.round(v * 255))]
|
||||||
}
|
}
|
||||||
|
|
||||||
function rgbArrayToHtml(arr) {
|
function rgbArrayToHtml(arr) {
|
||||||
return (
|
return (
|
||||||
"#" +
|
'#' +
|
||||||
arr
|
arr
|
||||||
.map((v) => Math.round(v * 255).toString(16))
|
.map((v) => Math.round(v * 255).toString(16))
|
||||||
.map((v) => (v.length == 1 ? "0" : "") + v)
|
.map((v) => (v.length == 1 ? '0' : '') + v)
|
||||||
.join("")
|
.join('')
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function colormapToScale(colormap, value, min, max) {
|
export function colormapToScale(colormap, value, min, max) {
|
||||||
return [
|
return [
|
||||||
"interpolate-hcl",
|
'interpolate-hcl',
|
||||||
["linear"],
|
['linear'],
|
||||||
value,
|
value,
|
||||||
...colormap.flatMap((v, i, a) => [
|
...colormap.flatMap((v, i, a) => [(i / (a.length - 1)) * (max - min) + min, v]),
|
||||||
(i / (a.length - 1)) * (max - min) + min,
|
]
|
||||||
v,
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const viridis = simplifyColormap(viridisBase.map(rgbArrayToColor), 20);
|
export const viridis = simplifyColormap(viridisBase.map(rgbArrayToColor), 20)
|
||||||
export const viridisSimpleHtml = simplifyColormap(
|
export const viridisSimpleHtml = simplifyColormap(viridisBase.map(rgbArrayToHtml), 10)
|
||||||
viridisBase.map(rgbArrayToHtml),
|
export const grayscale = ['#FFFFFF', '#000000']
|
||||||
10
|
export const reds = ['rgba( 255, 0, 0, 0)', 'rgba( 255, 0, 0, 255)']
|
||||||
);
|
|
||||||
export const grayscale = ["#FFFFFF", "#000000"];
|
|
||||||
export const reds = ["rgba( 255, 0, 0, 0)", "rgba( 255, 0, 0, 255)"];
|
|
||||||
|
|
||||||
export function colorByCount(
|
export function colorByCount(attribute = 'event_count', maxCount, colormap = viridis) {
|
||||||
attribute = "event_count",
|
return colormapToScale(colormap, ['case', isValidAttribute(attribute), ['get', attribute], 0], 0, maxCount)
|
||||||
maxCount,
|
|
||||||
colormap = viridis
|
|
||||||
) {
|
|
||||||
return colormapToScale(
|
|
||||||
colormap,
|
|
||||||
["case", isValidAttribute(attribute), ["get", attribute], 0],
|
|
||||||
0,
|
|
||||||
maxCount
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var steps = { rural: [1.6, 1.8, 2.0, 2.2], urban: [1.1, 1.3, 1.5, 1.7] };
|
var steps = {rural: [1.6, 1.8, 2.0, 2.2], urban: [1.1, 1.3, 1.5, 1.7]}
|
||||||
|
|
||||||
export function isValidAttribute(attribute) {
|
export function isValidAttribute(attribute) {
|
||||||
if (attribute.endsWith("zone")) {
|
if (attribute.endsWith('zone')) {
|
||||||
return ["in", ["get", attribute], ["literal", ["rural", "urban"]]];
|
return ['in', ['get', attribute], ['literal', ['rural', 'urban']]]
|
||||||
}
|
}
|
||||||
return ["to-boolean", ["get", attribute]];
|
return ['to-boolean', ['get', attribute]]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function borderByZone() {
|
export function borderByZone() {
|
||||||
return ["match", ["get", "zone"], "rural", "cyan", "urban", "blue", "purple"];
|
return ['match', ['get', 'zone'], 'rural', 'cyan', 'urban', 'blue', 'purple']
|
||||||
}
|
}
|
||||||
|
|
||||||
export function colorByDistance(
|
export function colorByDistance(attribute = 'distance_overtaker_mean', fallback = '#ABC', zone = 'urban') {
|
||||||
attribute = "distance_overtaker_mean",
|
|
||||||
fallback = "#ABC",
|
|
||||||
zone = "urban"
|
|
||||||
) {
|
|
||||||
return [
|
return [
|
||||||
"case",
|
'case',
|
||||||
["!", isValidAttribute(attribute)],
|
['!', isValidAttribute(attribute)],
|
||||||
fallback,
|
fallback,
|
||||||
[
|
[
|
||||||
"match",
|
'match',
|
||||||
["get", "zone"],
|
['get', 'zone'],
|
||||||
"rural",
|
'rural',
|
||||||
[
|
[
|
||||||
"step",
|
'step',
|
||||||
["get", attribute],
|
['get', attribute],
|
||||||
"rgba(150, 0, 0, 1)",
|
'rgba(150, 0, 0, 1)',
|
||||||
steps["rural"][0],
|
steps['rural'][0],
|
||||||
"rgba(255, 0, 0, 1)",
|
'rgba(255, 0, 0, 1)',
|
||||||
steps["rural"][1],
|
steps['rural'][1],
|
||||||
"rgba(255, 220, 0, 1)",
|
'rgba(255, 220, 0, 1)',
|
||||||
steps["rural"][2],
|
steps['rural'][2],
|
||||||
"rgba(67, 200, 0, 1)",
|
'rgba(67, 200, 0, 1)',
|
||||||
steps["rural"][3],
|
steps['rural'][3],
|
||||||
"rgba(67, 150, 0, 1)",
|
'rgba(67, 150, 0, 1)',
|
||||||
],
|
],
|
||||||
"urban",
|
'urban',
|
||||||
[
|
[
|
||||||
"step",
|
'step',
|
||||||
["get", attribute],
|
['get', attribute],
|
||||||
"rgba(150, 0, 0, 1)",
|
'rgba(150, 0, 0, 1)',
|
||||||
steps["urban"][0],
|
steps['urban'][0],
|
||||||
"rgba(255, 0, 0, 1)",
|
'rgba(255, 0, 0, 1)',
|
||||||
steps["urban"][1],
|
steps['urban'][1],
|
||||||
"rgba(255, 220, 0, 1)",
|
'rgba(255, 220, 0, 1)',
|
||||||
steps["urban"][2],
|
steps['urban'][2],
|
||||||
"rgba(67, 200, 0, 1)",
|
'rgba(67, 200, 0, 1)',
|
||||||
steps["urban"][3],
|
steps['urban'][3],
|
||||||
"rgba(67, 150, 0, 1)",
|
'rgba(67, 150, 0, 1)',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"step",
|
'step',
|
||||||
["get", attribute],
|
['get', attribute],
|
||||||
"rgba(150, 0, 0, 1)",
|
'rgba(150, 0, 0, 1)',
|
||||||
steps["urban"][0],
|
steps['urban'][0],
|
||||||
"rgba(255, 0, 0, 1)",
|
'rgba(255, 0, 0, 1)',
|
||||||
steps["urban"][1],
|
steps['urban'][1],
|
||||||
"rgba(255, 220, 0, 1)",
|
'rgba(255, 220, 0, 1)',
|
||||||
steps["urban"][2],
|
steps['urban'][2],
|
||||||
"rgba(67, 200, 0, 1)",
|
'rgba(67, 200, 0, 1)',
|
||||||
steps["urban"][3],
|
steps['urban'][3],
|
||||||
"rgba(67, 150, 0, 1)",
|
'rgba(67, 150, 0, 1)',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const trackLayer = {
|
export const trackLayer = {
|
||||||
type: "line",
|
type: 'line',
|
||||||
paint: {
|
paint: {
|
||||||
"line-width": ["interpolate", ["linear"], ["zoom"], 14, 2, 17, 5],
|
'line-width': ['interpolate', ['linear'], ['zoom'], 14, 2, 17, 5],
|
||||||
"line-color": "#F06292",
|
'line-color': '#F06292',
|
||||||
"line-opacity": 0.6,
|
'line-opacity': 0.6,
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
||||||
export const getRegionLayers = (
|
export const getRegionLayers = (adminLevel = 6, baseColor = '#00897B', maxValue = 5000) => [
|
||||||
adminLevel = 6,
|
|
||||||
baseColor = "#00897B",
|
|
||||||
maxValue = 5000
|
|
||||||
) => [
|
|
||||||
{
|
{
|
||||||
id: "region",
|
id: 'region',
|
||||||
type: "fill",
|
type: 'fill',
|
||||||
source: "obs",
|
source: 'obs',
|
||||||
"source-layer": "obs_regions",
|
'source-layer': 'obs_regions',
|
||||||
minzoom: 0,
|
minzoom: 0,
|
||||||
maxzoom: 10,
|
maxzoom: 10,
|
||||||
// filter: [">", "overtaking_event_count", 0],
|
// filter: [">", "overtaking_event_count", 0],
|
||||||
paint: {
|
paint: {
|
||||||
"fill-color": baseColor,
|
'fill-color': baseColor,
|
||||||
"fill-antialias": true,
|
'fill-antialias': true,
|
||||||
"fill-opacity": [
|
'fill-opacity': [
|
||||||
"interpolate",
|
'interpolate',
|
||||||
["linear"],
|
['linear'],
|
||||||
["log10", ["max",["get", "overtaking_event_count"],1]],
|
['log10', ['max', ['get', 'overtaking_event_count'], 1]],
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
Math.log10(maxValue),
|
Math.log10(maxValue),
|
||||||
|
@ -172,38 +149,38 @@ export const getRegionLayers = (
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "region-border",
|
id: 'region-border',
|
||||||
type: "line",
|
type: 'line',
|
||||||
source: "obs",
|
source: 'obs',
|
||||||
"source-layer": "obs_regions",
|
'source-layer': 'obs_regions',
|
||||||
minzoom: 0,
|
minzoom: 0,
|
||||||
maxzoom: 10,
|
maxzoom: 10,
|
||||||
// filter: [">", "overtaking_event_count", 0],
|
// filter: [">", "overtaking_event_count", 0],
|
||||||
paint: {
|
paint: {
|
||||||
"line-width": [
|
'line-width': [
|
||||||
"interpolate",
|
'interpolate',
|
||||||
["linear"],
|
['linear'],
|
||||||
["log10", ["max",["get", "overtaking_event_count"],1]],
|
['log10', ['max', ['get', 'overtaking_event_count'], 1]],
|
||||||
0,
|
0,
|
||||||
0.2,
|
0.2,
|
||||||
Math.log10(maxValue),
|
Math.log10(maxValue),
|
||||||
1.5,
|
1.5,
|
||||||
],
|
],
|
||||||
"line-color": baseColor,
|
'line-color': baseColor,
|
||||||
},
|
},
|
||||||
layout: {
|
layout: {
|
||||||
"line-join": "round",
|
'line-join': 'round',
|
||||||
"line-cap": "round",
|
'line-cap': 'round',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|
||||||
export const trackLayerRaw = produce(trackLayer, (draft) => {
|
export const trackLayerRaw = produce(trackLayer, (draft) => {
|
||||||
// draft.paint['line-color'] = '#81D4FA'
|
// draft.paint['line-color'] = '#81D4FA'
|
||||||
draft.paint["line-width"][4] = 1;
|
draft.paint['line-width'][4] = 1
|
||||||
draft.paint["line-width"][6] = 2;
|
draft.paint['line-width'][6] = 2
|
||||||
draft.paint["line-dasharray"] = [3, 3];
|
draft.paint['line-dasharray'] = [3, 3]
|
||||||
delete draft.paint["line-opacity"];
|
delete draft.paint['line-opacity']
|
||||||
});
|
})
|
||||||
|
|
||||||
export const basemap = positron;
|
export const basemap = positron
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
import React from "react";
|
import React from 'react'
|
||||||
import { Header } from "semantic-ui-react";
|
import {Header} from 'semantic-ui-react'
|
||||||
import { useTranslation } from "react-i18next";
|
import {useTranslation} from 'react-i18next'
|
||||||
import Markdown from "react-markdown";
|
import Markdown from 'react-markdown'
|
||||||
|
|
||||||
import { Page } from "components";
|
import {Page} from 'components'
|
||||||
|
|
||||||
export default function AcknowledgementsPage() {
|
export default function AcknowledgementsPage() {
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
const title = t("AcknowledgementsPage.title");
|
const title = t('AcknowledgementsPage.title')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page title={title}>
|
<Page title={title}>
|
||||||
<Header as="h2">{title}</Header>
|
<Header as="h2">{title}</Header>
|
||||||
<Markdown>{t("AcknowledgementsPage.information")}</Markdown>
|
<Markdown>{t('AcknowledgementsPage.information')}</Markdown>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,134 +1,121 @@
|
||||||
import React, { useState, useCallback, useMemo } from "react";
|
import React, {useState, useCallback, useMemo} from 'react'
|
||||||
import { Source, Layer } from "react-map-gl";
|
import {Source, Layer} from 'react-map-gl'
|
||||||
import _ from "lodash";
|
import _ from 'lodash'
|
||||||
import {
|
import {Button, Form, Dropdown, Header, Message, Icon} from 'semantic-ui-react'
|
||||||
Button,
|
import {useTranslation, Trans as Translate} from 'react-i18next'
|
||||||
Form,
|
import Markdown from 'react-markdown'
|
||||||
Dropdown,
|
|
||||||
Header,
|
|
||||||
Message,
|
|
||||||
Icon,
|
|
||||||
} from "semantic-ui-react";
|
|
||||||
import { useTranslation, Trans as Translate } from "react-i18next";
|
|
||||||
import Markdown from "react-markdown";
|
|
||||||
|
|
||||||
import { useConfig } from "config";
|
import {useConfig} from 'config'
|
||||||
import { Page, Map } from "components";
|
import {Page, Map} from 'components'
|
||||||
|
|
||||||
const BoundingBoxSelector = React.forwardRef(
|
const BoundingBoxSelector = React.forwardRef(({value, name, onChange}, ref) => {
|
||||||
({ value, name, onChange }, ref) => {
|
const {t} = useTranslation()
|
||||||
const { t } = useTranslation();
|
const [pointNum, setPointNum] = useState(0)
|
||||||
const [pointNum, setPointNum] = useState(0);
|
const [point0, setPoint0] = useState(null)
|
||||||
const [point0, setPoint0] = useState(null);
|
const [point1, setPoint1] = useState(null)
|
||||||
const [point1, setPoint1] = useState(null);
|
|
||||||
|
|
||||||
const onClick = (e) => {
|
const onClick = (e) => {
|
||||||
if (pointNum == 0) {
|
if (pointNum == 0) {
|
||||||
setPoint0(e.lngLat);
|
setPoint0(e.lngLat)
|
||||||
} else {
|
} else {
|
||||||
setPoint1(e.lngLat);
|
setPoint1(e.lngLat)
|
||||||
}
|
}
|
||||||
setPointNum(1 - pointNum);
|
setPointNum(1 - pointNum)
|
||||||
};
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!point0 || !point1) return;
|
|
||||||
const bbox = `${point0[0]},${point0[1]},${point1[0]},${point1[1]}`;
|
|
||||||
if (bbox !== value) {
|
|
||||||
onChange(bbox);
|
|
||||||
}
|
|
||||||
}, [point0, point1]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!value) return;
|
|
||||||
const [p00, p01, p10, p11] = value
|
|
||||||
.split(",")
|
|
||||||
.map((v) => Number.parseFloat(v));
|
|
||||||
if (!point0 || point0[0] != p00 || point0[1] != p01)
|
|
||||||
setPoint0([p00, p01]);
|
|
||||||
if (!point1 || point1[0] != p10 || point1[1] != p11)
|
|
||||||
setPoint1([p10, p11]);
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Form.Input
|
|
||||||
label={t('ExportPage.boundingBox.label')}
|
|
||||||
{...{ name, value }}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div style={{ height: 400, position: "relative", marginBottom: 16 }}>
|
|
||||||
<Map onClick={onClick}>
|
|
||||||
<Source
|
|
||||||
id="bbox"
|
|
||||||
type="geojson"
|
|
||||||
data={
|
|
||||||
point0 && point1
|
|
||||||
? {
|
|
||||||
type: "FeatureCollection",
|
|
||||||
features: [
|
|
||||||
{
|
|
||||||
type: "Feature",
|
|
||||||
geometry: {
|
|
||||||
type: "Polygon",
|
|
||||||
coordinates: [
|
|
||||||
[
|
|
||||||
[point0[0], point0[1]],
|
|
||||||
[point1[0], point0[1]],
|
|
||||||
[point1[0], point1[1]],
|
|
||||||
[point0[0], point1[1]],
|
|
||||||
[point0[0], point0[1]],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Layer
|
|
||||||
id="bbox"
|
|
||||||
type="line"
|
|
||||||
paint={{
|
|
||||||
"line-width": 4,
|
|
||||||
"line-color": "#F06292",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Source>
|
|
||||||
</Map>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
const MODES = ["events", "segments"];
|
React.useEffect(() => {
|
||||||
const FORMATS = ["geojson", "shapefile"];
|
if (!point0 || !point1) return
|
||||||
|
const bbox = `${point0[0]},${point0[1]},${point1[0]},${point1[1]}`
|
||||||
|
if (bbox !== value) {
|
||||||
|
onChange(bbox)
|
||||||
|
}
|
||||||
|
}, [point0, point1])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!value) return
|
||||||
|
const [p00, p01, p10, p11] = value.split(',').map((v) => Number.parseFloat(v))
|
||||||
|
if (!point0 || point0[0] != p00 || point0[1] != p01) setPoint0([p00, p01])
|
||||||
|
if (!point1 || point1[0] != p10 || point1[1] != p11) setPoint1([p10, p11])
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Form.Input
|
||||||
|
label={t('ExportPage.boundingBox.label')}
|
||||||
|
{...{name, value}}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{height: 400, position: 'relative', marginBottom: 16}}>
|
||||||
|
<Map onClick={onClick}>
|
||||||
|
<Source
|
||||||
|
id="bbox"
|
||||||
|
type="geojson"
|
||||||
|
data={
|
||||||
|
point0 && point1
|
||||||
|
? {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Polygon',
|
||||||
|
coordinates: [
|
||||||
|
[
|
||||||
|
[point0[0], point0[1]],
|
||||||
|
[point1[0], point0[1]],
|
||||||
|
[point1[0], point1[1]],
|
||||||
|
[point0[0], point1[1]],
|
||||||
|
[point0[0], point0[1]],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Layer
|
||||||
|
id="bbox"
|
||||||
|
type="line"
|
||||||
|
paint={{
|
||||||
|
'line-width': 4,
|
||||||
|
'line-color': '#F06292',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Source>
|
||||||
|
</Map>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const MODES = ['events', 'segments']
|
||||||
|
const FORMATS = ['geojson', 'shapefile']
|
||||||
|
|
||||||
export default function ExportPage() {
|
export default function ExportPage() {
|
||||||
const [mode, setMode] = useState("events");
|
const [mode, setMode] = useState('events')
|
||||||
const [bbox, setBbox] = useState("8.294678,49.651182,9.059601,50.108249");
|
const [bbox, setBbox] = useState('8.294678,49.651182,9.059601,50.108249')
|
||||||
const [fmt, setFmt] = useState("geojson");
|
const [fmt, setFmt] = useState('geojson')
|
||||||
const config = useConfig();
|
const config = useConfig()
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
return (
|
return (
|
||||||
<Page title="Export">
|
<Page title="Export">
|
||||||
<Header as="h2">{t("ExportPage.title")}</Header>
|
<Header as="h2">{t('ExportPage.title')}</Header>
|
||||||
|
|
||||||
<Message icon info>
|
<Message icon info>
|
||||||
<Icon name="info circle" />
|
<Icon name="info circle" />
|
||||||
<Message.Content>
|
<Message.Content>
|
||||||
<Markdown>{t("ExportPage.information")}</Markdown>
|
<Markdown>{t('ExportPage.information')}</Markdown>
|
||||||
</Message.Content>
|
</Message.Content>
|
||||||
</Message>
|
</Message>
|
||||||
|
|
||||||
<Form>
|
<Form>
|
||||||
<Form.Field>
|
<Form.Field>
|
||||||
<label>{t("ExportPage.mode.label")}</label>
|
<label>{t('ExportPage.mode.label')}</label>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
placeholder={t("ExportPage.mode.placeholder")}
|
placeholder={t('ExportPage.mode.placeholder')}
|
||||||
fluid
|
fluid
|
||||||
selection
|
selection
|
||||||
options={MODES.map((value) => ({
|
options={MODES.map((value) => ({
|
||||||
|
@ -137,14 +124,14 @@ export default function ExportPage() {
|
||||||
value,
|
value,
|
||||||
}))}
|
}))}
|
||||||
value={mode}
|
value={mode}
|
||||||
onChange={(_e, { value }) => setMode(value)}
|
onChange={(_e, {value}) => setMode(value)}
|
||||||
/>
|
/>
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
|
|
||||||
<Form.Field>
|
<Form.Field>
|
||||||
<label>{t("ExportPage.format.label")}</label>
|
<label>{t('ExportPage.format.label')}</label>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
placeholder={t("ExportPage.format.placeholder")}
|
placeholder={t('ExportPage.format.placeholder')}
|
||||||
fluid
|
fluid
|
||||||
selection
|
selection
|
||||||
options={FORMATS.map((value) => ({
|
options={FORMATS.map((value) => ({
|
||||||
|
@ -153,7 +140,7 @@ export default function ExportPage() {
|
||||||
value,
|
value,
|
||||||
}))}
|
}))}
|
||||||
value={fmt}
|
value={fmt}
|
||||||
onChange={(_e, { value }) => setFmt(value)}
|
onChange={(_e, {value}) => setFmt(value)}
|
||||||
/>
|
/>
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
|
|
||||||
|
@ -170,5 +157,5 @@ export default function ExportPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,69 +1,66 @@
|
||||||
import React from "react";
|
import React from 'react'
|
||||||
import { connect } from "react-redux";
|
import {connect} from 'react-redux'
|
||||||
import { Redirect, useLocation, useHistory } from "react-router-dom";
|
import {Redirect, useLocation, useHistory} from 'react-router-dom'
|
||||||
import { Icon, Message } from "semantic-ui-react";
|
import {Icon, Message} from 'semantic-ui-react'
|
||||||
import { useObservable } from "rxjs-hooks";
|
import {useObservable} from 'rxjs-hooks'
|
||||||
import { switchMap, pluck, distinctUntilChanged } from "rxjs/operators";
|
import {switchMap, pluck, distinctUntilChanged} from 'rxjs/operators'
|
||||||
import { useTranslation } from "react-i18next";
|
import {useTranslation} from 'react-i18next'
|
||||||
|
|
||||||
import { Page } from "components";
|
import {Page} from 'components'
|
||||||
import api from "api";
|
import api from 'api'
|
||||||
|
|
||||||
const LoginRedirectPage = connect((state) => ({
|
const LoginRedirectPage = connect((state) => ({
|
||||||
loggedIn: Boolean(state.login),
|
loggedIn: Boolean(state.login),
|
||||||
}))(function LoginRedirectPage({ loggedIn }) {
|
}))(function LoginRedirectPage({loggedIn}) {
|
||||||
const location = useLocation();
|
const location = useLocation()
|
||||||
const history = useHistory();
|
const history = useHistory()
|
||||||
const { search } = location;
|
const {search} = location
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
|
|
||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
|
|
||||||
// Hook dependency arrays in this block are intentionally left blank, we want
|
// Hook dependency arrays in this block are intentionally left blank, we want
|
||||||
// to keep the initial state, but reset the url once, ASAP, to not leak the
|
// to keep the initial state, but reset the url once, ASAP, to not leak the
|
||||||
// query parameters. This is considered good practice by OAuth.
|
// query parameters. This is considered good practice by OAuth.
|
||||||
const searchParams = React.useMemo(
|
const searchParams = React.useMemo(() => Object.fromEntries(new URLSearchParams(search).entries()), [])
|
||||||
() => Object.fromEntries(new URLSearchParams(search).entries()),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
history.replace({ ...location, search: "" });
|
history.replace({...location, search: ''})
|
||||||
}, []);
|
}, [])
|
||||||
/* eslint-enable react-hooks/exhaustive-deps */
|
/* eslint-enable react-hooks/exhaustive-deps */
|
||||||
|
|
||||||
if (loggedIn) {
|
if (loggedIn) {
|
||||||
return <Redirect to="/" />;
|
return <Redirect to="/" />
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error, error_description: errorDescription, code } = searchParams;
|
const {error, error_description: errorDescription, code} = searchParams
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Page small title={t("LoginRedirectPage.loginError")}>
|
<Page small title={t('LoginRedirectPage.loginError')}>
|
||||||
<LoginError errorText={errorDescription || error} />
|
<LoginError errorText={errorDescription || error} />
|
||||||
</Page>
|
</Page>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ExchangeAuthCode code={code} />;
|
return <ExchangeAuthCode code={code} />
|
||||||
});
|
})
|
||||||
|
|
||||||
function LoginError({ errorText }: { errorText: string }) {
|
function LoginError({errorText}: {errorText: string}) {
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
return (
|
return (
|
||||||
<Message icon error>
|
<Message icon error>
|
||||||
<Icon name="warning sign" />
|
<Icon name="warning sign" />
|
||||||
<Message.Content>
|
<Message.Content>
|
||||||
<Message.Header>{t("LoginRedirectPage.loginError")}</Message.Header>
|
<Message.Header>{t('LoginRedirectPage.loginError')}</Message.Header>
|
||||||
{t("LoginRedirectPage.loginErrorText", { error: errorText })}
|
{t('LoginRedirectPage.loginErrorText', {error: errorText})}
|
||||||
</Message.Content>
|
</Message.Content>
|
||||||
</Message>
|
</Message>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExchangeAuthCode({ code }) {
|
function ExchangeAuthCode({code}) {
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
const result = useObservable(
|
const result = useObservable(
|
||||||
(_$, args$) =>
|
(_$, args$) =>
|
||||||
args$.pipe(
|
args$.pipe(
|
||||||
|
@ -73,31 +70,31 @@ function ExchangeAuthCode({ code }) {
|
||||||
),
|
),
|
||||||
null,
|
null,
|
||||||
[code]
|
[code]
|
||||||
);
|
)
|
||||||
|
|
||||||
let content;
|
let content
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
content = (
|
content = (
|
||||||
<Message icon info>
|
<Message icon info>
|
||||||
<Icon name="circle notched" loading />
|
<Icon name="circle notched" loading />
|
||||||
<Message.Content>
|
<Message.Content>
|
||||||
<Message.Header>{t("LoginRedirectPage.loggingIn")}</Message.Header>
|
<Message.Header>{t('LoginRedirectPage.loggingIn')}</Message.Header>
|
||||||
{t("LoginRedirectPage.hangTight")}
|
{t('LoginRedirectPage.hangTight')}
|
||||||
</Message.Content>
|
</Message.Content>
|
||||||
</Message>
|
</Message>
|
||||||
);
|
)
|
||||||
} else if (result === true) {
|
} else if (result === true) {
|
||||||
content = <Redirect to="/" />;
|
content = <Redirect to="/" />
|
||||||
} else {
|
} else {
|
||||||
const { error, error_description: errorDescription } = result;
|
const {error, error_description: errorDescription} = result
|
||||||
content = <LoginError errorText={errorDescription || error} />;
|
content = <LoginError errorText={errorDescription || error} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page small title="Login">
|
<Page small title="Login">
|
||||||
{content}
|
{content}
|
||||||
</Page>
|
</Page>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default LoginRedirectPage;
|
export default LoginRedirectPage
|
||||||
|
|
|
@ -1,81 +1,65 @@
|
||||||
import React from "react";
|
import React from 'react'
|
||||||
import _ from "lodash";
|
import _ from 'lodash'
|
||||||
import { connect } from "react-redux";
|
import {connect} from 'react-redux'
|
||||||
import { Link } from "react-router-dom";
|
import {Link} from 'react-router-dom'
|
||||||
import {
|
import {List, Select, Input, Divider, Label, Checkbox, Header} from 'semantic-ui-react'
|
||||||
List,
|
import {useTranslation} from 'react-i18next'
|
||||||
Select,
|
|
||||||
Input,
|
|
||||||
Divider,
|
|
||||||
Label,
|
|
||||||
Checkbox,
|
|
||||||
Header,
|
|
||||||
} from "semantic-ui-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MapConfig,
|
MapConfig,
|
||||||
setMapConfigFlag as setMapConfigFlagAction,
|
setMapConfigFlag as setMapConfigFlagAction,
|
||||||
initialState as defaultMapConfig,
|
initialState as defaultMapConfig,
|
||||||
} from "reducers/mapConfig";
|
} from 'reducers/mapConfig'
|
||||||
import { colorByDistance, colorByCount, viridisSimpleHtml } from "mapstyles";
|
import {colorByDistance, colorByCount, viridisSimpleHtml} from 'mapstyles'
|
||||||
import { ColorMapLegend, DiscreteColorMapLegend } from "components";
|
import {ColorMapLegend, DiscreteColorMapLegend} from 'components'
|
||||||
import styles from "./styles.module.less";
|
import styles from './styles.module.less'
|
||||||
|
|
||||||
const BASEMAP_STYLE_OPTIONS = ["positron", "bright"];
|
const BASEMAP_STYLE_OPTIONS = ['positron', 'bright']
|
||||||
|
|
||||||
const ROAD_ATTRIBUTE_OPTIONS = [
|
const ROAD_ATTRIBUTE_OPTIONS = [
|
||||||
"distance_overtaker_mean",
|
'distance_overtaker_mean',
|
||||||
"distance_overtaker_min",
|
'distance_overtaker_min',
|
||||||
"distance_overtaker_max",
|
'distance_overtaker_max',
|
||||||
"distance_overtaker_median",
|
'distance_overtaker_median',
|
||||||
"overtaking_event_count",
|
'overtaking_event_count',
|
||||||
"usage_count",
|
'usage_count',
|
||||||
"zone",
|
'zone',
|
||||||
];
|
]
|
||||||
|
|
||||||
const DATE_FILTER_MODES = ["none", "range", "threshold"];
|
const DATE_FILTER_MODES = ['none', 'range', 'threshold']
|
||||||
|
|
||||||
type User = Object;
|
type User = Object
|
||||||
|
|
||||||
function LayerSidebar({
|
function LayerSidebar({
|
||||||
mapConfig,
|
mapConfig,
|
||||||
login,
|
login,
|
||||||
setMapConfigFlag,
|
setMapConfigFlag,
|
||||||
}: {
|
}: {
|
||||||
login: User | null;
|
login: User | null
|
||||||
mapConfig: MapConfig;
|
mapConfig: MapConfig
|
||||||
setMapConfigFlag: (flag: string, value: unknown) => void;
|
setMapConfigFlag: (flag: string, value: unknown) => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
const {
|
const {
|
||||||
baseMap: { style },
|
baseMap: {style},
|
||||||
obsRoads: { show: showRoads, showUntagged, attribute, maxCount },
|
obsRoads: {show: showRoads, showUntagged, attribute, maxCount},
|
||||||
obsEvents: { show: showEvents },
|
obsEvents: {show: showEvents},
|
||||||
obsRegions: { show: showRegions },
|
obsRegions: {show: showRegions},
|
||||||
filters: {
|
filters: {currentUser: filtersCurrentUser, dateMode, startDate, endDate, thresholdAfter},
|
||||||
currentUser: filtersCurrentUser,
|
} = mapConfig
|
||||||
dateMode,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
thresholdAfter,
|
|
||||||
},
|
|
||||||
} = mapConfig;
|
|
||||||
|
|
||||||
const openStreetMapCopyright = (
|
const openStreetMapCopyright = (
|
||||||
<List.Item className={styles.copyright}>
|
<List.Item className={styles.copyright}>
|
||||||
{t("MapPage.sidebar.copyright.openStreetMap")}{" "}
|
{t('MapPage.sidebar.copyright.openStreetMap')}{' '}
|
||||||
<Link to="/acknowledgements">
|
<Link to="/acknowledgements">{t('MapPage.sidebar.copyright.learnMore')}</Link>
|
||||||
{t("MapPage.sidebar.copyright.learnMore")}
|
|
||||||
</Link>
|
|
||||||
</List.Item>
|
</List.Item>
|
||||||
);
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<List relaxed>
|
<List relaxed>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<List.Header>{t("MapPage.sidebar.baseMap.style.label")}</List.Header>
|
<List.Header>{t('MapPage.sidebar.baseMap.style.label')}</List.Header>
|
||||||
<Select
|
<Select
|
||||||
options={BASEMAP_STYLE_OPTIONS.map((value) => ({
|
options={BASEMAP_STYLE_OPTIONS.map((value) => ({
|
||||||
value,
|
value,
|
||||||
|
@ -83,9 +67,7 @@ function LayerSidebar({
|
||||||
text: t(`MapPage.sidebar.baseMap.style.${value}`),
|
text: t(`MapPage.sidebar.baseMap.style.${value}`),
|
||||||
}))}
|
}))}
|
||||||
value={style}
|
value={style}
|
||||||
onChange={(_e, { value }) =>
|
onChange={(_e, {value}) => setMapConfigFlag('baseMap.style', value)}
|
||||||
setMapConfigFlag("baseMap.style", value)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
{openStreetMapCopyright}
|
{openStreetMapCopyright}
|
||||||
|
@ -95,34 +77,30 @@ function LayerSidebar({
|
||||||
toggle
|
toggle
|
||||||
size="small"
|
size="small"
|
||||||
id="obsRegions.show"
|
id="obsRegions.show"
|
||||||
style={{ float: "right" }}
|
style={{float: 'right'}}
|
||||||
checked={showRegions}
|
checked={showRegions}
|
||||||
onChange={() => setMapConfigFlag("obsRegions.show", !showRegions)}
|
onChange={() => setMapConfigFlag('obsRegions.show', !showRegions)}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="obsRegions.show">
|
<label htmlFor="obsRegions.show">
|
||||||
<Header as="h4">{t("MapPage.sidebar.obsRegions.title")}</Header>
|
<Header as="h4">{t('MapPage.sidebar.obsRegions.title')}</Header>
|
||||||
</label>
|
</label>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
{showRegions && (
|
{showRegions && (
|
||||||
<>
|
<>
|
||||||
<List.Item>
|
<List.Item>{t('MapPage.sidebar.obsRegions.colorByEventCount')}</List.Item>
|
||||||
{t("MapPage.sidebar.obsRegions.colorByEventCount")}
|
|
||||||
</List.Item>
|
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<ColorMapLegend
|
<ColorMapLegend
|
||||||
twoTicks
|
twoTicks
|
||||||
map={[
|
map={[
|
||||||
[0, "#00897B00"],
|
[0, '#00897B00'],
|
||||||
[5000, "#00897BFF"],
|
[5000, '#00897BFF'],
|
||||||
]}
|
]}
|
||||||
digits={0}
|
digits={0}
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item className={styles.copyright}>
|
<List.Item className={styles.copyright}>
|
||||||
{t("MapPage.sidebar.copyright.boundaries")}{" "}
|
{t('MapPage.sidebar.copyright.boundaries')}{' '}
|
||||||
<Link to="/acknowledgements">
|
<Link to="/acknowledgements">{t('MapPage.sidebar.copyright.learnMore')}</Link>
|
||||||
{t("MapPage.sidebar.copyright.learnMore")}
|
|
||||||
</Link>
|
|
||||||
</List.Item>
|
</List.Item>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -132,12 +110,12 @@ function LayerSidebar({
|
||||||
toggle
|
toggle
|
||||||
size="small"
|
size="small"
|
||||||
id="obsRoads.show"
|
id="obsRoads.show"
|
||||||
style={{ float: "right" }}
|
style={{float: 'right'}}
|
||||||
checked={showRoads}
|
checked={showRoads}
|
||||||
onChange={() => setMapConfigFlag("obsRoads.show", !showRoads)}
|
onChange={() => setMapConfigFlag('obsRoads.show', !showRoads)}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="obsRoads.show">
|
<label htmlFor="obsRoads.show">
|
||||||
<Header as="h4">{t("MapPage.sidebar.obsRoads.title")}</Header>
|
<Header as="h4">{t('MapPage.sidebar.obsRoads.title')}</Header>
|
||||||
</label>
|
</label>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
{showRoads && (
|
{showRoads && (
|
||||||
|
@ -145,16 +123,12 @@ function LayerSidebar({
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={showUntagged}
|
checked={showUntagged}
|
||||||
onChange={() =>
|
onChange={() => setMapConfigFlag('obsRoads.showUntagged', !showUntagged)}
|
||||||
setMapConfigFlag("obsRoads.showUntagged", !showUntagged)
|
label={t('MapPage.sidebar.obsRoads.showUntagged.label')}
|
||||||
}
|
|
||||||
label={t("MapPage.sidebar.obsRoads.showUntagged.label")}
|
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<List.Header>
|
<List.Header>{t('MapPage.sidebar.obsRoads.attribute.label')}</List.Header>
|
||||||
{t("MapPage.sidebar.obsRoads.attribute.label")}
|
|
||||||
</List.Header>
|
|
||||||
<Select
|
<Select
|
||||||
fluid
|
fluid
|
||||||
options={ROAD_ATTRIBUTE_OPTIONS.map((value) => ({
|
options={ROAD_ATTRIBUTE_OPTIONS.map((value) => ({
|
||||||
|
@ -163,74 +137,50 @@ function LayerSidebar({
|
||||||
text: t(`MapPage.sidebar.obsRoads.attribute.${value}`),
|
text: t(`MapPage.sidebar.obsRoads.attribute.${value}`),
|
||||||
}))}
|
}))}
|
||||||
value={attribute}
|
value={attribute}
|
||||||
onChange={(_e, { value }) =>
|
onChange={(_e, {value}) => setMapConfigFlag('obsRoads.attribute', value)}
|
||||||
setMapConfigFlag("obsRoads.attribute", value)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
{attribute.endsWith("_count") ? (
|
{attribute.endsWith('_count') ? (
|
||||||
<>
|
<>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<List.Header>
|
<List.Header>{t('MapPage.sidebar.obsRoads.maxCount.label')}</List.Header>
|
||||||
{t("MapPage.sidebar.obsRoads.maxCount.label")}
|
|
||||||
</List.Header>
|
|
||||||
<Input
|
<Input
|
||||||
fluid
|
fluid
|
||||||
type="number"
|
type="number"
|
||||||
value={maxCount}
|
value={maxCount}
|
||||||
onChange={(_e, { value }) =>
|
onChange={(_e, {value}) => setMapConfigFlag('obsRoads.maxCount', value)}
|
||||||
setMapConfigFlag("obsRoads.maxCount", value)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<ColorMapLegend
|
<ColorMapLegend
|
||||||
map={_.chunk(
|
map={_.chunk(
|
||||||
colorByCount(
|
colorByCount('obsRoads.maxCount', mapConfig.obsRoads.maxCount, viridisSimpleHtml).slice(3),
|
||||||
"obsRoads.maxCount",
|
|
||||||
mapConfig.obsRoads.maxCount,
|
|
||||||
viridisSimpleHtml
|
|
||||||
).slice(3),
|
|
||||||
2
|
2
|
||||||
)}
|
)}
|
||||||
twoTicks
|
twoTicks
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
</>
|
</>
|
||||||
) : attribute.endsWith("zone") ? (
|
) : attribute.endsWith('zone') ? (
|
||||||
<>
|
<>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<Label
|
<Label size="small" style={{background: 'blue', color: 'white'}}>
|
||||||
size="small"
|
{t('general.zone.urban')} (1.5 m)
|
||||||
style={{ background: "blue", color: "white" }}
|
|
||||||
>
|
|
||||||
{t("general.zone.urban")} (1.5 m)
|
|
||||||
</Label>
|
</Label>
|
||||||
<Label
|
<Label size="small" style={{background: 'cyan', color: 'black'}}>
|
||||||
size="small"
|
{t('general.zone.rural')}(2 m)
|
||||||
style={{ background: "cyan", color: "black" }}
|
|
||||||
>
|
|
||||||
{t("general.zone.rural")}(2 m)
|
|
||||||
</Label>
|
</Label>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<List.Header>
|
<List.Header>{_.upperFirst(t('general.zone.urban'))}</List.Header>
|
||||||
{_.upperFirst(t("general.zone.urban"))}
|
<DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][5].slice(2)} />
|
||||||
</List.Header>
|
|
||||||
<DiscreteColorMapLegend
|
|
||||||
map={colorByDistance("distance_overtaker")[3][5].slice(2)}
|
|
||||||
/>
|
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<List.Header>
|
<List.Header>{_.upperFirst(t('general.zone.rural'))}</List.Header>
|
||||||
{_.upperFirst(t("general.zone.rural"))}
|
<DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][3].slice(2)} />
|
||||||
</List.Header>
|
|
||||||
<DiscreteColorMapLegend
|
|
||||||
map={colorByDistance("distance_overtaker")[3][3].slice(2)}
|
|
||||||
/>
|
|
||||||
</List.Item>
|
</List.Item>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -243,40 +193,36 @@ function LayerSidebar({
|
||||||
toggle
|
toggle
|
||||||
size="small"
|
size="small"
|
||||||
id="obsEvents.show"
|
id="obsEvents.show"
|
||||||
style={{ float: "right" }}
|
style={{float: 'right'}}
|
||||||
checked={showEvents}
|
checked={showEvents}
|
||||||
onChange={() => setMapConfigFlag("obsEvents.show", !showEvents)}
|
onChange={() => setMapConfigFlag('obsEvents.show', !showEvents)}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="obsEvents.show">
|
<label htmlFor="obsEvents.show">
|
||||||
<Header as="h4">{t("MapPage.sidebar.obsEvents.title")}</Header>
|
<Header as="h4">{t('MapPage.sidebar.obsEvents.title')}</Header>
|
||||||
</label>
|
</label>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
{showEvents && (
|
{showEvents && (
|
||||||
<>
|
<>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<List.Header>{_.upperFirst(t("general.zone.urban"))}</List.Header>
|
<List.Header>{_.upperFirst(t('general.zone.urban'))}</List.Header>
|
||||||
<DiscreteColorMapLegend
|
<DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][5].slice(2)} />
|
||||||
map={colorByDistance("distance_overtaker")[3][5].slice(2)}
|
|
||||||
/>
|
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<List.Header>{_.upperFirst(t("general.zone.rural"))}</List.Header>
|
<List.Header>{_.upperFirst(t('general.zone.rural'))}</List.Header>
|
||||||
<DiscreteColorMapLegend
|
<DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][3].slice(2)} />
|
||||||
map={colorByDistance("distance_overtaker")[3][3].slice(2)}
|
|
||||||
/>
|
|
||||||
</List.Item>
|
</List.Item>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<Header as="h4">{t("MapPage.sidebar.filters.title")}</Header>
|
<Header as="h4">{t('MapPage.sidebar.filters.title')}</Header>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
|
|
||||||
{login && (
|
{login && (
|
||||||
<>
|
<>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<Header as="h5">{t("MapPage.sidebar.filters.userData")}</Header>
|
<Header as="h5">{t('MapPage.sidebar.filters.userData')}</Header>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
|
|
||||||
<List.Item>
|
<List.Item>
|
||||||
|
@ -285,15 +231,13 @@ function LayerSidebar({
|
||||||
size="small"
|
size="small"
|
||||||
id="filters.currentUser"
|
id="filters.currentUser"
|
||||||
checked={filtersCurrentUser}
|
checked={filtersCurrentUser}
|
||||||
onChange={() =>
|
onChange={() => setMapConfigFlag('filters.currentUser', !filtersCurrentUser)}
|
||||||
setMapConfigFlag("filters.currentUser", !filtersCurrentUser)
|
label={t('MapPage.sidebar.filters.currentUser')}
|
||||||
}
|
|
||||||
label={t("MapPage.sidebar.filters.currentUser")}
|
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
|
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<Header as="h5">{t("MapPage.sidebar.filters.dateRange")}</Header>
|
<Header as="h5">{t('MapPage.sidebar.filters.dateRange')}</Header>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
|
|
||||||
<List.Item>
|
<List.Item>
|
||||||
|
@ -304,14 +248,12 @@ function LayerSidebar({
|
||||||
key: value,
|
key: value,
|
||||||
text: t(`MapPage.sidebar.filters.dateMode.${value}`),
|
text: t(`MapPage.sidebar.filters.dateMode.${value}`),
|
||||||
}))}
|
}))}
|
||||||
value={dateMode ?? "none"}
|
value={dateMode ?? 'none'}
|
||||||
onChange={(_e, { value }) =>
|
onChange={(_e, {value}) => setMapConfigFlag('filters.dateMode', value)}
|
||||||
setMapConfigFlag("filters.dateMode", value)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
|
|
||||||
{dateMode == "range" && (
|
{dateMode == 'range' && (
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
|
@ -319,16 +261,14 @@ function LayerSidebar({
|
||||||
step="7"
|
step="7"
|
||||||
size="small"
|
size="small"
|
||||||
id="filters.startDate"
|
id="filters.startDate"
|
||||||
onChange={(_e, { value }) =>
|
onChange={(_e, {value}) => setMapConfigFlag('filters.startDate', value)}
|
||||||
setMapConfigFlag("filters.startDate", value)
|
|
||||||
}
|
|
||||||
value={startDate ?? null}
|
value={startDate ?? null}
|
||||||
label={t("MapPage.sidebar.filters.start")}
|
label={t('MapPage.sidebar.filters.start')}
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dateMode == "range" && (
|
{dateMode == 'range' && (
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
|
@ -336,16 +276,14 @@ function LayerSidebar({
|
||||||
step="7"
|
step="7"
|
||||||
size="small"
|
size="small"
|
||||||
id="filters.endDate"
|
id="filters.endDate"
|
||||||
onChange={(_e, { value }) =>
|
onChange={(_e, {value}) => setMapConfigFlag('filters.endDate', value)}
|
||||||
setMapConfigFlag("filters.endDate", value)
|
|
||||||
}
|
|
||||||
value={endDate ?? null}
|
value={endDate ?? null}
|
||||||
label={t("MapPage.sidebar.filters.end")}
|
label={t('MapPage.sidebar.filters.end')}
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dateMode == "threshold" && (
|
{dateMode == 'threshold' && (
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
|
@ -354,42 +292,33 @@ function LayerSidebar({
|
||||||
size="small"
|
size="small"
|
||||||
id="filters.startDate"
|
id="filters.startDate"
|
||||||
value={startDate ?? null}
|
value={startDate ?? null}
|
||||||
onChange={(_e, { value }) =>
|
onChange={(_e, {value}) => setMapConfigFlag('filters.startDate', value)}
|
||||||
setMapConfigFlag("filters.startDate", value)
|
label={t('MapPage.sidebar.filters.threshold')}
|
||||||
}
|
|
||||||
label={t("MapPage.sidebar.filters.threshold")}
|
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dateMode == "threshold" && (
|
{dateMode == 'threshold' && (
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<span>
|
<span>
|
||||||
{t("MapPage.sidebar.filters.before")}{" "}
|
{t('MapPage.sidebar.filters.before')}{' '}
|
||||||
<Checkbox
|
<Checkbox
|
||||||
toggle
|
toggle
|
||||||
size="small"
|
size="small"
|
||||||
checked={thresholdAfter ?? false}
|
checked={thresholdAfter ?? false}
|
||||||
onChange={() =>
|
onChange={() => setMapConfigFlag('filters.thresholdAfter', !thresholdAfter)}
|
||||||
setMapConfigFlag(
|
|
||||||
"filters.thresholdAfter",
|
|
||||||
!thresholdAfter
|
|
||||||
)
|
|
||||||
}
|
|
||||||
id="filters.thresholdAfter"
|
id="filters.thresholdAfter"
|
||||||
/>{" "}
|
/>{' '}
|
||||||
{t("MapPage.sidebar.filters.after")}
|
{t('MapPage.sidebar.filters.after')}
|
||||||
</span>
|
</span>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!login && (
|
{!login && <List.Item>{t('MapPage.sidebar.filters.needsLogin')}</List.Item>}
|
||||||
<List.Item>{t("MapPage.sidebar.filters.needsLogin")}</List.Item>
|
|
||||||
)}
|
|
||||||
</List>
|
</List>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect(
|
export default connect(
|
||||||
|
@ -402,6 +331,6 @@ export default connect(
|
||||||
),
|
),
|
||||||
login: state.login,
|
login: state.login,
|
||||||
}),
|
}),
|
||||||
{ setMapConfigFlag: setMapConfigFlagAction }
|
{setMapConfigFlag: setMapConfigFlagAction}
|
||||||
//
|
//
|
||||||
)(LayerSidebar);
|
)(LayerSidebar)
|
||||||
|
|
|
@ -1,296 +1,253 @@
|
||||||
import React, { useState, useCallback, useMemo, useRef } from "react";
|
import React, {useState, useCallback, useMemo, useRef} from 'react'
|
||||||
import _ from "lodash";
|
import _ from 'lodash'
|
||||||
import { connect } from "react-redux";
|
import {connect} from 'react-redux'
|
||||||
import { Button } from "semantic-ui-react";
|
import {Button} from 'semantic-ui-react'
|
||||||
import { Layer, Source } from "react-map-gl";
|
import {Layer, Source} from 'react-map-gl'
|
||||||
import produce from "immer";
|
import produce from 'immer'
|
||||||
import classNames from "classnames";
|
import classNames from 'classnames'
|
||||||
|
|
||||||
import api from "api";
|
import api from 'api'
|
||||||
import type { Location } from "types";
|
import type {Location} from 'types'
|
||||||
import { Page, Map } from "components";
|
import {Page, Map} from 'components'
|
||||||
import { useConfig } from "config";
|
import {useConfig} from 'config'
|
||||||
import {
|
import {colorByDistance, colorByCount, getRegionLayers, borderByZone, isValidAttribute} from 'mapstyles'
|
||||||
colorByDistance,
|
import {useMapConfig} from 'reducers/mapConfig'
|
||||||
colorByCount,
|
|
||||||
getRegionLayers,
|
|
||||||
borderByZone,
|
|
||||||
isValidAttribute,
|
|
||||||
} from "mapstyles";
|
|
||||||
import { useMapConfig } from "reducers/mapConfig";
|
|
||||||
|
|
||||||
import RoadInfo, { RoadInfoType } from "./RoadInfo";
|
import RoadInfo, {RoadInfoType} from './RoadInfo'
|
||||||
import RegionInfo from "./RegionInfo";
|
import RegionInfo from './RegionInfo'
|
||||||
import LayerSidebar from "./LayerSidebar";
|
import LayerSidebar from './LayerSidebar'
|
||||||
import styles from "./styles.module.less";
|
import styles from './styles.module.less'
|
||||||
|
|
||||||
const untaggedRoadsLayer = {
|
const untaggedRoadsLayer = {
|
||||||
id: "obs_roads_untagged",
|
id: 'obs_roads_untagged',
|
||||||
type: "line",
|
type: 'line',
|
||||||
source: "obs",
|
source: 'obs',
|
||||||
"source-layer": "obs_roads",
|
'source-layer': 'obs_roads',
|
||||||
minzoom: 12,
|
minzoom: 12,
|
||||||
filter: ["!", ["to-boolean", ["get", "distance_overtaker_mean"]]],
|
filter: ['!', ['to-boolean', ['get', 'distance_overtaker_mean']]],
|
||||||
layout: {
|
layout: {
|
||||||
"line-cap": "round",
|
'line-cap': 'round',
|
||||||
"line-join": "round",
|
'line-join': 'round',
|
||||||
},
|
},
|
||||||
paint: {
|
paint: {
|
||||||
"line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 12, 2, 17, 2],
|
'line-width': ['interpolate', ['exponential', 1.5], ['zoom'], 12, 2, 17, 2],
|
||||||
"line-color": "#ABC",
|
'line-color': '#ABC',
|
||||||
// "line-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1],
|
// "line-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1],
|
||||||
"line-offset": [
|
'line-offset': [
|
||||||
"interpolate",
|
'interpolate',
|
||||||
["exponential", 1.5],
|
['exponential', 1.5],
|
||||||
["zoom"],
|
['zoom'],
|
||||||
12,
|
12,
|
||||||
["get", "offset_direction"],
|
['get', 'offset_direction'],
|
||||||
19,
|
19,
|
||||||
["*", ["get", "offset_direction"], 8],
|
['*', ['get', 'offset_direction'], 8],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
||||||
const getUntaggedRoadsLayer = (colorAttribute) =>
|
const getUntaggedRoadsLayer = (colorAttribute) =>
|
||||||
produce(untaggedRoadsLayer, (draft) => {
|
produce(untaggedRoadsLayer, (draft) => {
|
||||||
draft.filter = ["!", isValidAttribute(colorAttribute)];
|
draft.filter = ['!', isValidAttribute(colorAttribute)]
|
||||||
});
|
})
|
||||||
|
|
||||||
const getRoadsLayer = (colorAttribute, maxCount) =>
|
const getRoadsLayer = (colorAttribute, maxCount) =>
|
||||||
produce(untaggedRoadsLayer, (draft) => {
|
produce(untaggedRoadsLayer, (draft) => {
|
||||||
draft.id = "obs_roads_normal";
|
draft.id = 'obs_roads_normal'
|
||||||
draft.filter = isValidAttribute(colorAttribute);
|
draft.filter = isValidAttribute(colorAttribute)
|
||||||
draft.minzoom = 10;
|
draft.minzoom = 10
|
||||||
draft.paint["line-width"][6] = 6; // scale bigger on zoom
|
draft.paint['line-width'][6] = 6 // scale bigger on zoom
|
||||||
draft.paint["line-color"] = colorAttribute.startsWith("distance_")
|
draft.paint['line-color'] = colorAttribute.startsWith('distance_')
|
||||||
? colorByDistance(colorAttribute)
|
? colorByDistance(colorAttribute)
|
||||||
: colorAttribute.endsWith("_count")
|
: colorAttribute.endsWith('_count')
|
||||||
? colorByCount(colorAttribute, maxCount)
|
? colorByCount(colorAttribute, maxCount)
|
||||||
: colorAttribute.endsWith("zone")
|
: colorAttribute.endsWith('zone')
|
||||||
? borderByZone()
|
? borderByZone()
|
||||||
: "#DDD";
|
: '#DDD'
|
||||||
// draft.paint["line-opacity"][3] = 12;
|
// draft.paint["line-opacity"][3] = 12;
|
||||||
// draft.paint["line-opacity"][5] = 13;
|
// draft.paint["line-opacity"][5] = 13;
|
||||||
});
|
})
|
||||||
|
|
||||||
const getEventsLayer = () => ({
|
const getEventsLayer = () => ({
|
||||||
id: "obs_events",
|
id: 'obs_events',
|
||||||
type: "circle",
|
type: 'circle',
|
||||||
source: "obs",
|
source: 'obs',
|
||||||
"source-layer": "obs_events",
|
'source-layer': 'obs_events',
|
||||||
paint: {
|
paint: {
|
||||||
"circle-radius": ["interpolate", ["linear"], ["zoom"], 14, 3, 17, 8],
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], 14, 3, 17, 8],
|
||||||
"circle-opacity": ["interpolate",["linear"],["zoom"],8,0.1,9,0.3,10,0.5,11,1],
|
'circle-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.1, 9, 0.3, 10, 0.5, 11, 1],
|
||||||
"circle-color": colorByDistance("distance_overtaker"),
|
'circle-color': colorByDistance('distance_overtaker'),
|
||||||
},
|
},
|
||||||
minzoom: 8,
|
minzoom: 8,
|
||||||
});
|
})
|
||||||
|
|
||||||
const getEventsTextLayer = () => ({
|
const getEventsTextLayer = () => ({
|
||||||
id: "obs_events_text",
|
id: 'obs_events_text',
|
||||||
type: "symbol",
|
type: 'symbol',
|
||||||
minzoom: 18,
|
minzoom: 18,
|
||||||
source: "obs",
|
source: 'obs',
|
||||||
"source-layer": "obs_events",
|
'source-layer': 'obs_events',
|
||||||
layout: {
|
layout: {
|
||||||
"text-field": [
|
'text-field': [
|
||||||
"number-format",
|
'number-format',
|
||||||
["get", "distance_overtaker"],
|
['get', 'distance_overtaker'],
|
||||||
{ "min-fraction-digits": 2, "max-fraction-digits": 2 },
|
{'min-fraction-digits': 2, 'max-fraction-digits': 2},
|
||||||
],
|
],
|
||||||
"text-allow-overlap": true,
|
'text-allow-overlap': true,
|
||||||
"text-size": 14,
|
'text-size': 14,
|
||||||
"text-keep-upright": false,
|
'text-keep-upright': false,
|
||||||
"text-anchor": "left",
|
'text-anchor': 'left',
|
||||||
"text-radial-offset": 1,
|
'text-radial-offset': 1,
|
||||||
"text-rotate": ["-", 90, ["*", ["get", "course"], 180 / Math.PI]],
|
'text-rotate': ['-', 90, ['*', ['get', 'course'], 180 / Math.PI]],
|
||||||
"text-rotation-alignment": "map",
|
'text-rotation-alignment': 'map',
|
||||||
},
|
},
|
||||||
paint: {
|
paint: {
|
||||||
"text-halo-color": "rgba(255, 255, 255, 1)",
|
'text-halo-color': 'rgba(255, 255, 255, 1)',
|
||||||
"text-halo-width": 1,
|
'text-halo-width': 1,
|
||||||
"text-opacity": ["interpolate", ["linear"], ["zoom"], 15, 0, 15.3, 1],
|
'text-opacity': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.3, 1],
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
interface RegionInfo {
|
interface RegionInfo {
|
||||||
properties: {
|
properties: {
|
||||||
admin_level: number;
|
admin_level: number
|
||||||
name: string;
|
name: string
|
||||||
overtaking_event_count: number;
|
overtaking_event_count: number
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Details =
|
type Details = {type: 'road'; road: RoadInfoType} | {type: 'region'; region: RegionInfo}
|
||||||
| { type: "road"; road: RoadInfoType }
|
|
||||||
| { type: "region"; region: RegionInfo };
|
|
||||||
|
|
||||||
function MapPage({ login }) {
|
function MapPage({login}) {
|
||||||
const { obsMapSource, banner } = useConfig() || {};
|
const {obsMapSource, banner} = useConfig() || {}
|
||||||
const [details, setDetails] = useState<null | Details>(null);
|
const [details, setDetails] = useState<null | Details>(null)
|
||||||
|
|
||||||
const onCloseDetails = useCallback(() => setDetails(null), [setDetails]);
|
const onCloseDetails = useCallback(() => setDetails(null), [setDetails])
|
||||||
|
|
||||||
const mapConfig = useMapConfig();
|
const mapConfig = useMapConfig()
|
||||||
|
|
||||||
const viewportRef = useRef();
|
const viewportRef = useRef()
|
||||||
const mapInfoPortal = useRef();
|
const mapInfoPortal = useRef()
|
||||||
|
|
||||||
const onViewportChange = useCallback(
|
const onViewportChange = useCallback(
|
||||||
(viewport) => {
|
(viewport) => {
|
||||||
viewportRef.current = viewport;
|
viewportRef.current = viewport
|
||||||
},
|
},
|
||||||
[viewportRef]
|
[viewportRef]
|
||||||
);
|
)
|
||||||
|
|
||||||
const onClick = useCallback(
|
const onClick = useCallback(
|
||||||
async (e) => {
|
async (e) => {
|
||||||
// check if we clicked inside the mapInfoBox, if so, early exit
|
// check if we clicked inside the mapInfoBox, if so, early exit
|
||||||
let node = e.target;
|
let node = e.target
|
||||||
while (node) {
|
while (node) {
|
||||||
if (
|
if ([styles.mapInfoBox, styles.mapToolbar].some((className) => node?.classList?.contains(className))) {
|
||||||
[styles.mapInfoBox, styles.mapToolbar].some((className) =>
|
return
|
||||||
node?.classList?.contains(className)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
node = node.parentNode;
|
node = node.parentNode
|
||||||
}
|
}
|
||||||
|
|
||||||
const { zoom } = viewportRef.current;
|
const {zoom} = viewportRef.current
|
||||||
|
|
||||||
if (zoom < 10) {
|
if (zoom < 10) {
|
||||||
const clickedRegion = e.features?.find(
|
const clickedRegion = e.features?.find((f) => f.source === 'obs' && f.sourceLayer === 'obs_regions')
|
||||||
(f) => f.source === "obs" && f.sourceLayer === "obs_regions"
|
setDetails(clickedRegion ? {type: 'region', region: clickedRegion} : null)
|
||||||
);
|
|
||||||
setDetails(
|
|
||||||
clickedRegion ? { type: "region", region: clickedRegion } : null
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
const road = await api.get("/mapdetails/road", {
|
const road = await api.get('/mapdetails/road', {
|
||||||
query: {
|
query: {
|
||||||
longitude: e.lngLat[0],
|
longitude: e.lngLat[0],
|
||||||
latitude: e.lngLat[1],
|
latitude: e.lngLat[1],
|
||||||
radius: 100,
|
radius: 100,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
setDetails(road?.road ? { type: "road", road } : null);
|
setDetails(road?.road ? {type: 'road', road} : null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setDetails]
|
[setDetails]
|
||||||
);
|
)
|
||||||
|
|
||||||
const [layerSidebar, setLayerSidebar] = useState(true);
|
const [layerSidebar, setLayerSidebar] = useState(true)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
obsRoads: { attribute, maxCount },
|
obsRoads: {attribute, maxCount},
|
||||||
} = mapConfig;
|
} = mapConfig
|
||||||
|
|
||||||
const layers = [];
|
const layers = []
|
||||||
|
|
||||||
const untaggedRoadsLayerCustom = useMemo(
|
const untaggedRoadsLayerCustom = useMemo(() => getUntaggedRoadsLayer(attribute), [attribute])
|
||||||
() => getUntaggedRoadsLayer(attribute),
|
|
||||||
[attribute]
|
|
||||||
);
|
|
||||||
if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) {
|
if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) {
|
||||||
layers.push(untaggedRoadsLayerCustom);
|
layers.push(untaggedRoadsLayerCustom)
|
||||||
}
|
}
|
||||||
|
|
||||||
const roadsLayer = useMemo(
|
const roadsLayer = useMemo(() => getRoadsLayer(attribute, maxCount), [attribute, maxCount])
|
||||||
() => getRoadsLayer(attribute, maxCount),
|
|
||||||
[attribute, maxCount]
|
|
||||||
);
|
|
||||||
if (mapConfig.obsRoads.show) {
|
if (mapConfig.obsRoads.show) {
|
||||||
layers.push(roadsLayer);
|
layers.push(roadsLayer)
|
||||||
}
|
}
|
||||||
|
|
||||||
const regionLayers = useMemo(() => getRegionLayers(), []);
|
const regionLayers = useMemo(() => getRegionLayers(), [])
|
||||||
if (mapConfig.obsRegions.show) {
|
if (mapConfig.obsRegions.show) {
|
||||||
layers.push(...regionLayers);
|
layers.push(...regionLayers)
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventsLayer = useMemo(() => getEventsLayer(), []);
|
const eventsLayer = useMemo(() => getEventsLayer(), [])
|
||||||
const eventsTextLayer = useMemo(() => getEventsTextLayer(), []);
|
const eventsTextLayer = useMemo(() => getEventsTextLayer(), [])
|
||||||
|
|
||||||
if (mapConfig.obsEvents.show) {
|
if (mapConfig.obsEvents.show) {
|
||||||
layers.push(eventsLayer);
|
layers.push(eventsLayer)
|
||||||
layers.push(eventsTextLayer);
|
layers.push(eventsTextLayer)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onToggleLayerSidebarButtonClick = useCallback(
|
const onToggleLayerSidebarButtonClick = useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation()
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
console.log("toggl;e");
|
console.log('toggl;e')
|
||||||
setLayerSidebar((v) => !v);
|
setLayerSidebar((v) => !v)
|
||||||
},
|
},
|
||||||
[setLayerSidebar]
|
[setLayerSidebar]
|
||||||
);
|
)
|
||||||
|
|
||||||
if (!obsMapSource) {
|
if (!obsMapSource) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const tiles = obsMapSource?.tiles?.map((tileUrl: string) => {
|
const tiles = obsMapSource?.tiles?.map((tileUrl: string) => {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams()
|
||||||
if (login) {
|
if (login) {
|
||||||
if (mapConfig.filters.currentUser) {
|
if (mapConfig.filters.currentUser) {
|
||||||
query.append("user", login.id);
|
query.append('user', login.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mapConfig.filters.dateMode === "range") {
|
if (mapConfig.filters.dateMode === 'range') {
|
||||||
if (mapConfig.filters.startDate) {
|
if (mapConfig.filters.startDate) {
|
||||||
query.append("start", mapConfig.filters.startDate);
|
query.append('start', mapConfig.filters.startDate)
|
||||||
}
|
}
|
||||||
if (mapConfig.filters.endDate) {
|
if (mapConfig.filters.endDate) {
|
||||||
query.append("end", mapConfig.filters.endDate);
|
query.append('end', mapConfig.filters.endDate)
|
||||||
}
|
}
|
||||||
} else if (mapConfig.filters.dateMode === "threshold") {
|
} else if (mapConfig.filters.dateMode === 'threshold') {
|
||||||
if (mapConfig.filters.startDate) {
|
if (mapConfig.filters.startDate) {
|
||||||
query.append(
|
query.append(mapConfig.filters.thresholdAfter ? 'start' : 'end', mapConfig.filters.startDate)
|
||||||
mapConfig.filters.thresholdAfter ? "start" : "end",
|
|
||||||
mapConfig.filters.startDate
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const queryString = String(query);
|
const queryString = String(query)
|
||||||
return tileUrl + (queryString ? "?" : "") + queryString;
|
return tileUrl + (queryString ? '?' : '') + queryString
|
||||||
});
|
})
|
||||||
|
|
||||||
const hasFilters: boolean =
|
const hasFilters: boolean = login && (mapConfig.filters.currentUser || mapConfig.filters.dateMode !== 'none')
|
||||||
login &&
|
|
||||||
(mapConfig.filters.currentUser || mapConfig.filters.dateMode !== "none");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page fullScreen title="Map">
|
<Page fullScreen title="Map">
|
||||||
<div
|
<div className={classNames(styles.mapContainer, banner ? styles.hasBanner : null)} ref={mapInfoPortal}>
|
||||||
className={classNames(
|
|
||||||
styles.mapContainer,
|
|
||||||
banner ? styles.hasBanner : null
|
|
||||||
)}
|
|
||||||
ref={mapInfoPortal}
|
|
||||||
>
|
|
||||||
{layerSidebar && (
|
{layerSidebar && (
|
||||||
<div className={styles.mapSidebar}>
|
<div className={styles.mapSidebar}>
|
||||||
<LayerSidebar />
|
<LayerSidebar />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={styles.map}>
|
<div className={styles.map}>
|
||||||
<Map
|
<Map viewportFromUrl onClick={onClick} hasToolbar onViewportChange={onViewportChange}>
|
||||||
viewportFromUrl
|
|
||||||
onClick={onClick}
|
|
||||||
hasToolbar
|
|
||||||
onViewportChange={onViewportChange}
|
|
||||||
>
|
|
||||||
<div className={styles.mapToolbar}>
|
<div className={styles.mapToolbar}>
|
||||||
<Button
|
<Button primary icon="bars" active={layerSidebar} onClick={onToggleLayerSidebarButtonClick} />
|
||||||
primary
|
|
||||||
icon="bars"
|
|
||||||
active={layerSidebar}
|
|
||||||
onClick={onToggleLayerSidebarButtonClick}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<Source id="obs" {...obsMapSource} tiles={tiles}>
|
<Source id="obs" {...obsMapSource} tiles={tiles}>
|
||||||
{layers.map((layer) => (
|
{layers.map((layer) => (
|
||||||
|
@ -298,27 +255,23 @@ function MapPage({ login }) {
|
||||||
))}
|
))}
|
||||||
</Source>
|
</Source>
|
||||||
|
|
||||||
{details?.type === "road" && details?.road?.road && (
|
{details?.type === 'road' && details?.road?.road && (
|
||||||
<RoadInfo
|
<RoadInfo
|
||||||
roadInfo={details.road}
|
roadInfo={details.road}
|
||||||
mapInfoPortal={mapInfoPortal.current}
|
mapInfoPortal={mapInfoPortal.current}
|
||||||
onClose={onCloseDetails}
|
onClose={onCloseDetails}
|
||||||
{...{ hasFilters }}
|
{...{hasFilters}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{details?.type === "region" && details?.region && (
|
{details?.type === 'region' && details?.region && (
|
||||||
<RegionInfo
|
<RegionInfo region={details.region} mapInfoPortal={mapInfoPortal.current} onClose={onCloseDetails} />
|
||||||
region={details.region}
|
|
||||||
mapInfoPortal={mapInfoPortal.current}
|
|
||||||
onClose={onCloseDetails}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Map>
|
</Map>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect((state) => ({ login: state.login }))(MapPage);
|
export default connect((state) => ({login: state.login}))(MapPage)
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useCallback, useMemo, useState } from "react";
|
import React, {useCallback, useMemo, useState} from 'react'
|
||||||
import { connect } from "react-redux";
|
import {connect} from 'react-redux'
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
Button,
|
Button,
|
||||||
|
@ -14,74 +14,65 @@ import {
|
||||||
SemanticCOLORS,
|
SemanticCOLORS,
|
||||||
SemanticICONS,
|
SemanticICONS,
|
||||||
Table,
|
Table,
|
||||||
} from "semantic-ui-react";
|
} from 'semantic-ui-react'
|
||||||
import { useObservable } from "rxjs-hooks";
|
import {useObservable} from 'rxjs-hooks'
|
||||||
import { Link } from "react-router-dom";
|
import {Link} from 'react-router-dom'
|
||||||
import { of, from, concat, BehaviorSubject, combineLatest } from "rxjs";
|
import {of, from, concat, BehaviorSubject, combineLatest} from 'rxjs'
|
||||||
import { map, switchMap, distinctUntilChanged } from "rxjs/operators";
|
import {map, switchMap, distinctUntilChanged} from 'rxjs/operators'
|
||||||
import _ from "lodash";
|
import _ from 'lodash'
|
||||||
import { useTranslation } from "react-i18next";
|
import {useTranslation} from 'react-i18next'
|
||||||
|
|
||||||
import type { ProcessingStatus, Track, UserDevice } from "types";
|
import type {ProcessingStatus, Track, UserDevice} from 'types'
|
||||||
import { Page, FormattedDate, Visibility } from "components";
|
import {Page, FormattedDate, Visibility} from 'components'
|
||||||
import api from "api";
|
import api from 'api'
|
||||||
import { useCallbackRef, formatDistance, formatDuration } from "utils";
|
import {useCallbackRef, formatDistance, formatDuration} from 'utils'
|
||||||
|
|
||||||
import download from "downloadjs";
|
import download from 'downloadjs'
|
||||||
|
|
||||||
const COLOR_BY_STATUS: Record<ProcessingStatus, SemanticCOLORS> = {
|
const COLOR_BY_STATUS: Record<ProcessingStatus, SemanticCOLORS> = {
|
||||||
error: "red",
|
error: 'red',
|
||||||
complete: "green",
|
complete: 'green',
|
||||||
created: "grey",
|
created: 'grey',
|
||||||
queued: "orange",
|
queued: 'orange',
|
||||||
processing: "orange",
|
processing: 'orange',
|
||||||
};
|
}
|
||||||
|
|
||||||
const ICON_BY_STATUS: Record<ProcessingStatus, SemanticICONS> = {
|
const ICON_BY_STATUS: Record<ProcessingStatus, SemanticICONS> = {
|
||||||
error: "warning sign",
|
error: 'warning sign',
|
||||||
complete: "check circle outline",
|
complete: 'check circle outline',
|
||||||
created: "bolt",
|
created: 'bolt',
|
||||||
queued: "bolt",
|
queued: 'bolt',
|
||||||
processing: "bolt",
|
processing: 'bolt',
|
||||||
};
|
}
|
||||||
|
|
||||||
function ProcessingStatusLabel({ status }: { status: ProcessingStatus }) {
|
function ProcessingStatusLabel({status}: {status: ProcessingStatus}) {
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
return (
|
return (
|
||||||
<span title={t(`TracksPage.processing.${status}`)}>
|
<span title={t(`TracksPage.processing.${status}`)}>
|
||||||
<Icon color={COLOR_BY_STATUS[status]} name={ICON_BY_STATUS[status]} />
|
<Icon color={COLOR_BY_STATUS[status]} name={ICON_BY_STATUS[status]} />
|
||||||
</span>
|
</span>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableHeader({
|
function SortableHeader({children, setOrderBy, orderBy, reversed, setReversed, name, ...props}) {
|
||||||
children,
|
|
||||||
setOrderBy,
|
|
||||||
orderBy,
|
|
||||||
reversed,
|
|
||||||
setReversed,
|
|
||||||
name,
|
|
||||||
...props
|
|
||||||
}) {
|
|
||||||
const toggleSort = (e) => {
|
const toggleSort = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
e.stopPropagation();
|
e.stopPropagation()
|
||||||
|
|
||||||
if (orderBy === name) {
|
if (orderBy === name) {
|
||||||
if (!reversed) {
|
if (!reversed) {
|
||||||
setReversed(true);
|
setReversed(true)
|
||||||
} else {
|
} else {
|
||||||
setReversed(false);
|
setReversed(false)
|
||||||
setOrderBy(null);
|
setOrderBy(null)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setReversed(false);
|
setReversed(false)
|
||||||
setOrderBy(name);
|
setOrderBy(name)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
let icon =
|
let icon = orderBy === name ? (reversed ? 'sort descending' : 'sort ascending') : null
|
||||||
orderBy === name ? (reversed ? "sort descending" : "sort ascending") : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table.HeaderCell {...props}>
|
<Table.HeaderCell {...props}>
|
||||||
|
@ -90,22 +81,22 @@ function SortableHeader({
|
||||||
<Icon name={icon} />
|
<Icon name={icon} />
|
||||||
</div>
|
</div>
|
||||||
</Table.HeaderCell>
|
</Table.HeaderCell>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Filters = {
|
type Filters = {
|
||||||
userDeviceId?: null | number;
|
userDeviceId?: null | number
|
||||||
visibility?: null | boolean;
|
visibility?: null | boolean
|
||||||
};
|
}
|
||||||
|
|
||||||
function TrackFilters({
|
function TrackFilters({
|
||||||
filters,
|
filters,
|
||||||
setFilters,
|
setFilters,
|
||||||
deviceNames,
|
deviceNames,
|
||||||
}: {
|
}: {
|
||||||
filters: Filters;
|
filters: Filters
|
||||||
setFilters: (f: Filters) => void;
|
setFilters: (f: Filters) => void
|
||||||
deviceNames: null | Record<number, string>;
|
deviceNames: null | Record<number, string>
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<List horizontal>
|
<List horizontal>
|
||||||
|
@ -115,19 +106,15 @@ function TrackFilters({
|
||||||
selection
|
selection
|
||||||
clearable
|
clearable
|
||||||
options={[
|
options={[
|
||||||
{ value: 0, key: "__none__", text: "All my devices" },
|
{value: 0, key: '__none__', text: 'All my devices'},
|
||||||
..._.sortBy(Object.entries(deviceNames ?? {}), 1).map(
|
..._.sortBy(Object.entries(deviceNames ?? {}), 1).map(([deviceId, deviceName]: [string, string]) => ({
|
||||||
([deviceId, deviceName]: [string, string]) => ({
|
value: Number(deviceId),
|
||||||
value: Number(deviceId),
|
key: deviceId,
|
||||||
key: deviceId,
|
text: deviceName,
|
||||||
text: deviceName,
|
})),
|
||||||
})
|
|
||||||
),
|
|
||||||
]}
|
]}
|
||||||
value={filters?.userDeviceId ?? 0}
|
value={filters?.userDeviceId ?? 0}
|
||||||
onChange={(_e, { value }) =>
|
onChange={(_e, {value}) => setFilters({...filters, userDeviceId: (value as number) || null})}
|
||||||
setFilters({ ...filters, userDeviceId: (value as number) || null })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
|
|
||||||
|
@ -137,54 +124,48 @@ function TrackFilters({
|
||||||
selection
|
selection
|
||||||
clearable
|
clearable
|
||||||
options={[
|
options={[
|
||||||
{ value: "none", key: "any", text: "Any" },
|
{value: 'none', key: 'any', text: 'Any'},
|
||||||
{ value: true, key: "public", text: "Public" },
|
{value: true, key: 'public', text: 'Public'},
|
||||||
{ value: false, key: "private", text: "Private" },
|
{value: false, key: 'private', text: 'Private'},
|
||||||
]}
|
]}
|
||||||
value={filters?.visibility ?? "none"}
|
value={filters?.visibility ?? 'none'}
|
||||||
onChange={(_e, { value }) =>
|
onChange={(_e, {value}) =>
|
||||||
setFilters({
|
setFilters({
|
||||||
...filters,
|
...filters,
|
||||||
visibility: value === "none" ? null : (value as boolean),
|
visibility: value === 'none' ? null : (value as boolean),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
</List>
|
</List>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TracksTable({ title }) {
|
function TracksTable({title}) {
|
||||||
const [orderBy, setOrderBy] = useState("recordedAt");
|
const [orderBy, setOrderBy] = useState('recordedAt')
|
||||||
const [reversed, setReversed] = useState(true);
|
const [reversed, setReversed] = useState(true)
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false)
|
||||||
const [filters, setFilters] = useState<Filters>({});
|
const [filters, setFilters] = useState<Filters>({})
|
||||||
const [selectedTracks, setSelectedTracks] = useState<Record<string, boolean>>(
|
const [selectedTracks, setSelectedTracks] = useState<Record<string, boolean>>({})
|
||||||
{}
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleTrackSelection = useCallbackRef(
|
const toggleTrackSelection = useCallbackRef((slug: string, selected?: boolean) => {
|
||||||
(slug: string, selected?: boolean) => {
|
const newSelected = selected ?? !selectedTracks[slug]
|
||||||
const newSelected = selected ?? !selectedTracks[slug];
|
setSelectedTracks(_.pickBy({...selectedTracks, [slug]: newSelected}, _.identity))
|
||||||
setSelectedTracks(
|
})
|
||||||
_.pickBy({ ...selectedTracks, [slug]: newSelected }, _.identity)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const query = _.pickBy(
|
const query = _.pickBy(
|
||||||
{
|
{
|
||||||
limit: 1000,
|
limit: 1000,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
order_by: orderBy,
|
order_by: orderBy,
|
||||||
reversed: reversed ? "true" : "false",
|
reversed: reversed ? 'true' : 'false',
|
||||||
user_device_id: filters?.userDeviceId,
|
user_device_id: filters?.userDeviceId,
|
||||||
public: filters?.visibility,
|
public: filters?.visibility,
|
||||||
},
|
},
|
||||||
(x) => x != null
|
(x) => x != null
|
||||||
);
|
)
|
||||||
|
|
||||||
const forceUpdate$ = useMemo(() => new BehaviorSubject(null), []);
|
const forceUpdate$ = useMemo(() => new BehaviorSubject(null), [])
|
||||||
const tracks: Track[] | null = useObservable(
|
const tracks: Track[] | null = useObservable(
|
||||||
(_$, inputs$) =>
|
(_$, inputs$) =>
|
||||||
combineLatest([
|
combineLatest([
|
||||||
|
@ -193,125 +174,91 @@ function TracksTable({ title }) {
|
||||||
distinctUntilChanged(_.isEqual)
|
distinctUntilChanged(_.isEqual)
|
||||||
),
|
),
|
||||||
forceUpdate$,
|
forceUpdate$,
|
||||||
]).pipe(
|
]).pipe(switchMap(([query]) => concat(of(null), from(api.get('/tracks/feed', {query}).then((r) => r.tracks))))),
|
||||||
switchMap(([query]) =>
|
|
||||||
concat(
|
|
||||||
of(null),
|
|
||||||
from(api.get("/tracks/feed", { query }).then((r) => r.tracks))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
null,
|
null,
|
||||||
[query]
|
[query]
|
||||||
);
|
)
|
||||||
|
|
||||||
const deviceNames: null | Record<number, string> = useObservable(() =>
|
const deviceNames: null | Record<number, string> = useObservable(() =>
|
||||||
from(api.get("/user/devices")).pipe(
|
from(api.get('/user/devices')).pipe(
|
||||||
map((response: UserDevice[]) =>
|
map((response: UserDevice[]) =>
|
||||||
Object.fromEntries(
|
Object.fromEntries(response.map((device) => [device.id, device.displayName || device.identifier]))
|
||||||
response.map((device) => [
|
|
||||||
device.id,
|
|
||||||
device.displayName || device.identifier,
|
|
||||||
])
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
)
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
|
|
||||||
const p = { orderBy, setOrderBy, reversed, setReversed };
|
const p = {orderBy, setOrderBy, reversed, setReversed}
|
||||||
|
|
||||||
const selectedCount = Object.keys(selectedTracks).length;
|
const selectedCount = Object.keys(selectedTracks).length
|
||||||
const noneSelected = selectedCount === 0;
|
const noneSelected = selectedCount === 0
|
||||||
const allSelected = selectedCount === tracks?.length;
|
const allSelected = selectedCount === tracks?.length
|
||||||
const selectAll = () => {
|
const selectAll = () => {
|
||||||
setSelectedTracks(
|
setSelectedTracks(Object.fromEntries(tracks?.map((t) => [t.slug, true]) ?? []))
|
||||||
Object.fromEntries(tracks?.map((t) => [t.slug, true]) ?? [])
|
}
|
||||||
);
|
|
||||||
};
|
|
||||||
const selectNone = () => {
|
const selectNone = () => {
|
||||||
setSelectedTracks({});
|
setSelectedTracks({})
|
||||||
};
|
}
|
||||||
|
|
||||||
const bulkAction = async (action: string) => {
|
const bulkAction = async (action: string) => {
|
||||||
const response = await api.post("/tracks/bulk", {
|
const response = await api.post('/tracks/bulk', {
|
||||||
body: {
|
body: {
|
||||||
action,
|
action,
|
||||||
tracks: Object.keys(selectedTracks),
|
tracks: Object.keys(selectedTracks),
|
||||||
},
|
},
|
||||||
returnResponse: true,
|
returnResponse: true,
|
||||||
});
|
})
|
||||||
if (action === "download") {
|
if (action === 'download') {
|
||||||
const contentType =
|
const contentType = response.headers.get('content-type') ?? 'application/x-gtar'
|
||||||
response.headers.get("content-type") ?? "application/x-gtar";
|
|
||||||
|
|
||||||
const filename =
|
const filename = response.headers.get('content-disposition')?.match(/filename="([^"]+)"/)?.[1] ?? 'tracks.tar.bz2'
|
||||||
response.headers
|
download(await response.blob(), filename, contentType)
|
||||||
.get("content-disposition")
|
|
||||||
?.match(/filename="([^"]+)"/)?.[1] ?? "tracks.tar.bz2";
|
|
||||||
download(await response.blob(), filename, contentType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowBulkDelete(false);
|
setShowBulkDelete(false)
|
||||||
setSelectedTracks({});
|
setSelectedTracks({})
|
||||||
forceUpdate$.next(null);
|
forceUpdate$.next(null)
|
||||||
};
|
}
|
||||||
const [showBulkDelete, setShowBulkDelete] = useState(false);
|
const [showBulkDelete, setShowBulkDelete] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ float: "right" }}>
|
<div style={{float: 'right'}}>
|
||||||
<Dropdown disabled={noneSelected} text="Bulk actions" floating button>
|
<Dropdown disabled={noneSelected} text="Bulk actions" floating button>
|
||||||
<Dropdown.Menu>
|
<Dropdown.Menu>
|
||||||
<Dropdown.Header>
|
<Dropdown.Header>Selection of {selectedCount} tracks</Dropdown.Header>
|
||||||
Selection of {selectedCount} tracks
|
<Dropdown.Item onClick={() => bulkAction('makePrivate')}>Make private</Dropdown.Item>
|
||||||
</Dropdown.Header>
|
<Dropdown.Item onClick={() => bulkAction('makePublic')}>Make public</Dropdown.Item>
|
||||||
<Dropdown.Item onClick={() => bulkAction("makePrivate")}>
|
<Dropdown.Item onClick={() => bulkAction('reprocess')}>Reprocess</Dropdown.Item>
|
||||||
Make private
|
<Dropdown.Item onClick={() => bulkAction('download')}>Download</Dropdown.Item>
|
||||||
</Dropdown.Item>
|
<Dropdown.Item onClick={() => setShowBulkDelete(true)}>Delete</Dropdown.Item>
|
||||||
<Dropdown.Item onClick={() => bulkAction("makePublic")}>
|
|
||||||
Make public
|
|
||||||
</Dropdown.Item>
|
|
||||||
<Dropdown.Item onClick={() => bulkAction("reprocess")}>
|
|
||||||
Reprocess
|
|
||||||
</Dropdown.Item>
|
|
||||||
<Dropdown.Item onClick={() => bulkAction("download")}>
|
|
||||||
Download
|
|
||||||
</Dropdown.Item>
|
|
||||||
<Dropdown.Item onClick={() => setShowBulkDelete(true)}>
|
|
||||||
Delete
|
|
||||||
</Dropdown.Item>
|
|
||||||
</Dropdown.Menu>
|
</Dropdown.Menu>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
<Link component={UploadButton} to="/upload" />
|
<Link component={UploadButton} to="/upload" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Header as="h1">{title}</Header>
|
<Header as="h1">{title}</Header>
|
||||||
<div style={{ clear: "both" }}>
|
<div style={{clear: 'both'}}>
|
||||||
<Loader content={t("general.loading")} active={tracks == null} />
|
<Loader content={t('general.loading')} active={tracks == null} />
|
||||||
|
|
||||||
<Accordion>
|
<Accordion>
|
||||||
<Accordion.Title
|
<Accordion.Title active={showFilters} index={0} onClick={() => setShowFilters(!showFilters)}>
|
||||||
active={showFilters}
|
|
||||||
index={0}
|
|
||||||
onClick={() => setShowFilters(!showFilters)}
|
|
||||||
>
|
|
||||||
<Icon name="dropdown" />
|
<Icon name="dropdown" />
|
||||||
Filters
|
Filters
|
||||||
</Accordion.Title>
|
</Accordion.Title>
|
||||||
<Accordion.Content active={showFilters}>
|
<Accordion.Content active={showFilters}>
|
||||||
<TrackFilters {...{ filters, setFilters, deviceNames }} />
|
<TrackFilters {...{filters, setFilters, deviceNames}} />
|
||||||
</Accordion.Content>
|
</Accordion.Content>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Confirm
|
<Confirm
|
||||||
open={showBulkDelete}
|
open={showBulkDelete}
|
||||||
onCancel={() => setShowBulkDelete(false)}
|
onCancel={() => setShowBulkDelete(false)}
|
||||||
onConfirm={() => bulkAction("delete")}
|
onConfirm={() => bulkAction('delete')}
|
||||||
content={`Are you sure you want to delete ${selectedCount} tracks?`}
|
content={`Are you sure you want to delete ${selectedCount} tracks?`}
|
||||||
confirmButton={t("general.delete")}
|
confirmButton={t('general.delete')}
|
||||||
cancelButton={t("general.cancel")}
|
cancelButton={t('general.cancel')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Table compact>
|
<Table compact>
|
||||||
|
@ -356,11 +303,9 @@ function TracksTable({ title }) {
|
||||||
/>
|
/>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{track.processingStatus == null ? null : (
|
{track.processingStatus == null ? null : <ProcessingStatusLabel status={track.processingStatus} />}
|
||||||
<ProcessingStatusLabel status={track.processingStatus} />
|
|
||||||
)}
|
|
||||||
<Item.Header as={Link} to={`/tracks/${track.slug}`}>
|
<Item.Header as={Link} to={`/tracks/${track.slug}`}>
|
||||||
{track.title || t("general.unnamedTrack")}
|
{track.title || t('general.unnamedTrack')}
|
||||||
</Item.Header>
|
</Item.Header>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|
||||||
|
@ -368,62 +313,48 @@ function TracksTable({ title }) {
|
||||||
<FormattedDate date={track.recordedAt} />
|
<FormattedDate date={track.recordedAt} />
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|
||||||
<Table.Cell>
|
<Table.Cell>{track.public == null ? null : <Visibility public={track.public} />}</Table.Cell>
|
||||||
{track.public == null ? null : (
|
|
||||||
<Visibility public={track.public} />
|
|
||||||
)}
|
|
||||||
</Table.Cell>
|
|
||||||
|
|
||||||
<Table.Cell textAlign="right">
|
<Table.Cell textAlign="right">{formatDistance(track.length)}</Table.Cell>
|
||||||
{formatDistance(track.length)}
|
|
||||||
</Table.Cell>
|
|
||||||
|
|
||||||
<Table.Cell textAlign="right">
|
<Table.Cell textAlign="right">{formatDuration(track.duration)}</Table.Cell>
|
||||||
{formatDuration(track.duration)}
|
|
||||||
</Table.Cell>
|
|
||||||
|
|
||||||
<Table.Cell>
|
<Table.Cell>{track.userDeviceId ? deviceNames?.[track.userDeviceId] ?? '...' : null}</Table.Cell>
|
||||||
{track.userDeviceId
|
|
||||||
? deviceNames?.[track.userDeviceId] ?? "..."
|
|
||||||
: null}
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
))}
|
))}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function UploadButton({ navigate, ...props }) {
|
function UploadButton({navigate, ...props}) {
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
const onClick = useCallback(
|
const onClick = useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
navigate();
|
navigate()
|
||||||
},
|
},
|
||||||
[navigate]
|
[navigate]
|
||||||
);
|
)
|
||||||
return (
|
return (
|
||||||
<Button onClick={onClick} {...props} color="green">
|
<Button onClick={onClick} {...props} color="green">
|
||||||
{t("TracksPage.upload")}
|
{t('TracksPage.upload')}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const MyTracksPage = connect((state) => ({ login: (state as any).login }))(
|
const MyTracksPage = connect((state) => ({login: (state as any).login}))(function MyTracksPage({login}) {
|
||||||
function MyTracksPage({ login }) {
|
const {t} = useTranslation()
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const title = t("TracksPage.titleUser");
|
const title = t('TracksPage.titleUser')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page title={title}>
|
<Page title={title}>
|
||||||
<TracksTable {...{ title }} />
|
<TracksTable {...{title}} />
|
||||||
</Page>
|
</Page>
|
||||||
);
|
)
|
||||||
}
|
})
|
||||||
);
|
|
||||||
|
|
||||||
export default MyTracksPage;
|
export default MyTracksPage
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {Button, Header} from 'semantic-ui-react'
|
import {Button, Header} from 'semantic-ui-react'
|
||||||
import {useHistory} from 'react-router-dom'
|
import {useHistory} from 'react-router-dom'
|
||||||
import { useTranslation } from "react-i18next";
|
import {useTranslation} from 'react-i18next'
|
||||||
|
|
||||||
import {Page} from '../components'
|
import {Page} from '../components'
|
||||||
|
|
||||||
export default function NotFoundPage() {
|
export default function NotFoundPage() {
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
const history = useHistory()
|
const history = useHistory()
|
||||||
return (
|
return (
|
||||||
<Page title={t('NotFoundPage.title')}>
|
<Page title={t('NotFoundPage.title')}>
|
||||||
|
|
|
@ -1,125 +1,102 @@
|
||||||
import React from "react";
|
import React from 'react'
|
||||||
import { connect } from "react-redux";
|
import {connect} from 'react-redux'
|
||||||
import {
|
import {Message, Icon, Button, Ref, Input, Segment, Popup} from 'semantic-ui-react'
|
||||||
Message,
|
import Markdown from 'react-markdown'
|
||||||
Icon,
|
import {useTranslation} from 'react-i18next'
|
||||||
Button,
|
|
||||||
Ref,
|
|
||||||
Input,
|
|
||||||
Segment,
|
|
||||||
Popup,
|
|
||||||
} from "semantic-ui-react";
|
|
||||||
import Markdown from "react-markdown";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { setLogin } from "reducers/login";
|
import {setLogin} from 'reducers/login'
|
||||||
import api from "api";
|
import api from 'api'
|
||||||
import { findInput } from "utils";
|
import {findInput} from 'utils'
|
||||||
import { useConfig } from "config";
|
import {useConfig} from 'config'
|
||||||
|
|
||||||
function CopyInput({ value, ...props }) {
|
function CopyInput({value, ...props}) {
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
const [success, setSuccess] = React.useState(null);
|
const [success, setSuccess] = React.useState(null)
|
||||||
const onClick = async () => {
|
const onClick = async () => {
|
||||||
try {
|
try {
|
||||||
await window.navigator?.clipboard?.writeText(value);
|
await window.navigator?.clipboard?.writeText(value)
|
||||||
setSuccess(true);
|
setSuccess(true)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSuccess(false);
|
setSuccess(false)
|
||||||
} finally {
|
} finally {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setSuccess(null);
|
setSuccess(null)
|
||||||
}, 2000);
|
}, 2000)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popup
|
<Popup
|
||||||
trigger={
|
trigger={<Input {...props} value={value} fluid action={{icon: 'copy', onClick}} />}
|
||||||
<Input
|
|
||||||
{...props}
|
|
||||||
value={value}
|
|
||||||
fluid
|
|
||||||
action={{ icon: "copy", onClick }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
position="top right"
|
position="top right"
|
||||||
open={success != null}
|
open={success != null}
|
||||||
content={success ? t("general.copied") : t("general.copyError")}
|
content={success ? t('general.copied') : t('general.copyError')}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectField = findInput((ref) => ref?.select());
|
const selectField = findInput((ref) => ref?.select())
|
||||||
|
|
||||||
const ApiKeySettings = connect((state) => ({ login: state.login }), {
|
const ApiKeySettings = connect((state) => ({login: state.login}), {
|
||||||
setLogin,
|
setLogin,
|
||||||
})(function ApiKeySettings({ login, setLogin, setErrors }) {
|
})(function ApiKeySettings({login, setLogin, setErrors}) {
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false)
|
||||||
const config = useConfig();
|
const config = useConfig()
|
||||||
const [show, setShow] = React.useState(false);
|
const [show, setShow] = React.useState(false)
|
||||||
const onClick = React.useCallback(
|
const onClick = React.useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
setShow(true);
|
setShow(true)
|
||||||
},
|
},
|
||||||
[setShow]
|
[setShow]
|
||||||
);
|
)
|
||||||
|
|
||||||
const onGenerateNewKey = React.useCallback(
|
const onGenerateNewKey = React.useCallback(
|
||||||
async (e) => {
|
async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
setLoading(true);
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const response = await api.put("/user", {
|
const response = await api.put('/user', {
|
||||||
body: { updateApiKey: true },
|
body: {updateApiKey: true},
|
||||||
});
|
})
|
||||||
setLogin(response);
|
setLogin(response)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setErrors(err.errors);
|
setErrors(err.errors)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setLoading, setLogin, setErrors]
|
[setLoading, setLogin, setErrors]
|
||||||
);
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Segment style={{ maxWidth: 600, margin: "24px auto" }}>
|
<Segment style={{maxWidth: 600, margin: '24px auto'}}>
|
||||||
<Markdown>{t("SettingsPage.apiKey.description")}</Markdown>
|
<Markdown>{t('SettingsPage.apiKey.description')}</Markdown>
|
||||||
<div style={{ minHeight: 40, marginBottom: 16 }}>
|
<div style={{minHeight: 40, marginBottom: 16}}>
|
||||||
{show ? (
|
{show ? (
|
||||||
login.apiKey ? (
|
login.apiKey ? (
|
||||||
<Ref innerRef={selectField}>
|
<Ref innerRef={selectField}>
|
||||||
<CopyInput
|
<CopyInput label={t('SettingsPage.apiKey.key.label')} value={login.apiKey} />
|
||||||
label={t("SettingsPage.apiKey.key.label")}
|
|
||||||
value={login.apiKey}
|
|
||||||
/>
|
|
||||||
</Ref>
|
</Ref>
|
||||||
) : (
|
) : (
|
||||||
<Message warning content={t("SettingsPage.apiKey.key.empty")} />
|
<Message warning content={t('SettingsPage.apiKey.key.empty')} />
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<Button onClick={onClick}>
|
<Button onClick={onClick}>
|
||||||
<Icon name="lock" /> {t("SettingsPage.apiKey.key.show")}
|
<Icon name="lock" /> {t('SettingsPage.apiKey.key.show')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Markdown>{t("SettingsPage.apiKey.urlDescription")}</Markdown>
|
<Markdown>{t('SettingsPage.apiKey.urlDescription')}</Markdown>
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{marginBottom: 16}}>
|
||||||
<CopyInput
|
<CopyInput label={t('SettingsPage.apiKey.url.label')} value={config?.apiUrl?.replace(/\/api$/, '') ?? '...'} />
|
||||||
label={t("SettingsPage.apiKey.url.label")}
|
|
||||||
value={config?.apiUrl?.replace(/\/api$/, "") ?? "..."}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<Markdown>{t("SettingsPage.apiKey.generateDescription")}</Markdown>
|
<Markdown>{t('SettingsPage.apiKey.generateDescription')}</Markdown>
|
||||||
<p></p>
|
<p></p>
|
||||||
<Button onClick={onGenerateNewKey}>
|
<Button onClick={onGenerateNewKey}>{t('SettingsPage.apiKey.generate')}</Button>
|
||||||
{t("SettingsPage.apiKey.generate")}
|
|
||||||
</Button>
|
|
||||||
</Segment>
|
</Segment>
|
||||||
);
|
)
|
||||||
});
|
})
|
||||||
|
|
||||||
export default ApiKeySettings;
|
export default ApiKeySettings
|
||||||
|
|
|
@ -27,7 +27,7 @@ function EditField({value, onEdit}) {
|
||||||
}, [setEditing, setTempValue, value, cancelTimeout])
|
}, [setEditing, setTempValue, value, cancelTimeout])
|
||||||
|
|
||||||
const confirm = useCallback(() => {
|
const confirm = useCallback(() => {
|
||||||
console.log("confirmed")
|
console.log('confirmed')
|
||||||
cancelTimeout()
|
cancelTimeout()
|
||||||
setEditing(false)
|
setEditing(false)
|
||||||
onEdit(tempValue)
|
onEdit(tempValue)
|
||||||
|
|
|
@ -1,89 +1,77 @@
|
||||||
import React from "react";
|
import React from 'react'
|
||||||
import { connect } from "react-redux";
|
import {connect} from 'react-redux'
|
||||||
import {
|
import {Segment, Message, Form, Button, TextArea, Ref, Input} from 'semantic-ui-react'
|
||||||
Segment,
|
import {useForm} from 'react-hook-form'
|
||||||
Message,
|
import {useTranslation} from 'react-i18next'
|
||||||
Form,
|
|
||||||
Button,
|
|
||||||
TextArea,
|
|
||||||
Ref,
|
|
||||||
Input,
|
|
||||||
} from "semantic-ui-react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { setLogin } from "reducers/login";
|
import {setLogin} from 'reducers/login'
|
||||||
import api from "api";
|
import api from 'api'
|
||||||
import { findInput } from "utils";
|
import {findInput} from 'utils'
|
||||||
|
|
||||||
const UserSettingsForm = connect((state) => ({ login: state.login }), {
|
const UserSettingsForm = connect((state) => ({login: state.login}), {
|
||||||
setLogin,
|
setLogin,
|
||||||
})(function UserSettingsForm({ login, setLogin, errors, setErrors }) {
|
})(function UserSettingsForm({login, setLogin, errors, setErrors}) {
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
const { register, handleSubmit } = useForm();
|
const {register, handleSubmit} = useForm()
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false)
|
||||||
|
|
||||||
const onSave = React.useCallback(
|
const onSave = React.useCallback(
|
||||||
async (changes) => {
|
async (changes) => {
|
||||||
setLoading(true);
|
setLoading(true)
|
||||||
setErrors(null);
|
setErrors(null)
|
||||||
try {
|
try {
|
||||||
const response = await api.put("/user", { body: changes });
|
const response = await api.put('/user', {body: changes})
|
||||||
setLogin(response);
|
setLogin(response)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setErrors(err.errors);
|
setErrors(err.errors)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setLoading, setLogin, setErrors]
|
[setLoading, setLogin, setErrors]
|
||||||
);
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Segment style={{ maxWidth: 600 }}>
|
<Segment style={{maxWidth: 600}}>
|
||||||
<Form onSubmit={handleSubmit(onSave)} loading={loading}>
|
<Form onSubmit={handleSubmit(onSave)} loading={loading}>
|
||||||
<Form.Field error={errors?.username}>
|
<Form.Field error={errors?.username}>
|
||||||
<label>{t("SettingsPage.profile.username.label")}</label>
|
<label>{t('SettingsPage.profile.username.label')}</label>
|
||||||
<Ref innerRef={findInput(register)}>
|
<Ref innerRef={findInput(register)}>
|
||||||
<Input name="username" defaultValue={login.username} disabled />
|
<Input name="username" defaultValue={login.username} disabled />
|
||||||
</Ref>
|
</Ref>
|
||||||
<small>{t("SettingsPage.profile.username.hint")}</small>
|
<small>{t('SettingsPage.profile.username.hint')}</small>
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
|
|
||||||
<Message info visible>
|
<Message info visible>
|
||||||
{t("SettingsPage.profile.publicNotice")}
|
{t('SettingsPage.profile.publicNotice')}
|
||||||
</Message>
|
</Message>
|
||||||
|
|
||||||
<Form.Field error={errors?.displayName}>
|
<Form.Field error={errors?.displayName}>
|
||||||
<label>{t("SettingsPage.profile.displayName.label")}</label>
|
<label>{t('SettingsPage.profile.displayName.label')}</label>
|
||||||
<Ref innerRef={findInput(register)}>
|
<Ref innerRef={findInput(register)}>
|
||||||
<Input
|
<Input name="displayName" defaultValue={login.displayName} placeholder={login.username} />
|
||||||
name="displayName"
|
|
||||||
defaultValue={login.displayName}
|
|
||||||
placeholder={login.username}
|
|
||||||
/>
|
|
||||||
</Ref>
|
</Ref>
|
||||||
<small>{t("SettingsPage.profile.displayName.fallbackNotice")}</small>
|
<small>{t('SettingsPage.profile.displayName.fallbackNotice')}</small>
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
|
|
||||||
<Form.Field error={errors?.bio}>
|
<Form.Field error={errors?.bio}>
|
||||||
<label>{t("SettingsPage.profile.bio.label")}</label>
|
<label>{t('SettingsPage.profile.bio.label')}</label>
|
||||||
<Ref innerRef={register}>
|
<Ref innerRef={register}>
|
||||||
<TextArea name="bio" rows={4} defaultValue={login.bio} />
|
<TextArea name="bio" rows={4} defaultValue={login.bio} />
|
||||||
</Ref>
|
</Ref>
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
<Form.Field error={errors?.image}>
|
<Form.Field error={errors?.image}>
|
||||||
<label>{t("SettingsPage.profile.avatarUrl.label")}</label>
|
<label>{t('SettingsPage.profile.avatarUrl.label')}</label>
|
||||||
<Ref innerRef={findInput(register)}>
|
<Ref innerRef={findInput(register)}>
|
||||||
<Input name="image" defaultValue={login.image} />
|
<Input name="image" defaultValue={login.image} />
|
||||||
</Ref>
|
</Ref>
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
|
|
||||||
<Button type="submit" primary>
|
<Button type="submit" primary>
|
||||||
{t("general.save")}
|
{t('general.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
</Segment>
|
</Segment>
|
||||||
);
|
)
|
||||||
});
|
})
|
||||||
export default UserSettingsForm;
|
export default UserSettingsForm
|
||||||
|
|
|
@ -1,70 +1,68 @@
|
||||||
import React from "react";
|
import React from 'react'
|
||||||
import { connect } from "react-redux";
|
import {connect} from 'react-redux'
|
||||||
import { Header, Tab } from "semantic-ui-react";
|
import {Header, Tab} from 'semantic-ui-react'
|
||||||
import { useForm } from "react-hook-form";
|
import {useForm} from 'react-hook-form'
|
||||||
import { useTranslation } from "react-i18next";
|
import {useTranslation} from 'react-i18next'
|
||||||
|
|
||||||
import { setLogin } from "reducers/login";
|
import {setLogin} from 'reducers/login'
|
||||||
import { Page, Stats } from "components";
|
import {Page, Stats} from 'components'
|
||||||
import api from "api";
|
import api from 'api'
|
||||||
|
|
||||||
import ApiKeySettings from "./ApiKeySettings";
|
import ApiKeySettings from './ApiKeySettings'
|
||||||
|
|
||||||
import UserSettingsForm from "./UserSettingsForm";
|
import UserSettingsForm from './UserSettingsForm'
|
||||||
import DeviceList from "./DeviceList";
|
import DeviceList from './DeviceList'
|
||||||
|
|
||||||
const SettingsPage = connect((state) => ({ login: state.login }), { setLogin })(
|
const SettingsPage = connect((state) => ({login: state.login}), {setLogin})(function SettingsPage({login, setLogin}) {
|
||||||
function SettingsPage({ login, setLogin }) {
|
const {t} = useTranslation()
|
||||||
const { t } = useTranslation();
|
const {register, handleSubmit} = useForm()
|
||||||
const { register, handleSubmit } = useForm();
|
const [loading, setLoading] = React.useState(false)
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [errors, setErrors] = React.useState(null)
|
||||||
const [errors, setErrors] = React.useState(null);
|
|
||||||
|
|
||||||
const onGenerateNewKey = React.useCallback(async () => {
|
const onGenerateNewKey = React.useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true)
|
||||||
setErrors(null);
|
setErrors(null)
|
||||||
try {
|
try {
|
||||||
const response = await api.put("/user", {
|
const response = await api.put('/user', {
|
||||||
body: { updateApiKey: true },
|
body: {updateApiKey: true},
|
||||||
});
|
})
|
||||||
setLogin(response);
|
setLogin(response)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setErrors(err.errors);
|
setErrors(err.errors)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [setLoading, setLogin, setErrors]);
|
}, [setLoading, setLogin, setErrors])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page title={t("SettingsPage.title")}>
|
<Page title={t('SettingsPage.title')}>
|
||||||
<Header as="h1">{t("SettingsPage.title")}</Header>
|
<Header as="h1">{t('SettingsPage.title')}</Header>
|
||||||
<Tab
|
<Tab
|
||||||
menu={{ secondary: true, pointing: true }}
|
menu={{secondary: true, pointing: true}}
|
||||||
panes={[
|
panes={[
|
||||||
{
|
{
|
||||||
menuItem: t("SettingsPage.profile.title"),
|
menuItem: t('SettingsPage.profile.title'),
|
||||||
render: () => <UserSettingsForm {...{ errors, setErrors }} />,
|
render: () => <UserSettingsForm {...{errors, setErrors}} />,
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
menuItem: t("SettingsPage.apiKey.title"),
|
menuItem: t('SettingsPage.apiKey.title'),
|
||||||
render: () => <ApiKeySettings {...{ errors, setErrors }} />,
|
render: () => <ApiKeySettings {...{errors, setErrors}} />,
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
menuItem: t("SettingsPage.stats.title"),
|
menuItem: t('SettingsPage.stats.title'),
|
||||||
render: () => <Stats user={login.id} />,
|
render: () => <Stats user={login.id} />,
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
menuItem: t("SettingsPage.devices.title"),
|
menuItem: t('SettingsPage.devices.title'),
|
||||||
render: () => <DeviceList />,
|
render: () => <DeviceList />,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
)
|
||||||
}
|
})
|
||||||
);
|
|
||||||
|
|
||||||
export default SettingsPage;
|
export default SettingsPage
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import React from "react";
|
import React from 'react'
|
||||||
import _ from "lodash";
|
import _ from 'lodash'
|
||||||
import { connect } from "react-redux";
|
import {connect} from 'react-redux'
|
||||||
import {
|
import {
|
||||||
Divider,
|
Divider,
|
||||||
Message,
|
Message,
|
||||||
|
@ -14,31 +14,31 @@ import {
|
||||||
TextArea,
|
TextArea,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Header,
|
Header,
|
||||||
} from "semantic-ui-react";
|
} from 'semantic-ui-react'
|
||||||
import { useHistory, useParams, Link } from "react-router-dom";
|
import {useHistory, useParams, Link} from 'react-router-dom'
|
||||||
import { concat, of, from } from "rxjs";
|
import {concat, of, from} from 'rxjs'
|
||||||
import { pluck, distinctUntilChanged, map, switchMap } from "rxjs/operators";
|
import {pluck, distinctUntilChanged, map, switchMap} from 'rxjs/operators'
|
||||||
import { useObservable } from "rxjs-hooks";
|
import {useObservable} from 'rxjs-hooks'
|
||||||
import { findInput } from "utils";
|
import {findInput} from 'utils'
|
||||||
import { useForm, Controller } from "react-hook-form";
|
import {useForm, Controller} from 'react-hook-form'
|
||||||
import { useTranslation, Trans as Translate } from "react-i18next";
|
import {useTranslation, Trans as Translate} from 'react-i18next'
|
||||||
import Markdown from "react-markdown";
|
import Markdown from 'react-markdown'
|
||||||
|
|
||||||
import api from "api";
|
import api from 'api'
|
||||||
import { Page, FileUploadField } from "components";
|
import {Page, FileUploadField} from 'components'
|
||||||
import type { Track } from "types";
|
import type {Track} from 'types'
|
||||||
|
|
||||||
import { FileUploadStatus } from "pages/UploadPage";
|
import {FileUploadStatus} from 'pages/UploadPage'
|
||||||
|
|
||||||
function ReplaceTrackData({ slug }) {
|
function ReplaceTrackData({slug}) {
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
const [file, setFile] = React.useState(null);
|
const [file, setFile] = React.useState(null)
|
||||||
const [result, setResult] = React.useState(null);
|
const [result, setResult] = React.useState(null)
|
||||||
const onComplete = React.useCallback((_id, r) => setResult(r), [setResult]);
|
const onComplete = React.useCallback((_id, r) => setResult(r), [setResult])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header as="h2">{t("TrackEditor.replaceTrackData")}</Header>
|
<Header as="h2">{t('TrackEditor.replaceTrackData')}</Header>
|
||||||
{!file ? (
|
{!file ? (
|
||||||
<FileUploadField onSelect={setFile} />
|
<FileUploadField onSelect={setFile} />
|
||||||
) : result ? (
|
) : result ? (
|
||||||
|
@ -48,167 +48,146 @@ function ReplaceTrackData({ slug }) {
|
||||||
</Translate>
|
</Translate>
|
||||||
</Message>
|
</Message>
|
||||||
) : (
|
) : (
|
||||||
<FileUploadStatus {...{ file, onComplete, slug }} />
|
<FileUploadStatus {...{file, onComplete, slug}} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const TrackEditor = connect((state) => ({ login: state.login }))(
|
const TrackEditor = connect((state) => ({login: state.login}))(function TrackEditor({login}) {
|
||||||
function TrackEditor({ login }) {
|
const {t} = useTranslation()
|
||||||
const { t } = useTranslation();
|
const [busy, setBusy] = React.useState(false)
|
||||||
const [busy, setBusy] = React.useState(false);
|
const {register, control, handleSubmit} = useForm()
|
||||||
const { register, control, handleSubmit } = useForm();
|
const {slug} = useParams()
|
||||||
const { slug } = useParams();
|
const history = useHistory()
|
||||||
const history = useHistory();
|
|
||||||
|
|
||||||
const track: null | Track = useObservable(
|
const track: null | Track = useObservable(
|
||||||
(_$, args$) => {
|
(_$, args$) => {
|
||||||
const slug$ = args$.pipe(pluck(0), distinctUntilChanged());
|
const slug$ = args$.pipe(pluck(0), distinctUntilChanged())
|
||||||
return slug$.pipe(
|
return slug$.pipe(
|
||||||
map((slug) => `/tracks/${slug}`),
|
map((slug) => `/tracks/${slug}`),
|
||||||
switchMap((url) => concat(of(null), from(api.get(url)))),
|
switchMap((url) => concat(of(null), from(api.get(url)))),
|
||||||
pluck("track")
|
pluck('track')
|
||||||
);
|
)
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
[slug]
|
[slug]
|
||||||
);
|
)
|
||||||
|
|
||||||
const loading = busy || track == null;
|
const loading = busy || track == null
|
||||||
const isAuthor = login?.id === track?.author?.id;
|
const isAuthor = login?.id === track?.author?.id
|
||||||
|
|
||||||
// Navigate to track detials if we are not the author
|
// Navigate to track detials if we are not the author
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!login || (track && !isAuthor)) {
|
if (!login || (track && !isAuthor)) {
|
||||||
history.replace(`/tracks/${slug}`);
|
history.replace(`/tracks/${slug}`)
|
||||||
}
|
}
|
||||||
}, [slug, login, track, isAuthor, history]);
|
}, [slug, login, track, isAuthor, history])
|
||||||
|
|
||||||
const onSubmit = React.useMemo(
|
const onSubmit = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
handleSubmit(async (values) => {
|
handleSubmit(async (values) => {
|
||||||
setBusy(true);
|
setBusy(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.put(`/tracks/${slug}`, {
|
await api.put(`/tracks/${slug}`, {
|
||||||
body: {
|
body: {
|
||||||
track: _.pickBy(values, (v) => typeof v !== "undefined"),
|
track: _.pickBy(values, (v) => typeof v !== 'undefined'),
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
history.push(`/tracks/${slug}`);
|
history.push(`/tracks/${slug}`)
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
[slug, handleSubmit, history]
|
[slug, handleSubmit, history]
|
||||||
);
|
)
|
||||||
|
|
||||||
const [confirmDelete, setConfirmDelete] = React.useState(false);
|
const [confirmDelete, setConfirmDelete] = React.useState(false)
|
||||||
const onDelete = React.useCallback(async () => {
|
const onDelete = React.useCallback(async () => {
|
||||||
setBusy(true);
|
setBusy(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.delete(`/tracks/${slug}`);
|
await api.delete(`/tracks/${slug}`)
|
||||||
history.push("/tracks");
|
history.push('/tracks')
|
||||||
} finally {
|
} finally {
|
||||||
setConfirmDelete(false);
|
setConfirmDelete(false)
|
||||||
setBusy(false);
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}, [setBusy, setConfirmDelete, slug, history]);
|
}, [setBusy, setConfirmDelete, slug, history])
|
||||||
|
|
||||||
const trackTitle: string = track?.title || t("general.unnamedTrack");
|
const trackTitle: string = track?.title || t('general.unnamedTrack')
|
||||||
const title = t("TrackEditor.title", { trackTitle });
|
const title = t('TrackEditor.title', {trackTitle})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page title={title}>
|
<Page title={title}>
|
||||||
<Grid centered relaxed divided stackable>
|
<Grid centered relaxed divided stackable>
|
||||||
<Grid.Row>
|
<Grid.Row>
|
||||||
<Grid.Column width={10}>
|
<Grid.Column width={10}>
|
||||||
<Header as="h2">{title}</Header>
|
<Header as="h2">{title}</Header>
|
||||||
<Form loading={loading} key={track?.slug} onSubmit={onSubmit}>
|
<Form loading={loading} key={track?.slug} onSubmit={onSubmit}>
|
||||||
<Ref innerRef={findInput(register)}>
|
<Ref innerRef={findInput(register)}>
|
||||||
<Form.Input
|
<Form.Input label="Title" name="title" defaultValue={track?.title} style={{fontSize: '120%'}} />
|
||||||
label="Title"
|
</Ref>
|
||||||
name="title"
|
|
||||||
defaultValue={track?.title}
|
<Form.Field>
|
||||||
style={{ fontSize: "120%" }}
|
<label>{t('TrackEditor.description.label')}</label>
|
||||||
/>
|
<Ref innerRef={register}>
|
||||||
|
<TextArea name="description" rows={4} defaultValue={track?.description} />
|
||||||
</Ref>
|
</Ref>
|
||||||
|
</Form.Field>
|
||||||
|
|
||||||
<Form.Field>
|
<Form.Field>
|
||||||
<label>{t("TrackEditor.description.label")}</label>
|
<label>
|
||||||
<Ref innerRef={register}>
|
{t('TrackEditor.visibility.label')}
|
||||||
<TextArea
|
<Popup
|
||||||
name="description"
|
wide="very"
|
||||||
rows={4}
|
content={<Markdown>{t('TrackEditor.visibility.description')}</Markdown>}
|
||||||
defaultValue={track?.description}
|
trigger={<Icon name="warning sign" style={{marginLeft: 8}} color="orange" />}
|
||||||
/>
|
|
||||||
</Ref>
|
|
||||||
</Form.Field>
|
|
||||||
|
|
||||||
<Form.Field>
|
|
||||||
<label>
|
|
||||||
{t("TrackEditor.visibility.label")}
|
|
||||||
<Popup
|
|
||||||
wide="very"
|
|
||||||
content={
|
|
||||||
<Markdown>
|
|
||||||
{t("TrackEditor.visibility.description")}
|
|
||||||
</Markdown>
|
|
||||||
}
|
|
||||||
trigger={
|
|
||||||
<Icon
|
|
||||||
name="warning sign"
|
|
||||||
style={{ marginLeft: 8 }}
|
|
||||||
color="orange"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="public"
|
|
||||||
control={control}
|
|
||||||
defaultValue={track?.public}
|
|
||||||
render={(props) => (
|
|
||||||
<Checkbox
|
|
||||||
name="public"
|
|
||||||
label={t("TrackEditor.visibility.checkboxLabel")}
|
|
||||||
checked={props.value}
|
|
||||||
onChange={(_, { checked }) => props.onChange(checked)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</Form.Field>
|
</label>
|
||||||
<Button type="submit">{t("general.save")}</Button>
|
|
||||||
</Form>
|
|
||||||
</Grid.Column>
|
|
||||||
<Grid.Column width={6}>
|
|
||||||
<ReplaceTrackData slug={slug} />
|
|
||||||
|
|
||||||
<Divider />
|
<Controller
|
||||||
|
name="public"
|
||||||
|
control={control}
|
||||||
|
defaultValue={track?.public}
|
||||||
|
render={(props) => (
|
||||||
|
<Checkbox
|
||||||
|
name="public"
|
||||||
|
label={t('TrackEditor.visibility.checkboxLabel')}
|
||||||
|
checked={props.value}
|
||||||
|
onChange={(_, {checked}) => props.onChange(checked)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Form.Field>
|
||||||
|
<Button type="submit">{t('general.save')}</Button>
|
||||||
|
</Form>
|
||||||
|
</Grid.Column>
|
||||||
|
<Grid.Column width={6}>
|
||||||
|
<ReplaceTrackData slug={slug} />
|
||||||
|
|
||||||
<Header as="h2">{t("TrackEditor.dangerZone.title")}</Header>
|
<Divider />
|
||||||
<Markdown>{t("TrackEditor.dangerZone.description")}</Markdown>
|
|
||||||
|
|
||||||
<Button color="red" onClick={() => setConfirmDelete(true)}>
|
<Header as="h2">{t('TrackEditor.dangerZone.title')}</Header>
|
||||||
{t("general.delete")}
|
<Markdown>{t('TrackEditor.dangerZone.description')}</Markdown>
|
||||||
</Button>
|
|
||||||
<Confirm
|
|
||||||
open={confirmDelete}
|
|
||||||
onCancel={() => setConfirmDelete(false)}
|
|
||||||
onConfirm={onDelete}
|
|
||||||
content={t("TrackEditor.dangerZone.confirmDelete")}
|
|
||||||
confirmButton={t("general.delete")}
|
|
||||||
cancelButton={t("general.cancel")}
|
|
||||||
/>
|
|
||||||
</Grid.Column>
|
|
||||||
</Grid.Row>
|
|
||||||
</Grid>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export default TrackEditor;
|
<Button color="red" onClick={() => setConfirmDelete(true)}>
|
||||||
|
{t('general.delete')}
|
||||||
|
</Button>
|
||||||
|
<Confirm
|
||||||
|
open={confirmDelete}
|
||||||
|
onCancel={() => setConfirmDelete(false)}
|
||||||
|
onConfirm={onDelete}
|
||||||
|
content={t('TrackEditor.dangerZone.confirmDelete')}
|
||||||
|
confirmButton={t('general.delete')}
|
||||||
|
cancelButton={t('general.cancel')}
|
||||||
|
/>
|
||||||
|
</Grid.Column>
|
||||||
|
</Grid.Row>
|
||||||
|
</Grid>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default TrackEditor
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {Link} from 'react-router-dom'
|
import {Link} from 'react-router-dom'
|
||||||
import {Icon, Popup, Button, Dropdown} from 'semantic-ui-react'
|
import {Icon, Popup, Button, Dropdown} from 'semantic-ui-react'
|
||||||
import { useTranslation } from "react-i18next";
|
import {useTranslation} from 'react-i18next'
|
||||||
|
|
||||||
export default function TrackActions({slug, isAuthor, onDownload}) {
|
export default function TrackActions({slug, isAuthor, onDownload}) {
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -25,7 +25,11 @@ export default function TrackActions({slug, isAuthor, onDownload}) {
|
||||||
|
|
||||||
<Dropdown text={t('TrackPage.actions.download')} button>
|
<Dropdown text={t('TrackPage.actions.download')} button>
|
||||||
<Dropdown.Menu>
|
<Dropdown.Menu>
|
||||||
<Dropdown.Item text={t('TrackPage.actions.original')}onClick={() => onDownload('original.csv')} disabled={!isAuthor} />
|
<Dropdown.Item
|
||||||
|
text={t('TrackPage.actions.original')}
|
||||||
|
onClick={() => onDownload('original.csv')}
|
||||||
|
disabled={!isAuthor}
|
||||||
|
/>
|
||||||
<Dropdown.Item text={t('TrackPage.actions.gpx')} onClick={() => onDownload('track.gpx')} />
|
<Dropdown.Item text={t('TrackPage.actions.gpx')} onClick={() => onDownload('track.gpx')} />
|
||||||
</Dropdown.Menu>
|
</Dropdown.Menu>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|
|
@ -1,57 +1,34 @@
|
||||||
import React from "react";
|
import React from 'react'
|
||||||
import {
|
import {Message, Segment, Form, Button, Loader, Header, Comment} from 'semantic-ui-react'
|
||||||
Message,
|
import Markdown from 'react-markdown'
|
||||||
Segment,
|
import {useTranslation} from 'react-i18next'
|
||||||
Form,
|
|
||||||
Button,
|
|
||||||
Loader,
|
|
||||||
Header,
|
|
||||||
Comment,
|
|
||||||
} from "semantic-ui-react";
|
|
||||||
import Markdown from "react-markdown";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { Avatar, FormattedDate } from "components";
|
import {Avatar, FormattedDate} from 'components'
|
||||||
|
|
||||||
function CommentForm({ onSubmit }) {
|
function CommentForm({onSubmit}) {
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
const [body, setBody] = React.useState("");
|
const [body, setBody] = React.useState('')
|
||||||
|
|
||||||
const onSubmitComment = React.useCallback(() => {
|
const onSubmitComment = React.useCallback(() => {
|
||||||
onSubmit({ body });
|
onSubmit({body})
|
||||||
setBody("");
|
setBody('')
|
||||||
}, [onSubmit, body]);
|
}, [onSubmit, body])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form reply onSubmit={onSubmitComment}>
|
<Form reply onSubmit={onSubmitComment}>
|
||||||
<Form.TextArea
|
<Form.TextArea rows={4} value={body} onChange={(e) => setBody(e.target.value)} />
|
||||||
rows={4}
|
<Button content={t('TrackPage.comments.post')} labelPosition="left" icon="edit" primary />
|
||||||
value={body}
|
|
||||||
onChange={(e) => setBody(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
content={t("TrackPage.comments.post")}
|
|
||||||
labelPosition="left"
|
|
||||||
icon="edit"
|
|
||||||
primary
|
|
||||||
/>
|
|
||||||
</Form>
|
</Form>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TrackComments({
|
export default function TrackComments({comments, onSubmit, onDelete, login, hideLoader}) {
|
||||||
comments,
|
const {t} = useTranslation()
|
||||||
onSubmit,
|
|
||||||
onDelete,
|
|
||||||
login,
|
|
||||||
hideLoader,
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Comment.Group>
|
<Comment.Group>
|
||||||
<Header as="h2" dividing>
|
<Header as="h2" dividing>
|
||||||
{t("TrackPage.comments.title")}
|
{t('TrackPage.comments.title')}
|
||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
<Loader active={!hideLoader && comments == null} inline />
|
<Loader active={!hideLoader && comments == null} inline />
|
||||||
|
@ -60,9 +37,7 @@ export default function TrackComments({
|
||||||
<Comment key={comment.id}>
|
<Comment key={comment.id}>
|
||||||
<Avatar user={comment.author} />
|
<Avatar user={comment.author} />
|
||||||
<Comment.Content>
|
<Comment.Content>
|
||||||
<Comment.Author as="a">
|
<Comment.Author as="a">{comment.author.displayName}</Comment.Author>
|
||||||
{comment.author.displayName}
|
|
||||||
</Comment.Author>
|
|
||||||
<Comment.Metadata>
|
<Comment.Metadata>
|
||||||
<div>
|
<div>
|
||||||
<FormattedDate date={comment.createdAt} relative />
|
<FormattedDate date={comment.createdAt} relative />
|
||||||
|
@ -75,11 +50,11 @@ export default function TrackComments({
|
||||||
<Comment.Actions>
|
<Comment.Actions>
|
||||||
<Comment.Action
|
<Comment.Action
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
onDelete(comment.id);
|
onDelete(comment.id)
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("general.delete")}
|
{t('general.delete')}
|
||||||
</Comment.Action>
|
</Comment.Action>
|
||||||
</Comment.Actions>
|
</Comment.Actions>
|
||||||
)}
|
)}
|
||||||
|
@ -87,12 +62,10 @@ export default function TrackComments({
|
||||||
</Comment>
|
</Comment>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{comments != null && !comments.length && (
|
{comments != null && !comments.length && <Message>{t('TrackPage.comments.empty')}</Message>}
|
||||||
<Message>{t("TrackPage.comments.empty")}</Message>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{login && comments != null && <CommentForm onSubmit={onSubmit} />}
|
{login && comments != null && <CommentForm onSubmit={onSubmit} />}
|
||||||
</Comment.Group>
|
</Comment.Group>
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,66 +1,38 @@
|
||||||
import React from "react";
|
import React from 'react'
|
||||||
import _ from "lodash";
|
import _ from 'lodash'
|
||||||
import { List, Header, Grid } from "semantic-ui-react";
|
import {List, Header, Grid} from 'semantic-ui-react'
|
||||||
import { Duration } from "luxon";
|
import {Duration} from 'luxon'
|
||||||
import { useTranslation } from "react-i18next";
|
import {useTranslation} from 'react-i18next'
|
||||||
|
|
||||||
import { FormattedDate, Visibility } from "components";
|
import {FormattedDate, Visibility} from 'components'
|
||||||
import { formatDistance, formatDuration } from "utils";
|
import {formatDistance, formatDuration} from 'utils'
|
||||||
|
|
||||||
export default function TrackDetails({ track, isAuthor }) {
|
export default function TrackDetails({track, isAuthor}) {
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
track.public != null &&
|
track.public != null && isAuthor && [t('TrackPage.details.visibility'), <Visibility public={track.public} />],
|
||||||
isAuthor && [
|
|
||||||
t("TrackPage.details.visibility"),
|
|
||||||
<Visibility public={track.public} />,
|
|
||||||
],
|
|
||||||
|
|
||||||
track.uploadedByUserAgent != null && [
|
track.uploadedByUserAgent != null && [t('TrackPage.details.uploadedWith'), track.uploadedByUserAgent],
|
||||||
t("TrackPage.details.uploadedWith"),
|
|
||||||
track.uploadedByUserAgent,
|
|
||||||
],
|
|
||||||
|
|
||||||
track.duration != null && [
|
track.duration != null && [t('TrackPage.details.duration'), formatDuration(track.duration)],
|
||||||
t("TrackPage.details.duration"),
|
|
||||||
formatDuration(track.duration),
|
|
||||||
],
|
|
||||||
|
|
||||||
track.createdAt != null && [
|
track.createdAt != null && [t('TrackPage.details.uploadedDate'), <FormattedDate date={track.createdAt} />],
|
||||||
t("TrackPage.details.uploadedDate"),
|
|
||||||
<FormattedDate date={track.createdAt} />,
|
|
||||||
],
|
|
||||||
|
|
||||||
track?.recordedAt != null && [
|
track?.recordedAt != null && [t('TrackPage.details.recordedDate'), <FormattedDate date={track?.recordedAt} />],
|
||||||
t("TrackPage.details.recordedDate"),
|
|
||||||
<FormattedDate date={track?.recordedAt} />,
|
|
||||||
],
|
|
||||||
|
|
||||||
track?.numEvents != null && [
|
track?.numEvents != null && [t('TrackPage.details.numEvents'), track?.numEvents],
|
||||||
t("TrackPage.details.numEvents"),
|
|
||||||
track?.numEvents,
|
|
||||||
],
|
|
||||||
|
|
||||||
track?.length != null && [
|
track?.length != null && [t('TrackPage.details.length'), formatDistance(track?.length)],
|
||||||
t("TrackPage.details.length"),
|
|
||||||
formatDistance(track?.length),
|
|
||||||
],
|
|
||||||
|
|
||||||
track?.processingStatus != null &&
|
track?.processingStatus != null &&
|
||||||
track?.processingStatus != "error" && [
|
track?.processingStatus != 'error' && [t('TrackPage.details.processingStatus'), track.processingStatus],
|
||||||
t("TrackPage.details.processingStatus"),
|
|
||||||
track.processingStatus,
|
|
||||||
],
|
|
||||||
|
|
||||||
track.originalFileName != null && [
|
track.originalFileName != null && [t('TrackPage.details.originalFileName'), <code>{track.originalFileName}</code>],
|
||||||
t("TrackPage.details.originalFileName"),
|
].filter(Boolean)
|
||||||
<code>{track.originalFileName}</code>,
|
|
||||||
],
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
const COLUMNS = 4;
|
const COLUMNS = 4
|
||||||
const chunkSize = Math.ceil(items.length / COLUMNS);
|
const chunkSize = Math.ceil(items.length / COLUMNS)
|
||||||
return (
|
return (
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.Row columns={COLUMNS}>
|
<Grid.Row columns={COLUMNS}>
|
||||||
|
@ -78,5 +50,5 @@ export default function TrackDetails({ track, isAuthor }) {
|
||||||
))}
|
))}
|
||||||
</Grid.Row>
|
</Grid.Row>
|
||||||
</Grid>
|
</Grid>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import React from "react";
|
import React from 'react'
|
||||||
import { connect } from "react-redux";
|
import {connect} from 'react-redux'
|
||||||
import {
|
import {
|
||||||
List,
|
List,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
|
@ -12,354 +12,323 @@ import {
|
||||||
Message,
|
Message,
|
||||||
Confirm,
|
Confirm,
|
||||||
Container,
|
Container,
|
||||||
} from "semantic-ui-react";
|
} from 'semantic-ui-react'
|
||||||
import { useParams, useHistory } from "react-router-dom";
|
import {useParams, useHistory} from 'react-router-dom'
|
||||||
import { concat, combineLatest, of, from, Subject } from "rxjs";
|
import {concat, combineLatest, of, from, Subject} from 'rxjs'
|
||||||
import {
|
import {pluck, distinctUntilChanged, map, switchMap, startWith, catchError} from 'rxjs/operators'
|
||||||
pluck,
|
import {useObservable} from 'rxjs-hooks'
|
||||||
distinctUntilChanged,
|
import Markdown from 'react-markdown'
|
||||||
map,
|
import {useTranslation} from 'react-i18next'
|
||||||
switchMap,
|
|
||||||
startWith,
|
|
||||||
catchError,
|
|
||||||
} from "rxjs/operators";
|
|
||||||
import { useObservable } from "rxjs-hooks";
|
|
||||||
import Markdown from "react-markdown";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import api from "api";
|
import api from 'api'
|
||||||
import { Page } from "components";
|
import {Page} from 'components'
|
||||||
import type { Track, TrackData, TrackComment } from "types";
|
import type {Track, TrackData, TrackComment} from 'types'
|
||||||
import { trackLayer, trackLayerRaw } from "../../mapstyles";
|
import {trackLayer, trackLayerRaw} from '../../mapstyles'
|
||||||
|
|
||||||
import TrackActions from "./TrackActions";
|
import TrackActions from './TrackActions'
|
||||||
import TrackComments from "./TrackComments";
|
import TrackComments from './TrackComments'
|
||||||
import TrackDetails from "./TrackDetails";
|
import TrackDetails from './TrackDetails'
|
||||||
import TrackMap from "./TrackMap";
|
import TrackMap from './TrackMap'
|
||||||
|
|
||||||
import styles from "./TrackPage.module.less";
|
import styles from './TrackPage.module.less'
|
||||||
|
|
||||||
function useTriggerSubject() {
|
function useTriggerSubject() {
|
||||||
const subject$ = React.useMemo(() => new Subject(), []);
|
const subject$ = React.useMemo(() => new Subject(), [])
|
||||||
const trigger = React.useCallback(() => subject$.next(null), [subject$]);
|
const trigger = React.useCallback(() => subject$.next(null), [subject$])
|
||||||
return [trigger, subject$];
|
return [trigger, subject$]
|
||||||
}
|
}
|
||||||
|
|
||||||
function TrackMapSettings({
|
function TrackMapSettings({showTrack, setShowTrack, pointsMode, setPointsMode, side, setSide}) {
|
||||||
showTrack,
|
const {t} = useTranslation()
|
||||||
setShowTrack,
|
|
||||||
pointsMode,
|
|
||||||
setPointsMode,
|
|
||||||
side,
|
|
||||||
setSide,
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header as="h4">{t("TrackPage.mapSettings.title")}</Header>
|
<Header as="h4">{t('TrackPage.mapSettings.title')}</Header>
|
||||||
<List>
|
<List>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<Checkbox
|
<Checkbox checked={showTrack} onChange={(e, d) => setShowTrack(d.checked)} />{' '}
|
||||||
checked={showTrack}
|
{t('TrackPage.mapSettings.showTrack')}
|
||||||
onChange={(e, d) => setShowTrack(d.checked)}
|
<div style={{marginTop: 8}}>
|
||||||
/>{" "}
|
|
||||||
{t("TrackPage.mapSettings.showTrack")}
|
|
||||||
<div style={{ marginTop: 8 }}>
|
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
borderTop: "3px dashed " + trackLayerRaw.paint["line-color"],
|
borderTop: '3px dashed ' + trackLayerRaw.paint['line-color'],
|
||||||
height: 0,
|
height: 0,
|
||||||
width: 24,
|
width: 24,
|
||||||
display: "inline-block",
|
display: 'inline-block',
|
||||||
verticalAlign: "middle",
|
verticalAlign: 'middle',
|
||||||
marginRight: 4,
|
marginRight: 4,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{t("TrackPage.mapSettings.gpsTrack")}
|
{t('TrackPage.mapSettings.gpsTrack')}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
borderTop: "6px solid " + trackLayerRaw.paint["line-color"],
|
borderTop: '6px solid ' + trackLayerRaw.paint['line-color'],
|
||||||
height: 6,
|
height: 6,
|
||||||
width: 24,
|
width: 24,
|
||||||
display: "inline-block",
|
display: 'inline-block',
|
||||||
verticalAlign: "middle",
|
verticalAlign: 'middle',
|
||||||
marginRight: 4,
|
marginRight: 4,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{t("TrackPage.mapSettings.snappedTrack")}
|
{t('TrackPage.mapSettings.snappedTrack')}
|
||||||
</div>
|
</div>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<List.Header> {t("TrackPage.mapSettings.points")} </List.Header>
|
<List.Header> {t('TrackPage.mapSettings.points')} </List.Header>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
selection
|
selection
|
||||||
value={pointsMode}
|
value={pointsMode}
|
||||||
onChange={(e, d) => setPointsMode(d.value)}
|
onChange={(e, d) => setPointsMode(d.value)}
|
||||||
options={[
|
options={[
|
||||||
{ key: "none", value: "none", text: "None" },
|
{key: 'none', value: 'none', text: 'None'},
|
||||||
{
|
{
|
||||||
key: "overtakingEvents",
|
key: 'overtakingEvents',
|
||||||
value: "overtakingEvents",
|
value: 'overtakingEvents',
|
||||||
text: t("TrackPage.mapSettings.confirmedPoints"),
|
text: t('TrackPage.mapSettings.confirmedPoints'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "measurements",
|
key: 'measurements',
|
||||||
value: "measurements",
|
value: 'measurements',
|
||||||
text: t("TrackPage.mapSettings.allPoints"),
|
text: t('TrackPage.mapSettings.allPoints'),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<List.Header>{t("TrackPage.mapSettings.side")}</List.Header>
|
<List.Header>{t('TrackPage.mapSettings.side')}</List.Header>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
selection
|
selection
|
||||||
value={side}
|
value={side}
|
||||||
onChange={(e, d) => setSide(d.value)}
|
onChange={(e, d) => setSide(d.value)}
|
||||||
options={[
|
options={[
|
||||||
{
|
{
|
||||||
key: "overtaker",
|
key: 'overtaker',
|
||||||
value: "overtaker",
|
value: 'overtaker',
|
||||||
text: t("TrackPage.mapSettings.overtakerSide"),
|
text: t('TrackPage.mapSettings.overtakerSide'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "stationary",
|
key: 'stationary',
|
||||||
value: "stationary",
|
value: 'stationary',
|
||||||
text: t("TrackPage.mapSettings.stationarySide"),
|
text: t('TrackPage.mapSettings.stationarySide'),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
</List>
|
</List>
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const TrackPage = connect((state) => ({ login: state.login }))(
|
const TrackPage = connect((state) => ({login: state.login}))(function TrackPage({login}) {
|
||||||
function TrackPage({ login }) {
|
const {slug} = useParams()
|
||||||
const { slug } = useParams();
|
const {t} = useTranslation()
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const [reloadComments, reloadComments$] = useTriggerSubject();
|
const [reloadComments, reloadComments$] = useTriggerSubject()
|
||||||
const history = useHistory();
|
const history = useHistory()
|
||||||
|
|
||||||
const data: {
|
const data: {
|
||||||
track: null | Track;
|
track: null | Track
|
||||||
trackData: null | TrackData;
|
trackData: null | TrackData
|
||||||
comments: null | TrackComment[];
|
comments: null | TrackComment[]
|
||||||
} | null = useObservable(
|
} | null = useObservable(
|
||||||
(_$, args$) => {
|
(_$, args$) => {
|
||||||
const slug$ = args$.pipe(pluck(0), distinctUntilChanged());
|
const slug$ = args$.pipe(pluck(0), distinctUntilChanged())
|
||||||
const track$ = slug$.pipe(
|
const track$ = slug$.pipe(
|
||||||
map((slug) => `/tracks/${slug}`),
|
map((slug) => `/tracks/${slug}`),
|
||||||
switchMap((url) =>
|
switchMap((url) =>
|
||||||
concat(
|
concat(
|
||||||
of(null),
|
of(null),
|
||||||
from(api.get(url)).pipe(
|
|
||||||
catchError(() => {
|
|
||||||
history.replace("/tracks");
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
pluck("track")
|
|
||||||
);
|
|
||||||
|
|
||||||
const trackData$ = slug$.pipe(
|
|
||||||
map((slug) => `/tracks/${slug}/data`),
|
|
||||||
switchMap((url) =>
|
|
||||||
concat(
|
|
||||||
of(undefined),
|
|
||||||
from(api.get(url)).pipe(
|
|
||||||
catchError(() => {
|
|
||||||
return of(null);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
startWith(undefined) // show track infos before track data is loaded
|
|
||||||
);
|
|
||||||
|
|
||||||
const comments$ = concat(of(null), reloadComments$).pipe(
|
|
||||||
switchMap(() => slug$),
|
|
||||||
map((slug) => `/tracks/${slug}/comments`),
|
|
||||||
switchMap((url) =>
|
|
||||||
from(api.get(url)).pipe(
|
from(api.get(url)).pipe(
|
||||||
catchError(() => {
|
catchError(() => {
|
||||||
return of(null);
|
history.replace('/tracks')
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
),
|
)
|
||||||
pluck("comments"),
|
),
|
||||||
startWith(null) // show track infos before comments are loaded
|
pluck('track')
|
||||||
);
|
)
|
||||||
|
|
||||||
return combineLatest([track$, trackData$, comments$]).pipe(
|
const trackData$ = slug$.pipe(
|
||||||
map(([track, trackData, comments]) => ({
|
map((slug) => `/tracks/${slug}/data`),
|
||||||
track,
|
switchMap((url) =>
|
||||||
trackData,
|
concat(
|
||||||
comments,
|
of(undefined),
|
||||||
}))
|
from(api.get(url)).pipe(
|
||||||
);
|
catchError(() => {
|
||||||
},
|
return of(null)
|
||||||
null,
|
})
|
||||||
[slug]
|
)
|
||||||
);
|
)
|
||||||
|
),
|
||||||
|
startWith(undefined) // show track infos before track data is loaded
|
||||||
|
)
|
||||||
|
|
||||||
const onSubmitComment = React.useCallback(
|
const comments$ = concat(of(null), reloadComments$).pipe(
|
||||||
async ({ body }) => {
|
switchMap(() => slug$),
|
||||||
await api.post(`/tracks/${slug}/comments`, {
|
map((slug) => `/tracks/${slug}/comments`),
|
||||||
body: { comment: { body } },
|
switchMap((url) =>
|
||||||
});
|
from(api.get(url)).pipe(
|
||||||
reloadComments();
|
catchError(() => {
|
||||||
},
|
return of(null)
|
||||||
[slug, reloadComments]
|
})
|
||||||
);
|
)
|
||||||
|
),
|
||||||
|
pluck('comments'),
|
||||||
|
startWith(null) // show track infos before comments are loaded
|
||||||
|
)
|
||||||
|
|
||||||
const onDeleteComment = React.useCallback(
|
return combineLatest([track$, trackData$, comments$]).pipe(
|
||||||
async (id) => {
|
map(([track, trackData, comments]) => ({
|
||||||
await api.delete(`/tracks/${slug}/comments/${id}`);
|
track,
|
||||||
reloadComments();
|
trackData,
|
||||||
},
|
comments,
|
||||||
[slug, reloadComments]
|
}))
|
||||||
);
|
)
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
[slug]
|
||||||
|
)
|
||||||
|
|
||||||
const [downloadError, setDownloadError] = React.useState(null);
|
const onSubmitComment = React.useCallback(
|
||||||
const hideDownloadError = React.useCallback(
|
async ({body}) => {
|
||||||
() => setDownloadError(null),
|
await api.post(`/tracks/${slug}/comments`, {
|
||||||
[setDownloadError]
|
body: {comment: {body}},
|
||||||
);
|
})
|
||||||
const onDownload = React.useCallback(
|
reloadComments()
|
||||||
async (filename) => {
|
},
|
||||||
try {
|
[slug, reloadComments]
|
||||||
await api.downloadFile(`/tracks/${slug}/download/${filename}`);
|
)
|
||||||
} catch (err) {
|
|
||||||
if (/Failed to fetch/.test(String(err))) {
|
const onDeleteComment = React.useCallback(
|
||||||
setDownloadError(t("TrackPage.downloadError"));
|
async (id) => {
|
||||||
} else {
|
await api.delete(`/tracks/${slug}/comments/${id}`)
|
||||||
setDownloadError(String(err));
|
reloadComments()
|
||||||
}
|
},
|
||||||
|
[slug, reloadComments]
|
||||||
|
)
|
||||||
|
|
||||||
|
const [downloadError, setDownloadError] = React.useState(null)
|
||||||
|
const hideDownloadError = React.useCallback(() => setDownloadError(null), [setDownloadError])
|
||||||
|
const onDownload = React.useCallback(
|
||||||
|
async (filename) => {
|
||||||
|
try {
|
||||||
|
await api.downloadFile(`/tracks/${slug}/download/${filename}`)
|
||||||
|
} catch (err) {
|
||||||
|
if (/Failed to fetch/.test(String(err))) {
|
||||||
|
setDownloadError(t('TrackPage.downloadError'))
|
||||||
|
} else {
|
||||||
|
setDownloadError(String(err))
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
[slug]
|
},
|
||||||
);
|
[slug]
|
||||||
|
)
|
||||||
|
|
||||||
const isAuthor = login?.id === data?.track?.author?.id;
|
const isAuthor = login?.id === data?.track?.author?.id
|
||||||
|
|
||||||
const { track, trackData, comments } = data || {};
|
const {track, trackData, comments} = data || {}
|
||||||
|
|
||||||
const loading = track == null || trackData === undefined;
|
const loading = track == null || trackData === undefined
|
||||||
const processing = ["processing", "queued", "created"].includes(
|
const processing = ['processing', 'queued', 'created'].includes(track?.processingStatus)
|
||||||
track?.processingStatus
|
const error = track?.processingStatus === 'error'
|
||||||
);
|
|
||||||
const error = track?.processingStatus === "error";
|
|
||||||
|
|
||||||
const [showTrack, setShowTrack] = React.useState(true);
|
const [showTrack, setShowTrack] = React.useState(true)
|
||||||
const [pointsMode, setPointsMode] = React.useState("overtakingEvents"); // none|overtakingEvents|measurements
|
const [pointsMode, setPointsMode] = React.useState('overtakingEvents') // none|overtakingEvents|measurements
|
||||||
const [side, setSide] = React.useState("overtaker"); // overtaker|stationary
|
const [side, setSide] = React.useState('overtaker') // overtaker|stationary
|
||||||
|
|
||||||
const title = track ? track.title || t("general.unnamedTrack") : null;
|
const title = track ? track.title || t('general.unnamedTrack') : null
|
||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
title={title}
|
title={title}
|
||||||
stage={
|
stage={
|
||||||
<>
|
<>
|
||||||
<Container>
|
<Container>
|
||||||
{track && (
|
{track && (
|
||||||
<Segment basic>
|
<Segment basic>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: 'flex',
|
||||||
alignItems: "baseline",
|
alignItems: 'baseline',
|
||||||
marginBlockStart: 32,
|
marginBlockStart: 32,
|
||||||
marginBlockEnd: 16,
|
marginBlockEnd: 16,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Header as="h1">{title}</Header>
|
<Header as="h1">{title}</Header>
|
||||||
<div style={{ marginLeft: "auto" }}>
|
<div style={{marginLeft: 'auto'}}>
|
||||||
<TrackActions {...{ isAuthor, onDownload, slug }} />
|
<TrackActions {...{isAuthor, onDownload, slug}} />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBlockEnd: 16 }}>
|
<div style={{marginBlockEnd: 16}}>
|
||||||
<TrackDetails {...{ track, isAuthor }} />
|
<TrackDetails {...{track, isAuthor}} />
|
||||||
</div>
|
</div>
|
||||||
</Segment>
|
</Segment>
|
||||||
)}
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
<div className={styles.stage}>
|
<div className={styles.stage}>
|
||||||
<Loader active={loading} />
|
<Loader active={loading} />
|
||||||
<Dimmer.Dimmable blurring dimmed={loading}>
|
<Dimmer.Dimmable blurring dimmed={loading}>
|
||||||
<TrackMap
|
<TrackMap {...{track, trackData, pointsMode, side, showTrack}} style={{height: '80vh'}} />
|
||||||
{...{ track, trackData, pointsMode, side, showTrack }}
|
</Dimmer.Dimmable>
|
||||||
style={{ height: "80vh" }}
|
|
||||||
|
<div className={styles.details}>
|
||||||
|
<Segment>
|
||||||
|
<TrackMapSettings
|
||||||
|
{...{
|
||||||
|
showTrack,
|
||||||
|
setShowTrack,
|
||||||
|
pointsMode,
|
||||||
|
setPointsMode,
|
||||||
|
side,
|
||||||
|
setSide,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Dimmer.Dimmable>
|
</Segment>
|
||||||
|
|
||||||
<div className={styles.details}>
|
{processing && (
|
||||||
<Segment>
|
<Message warning>
|
||||||
<TrackMapSettings
|
<Message.Content>{t('TrackPage.processing')}</Message.Content>
|
||||||
{...{
|
</Message>
|
||||||
showTrack,
|
|
||||||
setShowTrack,
|
|
||||||
pointsMode,
|
|
||||||
setPointsMode,
|
|
||||||
side,
|
|
||||||
setSide,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Segment>
|
|
||||||
|
|
||||||
{processing && (
|
|
||||||
<Message warning>
|
|
||||||
<Message.Content>
|
|
||||||
{t("TrackPage.processing")}
|
|
||||||
</Message.Content>
|
|
||||||
</Message>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Message error>
|
|
||||||
<Message.Content>
|
|
||||||
{t("TrackPage.processingError")}
|
|
||||||
</Message.Content>
|
|
||||||
</Message>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Container>
|
|
||||||
{track?.description && (
|
|
||||||
<>
|
|
||||||
<Header as="h2" dividing>
|
|
||||||
{t("TrackPage.description")}
|
|
||||||
</Header>
|
|
||||||
<Markdown>{track.description}</Markdown>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TrackComments
|
{error && (
|
||||||
{...{ hideLoader: loading, comments, login }}
|
<Message error>
|
||||||
onSubmit={onSubmitComment}
|
<Message.Content>{t('TrackPage.processingError')}</Message.Content>
|
||||||
onDelete={onDeleteComment}
|
</Message>
|
||||||
/>
|
)}
|
||||||
</Container>
|
</div>
|
||||||
</>
|
</div>
|
||||||
}
|
|
||||||
>
|
|
||||||
<Confirm
|
|
||||||
open={downloadError != null}
|
|
||||||
cancelButton={false}
|
|
||||||
onConfirm={hideDownloadError}
|
|
||||||
header={t("TrackPage.downloadFailed")}
|
|
||||||
content={String(downloadError)}
|
|
||||||
confirmButton={t("general.ok")}
|
|
||||||
/>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export default TrackPage;
|
<Container>
|
||||||
|
{track?.description && (
|
||||||
|
<>
|
||||||
|
<Header as="h2" dividing>
|
||||||
|
{t('TrackPage.description')}
|
||||||
|
</Header>
|
||||||
|
<Markdown>{track.description}</Markdown>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TrackComments
|
||||||
|
{...{hideLoader: loading, comments, login}}
|
||||||
|
onSubmit={onSubmitComment}
|
||||||
|
onDelete={onDeleteComment}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Confirm
|
||||||
|
open={downloadError != null}
|
||||||
|
cancelButton={false}
|
||||||
|
onConfirm={hideDownloadError}
|
||||||
|
header={t('TrackPage.downloadFailed')}
|
||||||
|
content={String(downloadError)}
|
||||||
|
confirmButton={t('general.ok')}
|
||||||
|
/>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default TrackPage
|
||||||
|
|
|
@ -1,65 +1,49 @@
|
||||||
import React, { useCallback } from "react";
|
import React, {useCallback} from 'react'
|
||||||
import { connect } from "react-redux";
|
import {connect} from 'react-redux'
|
||||||
import {
|
import {Button, Message, Item, Header, Loader, Pagination, Icon} from 'semantic-ui-react'
|
||||||
Button,
|
import {useObservable} from 'rxjs-hooks'
|
||||||
Message,
|
import {Link} from 'react-router-dom'
|
||||||
Item,
|
import {of, from, concat} from 'rxjs'
|
||||||
Header,
|
import {map, switchMap, distinctUntilChanged} from 'rxjs/operators'
|
||||||
Loader,
|
import _ from 'lodash'
|
||||||
Pagination,
|
import {useTranslation, Trans as Translate} from 'react-i18next'
|
||||||
Icon,
|
|
||||||
} from "semantic-ui-react";
|
|
||||||
import { useObservable } from "rxjs-hooks";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { of, from, concat } from "rxjs";
|
|
||||||
import { map, switchMap, distinctUntilChanged } from "rxjs/operators";
|
|
||||||
import _ from "lodash";
|
|
||||||
import { useTranslation, Trans as Translate } from "react-i18next";
|
|
||||||
|
|
||||||
import type { Track } from "types";
|
import type {Track} from 'types'
|
||||||
import {
|
import {Avatar, Page, StripMarkdown, FormattedDate, Visibility} from 'components'
|
||||||
Avatar,
|
import api from 'api'
|
||||||
Page,
|
import {useQueryParam} from 'query'
|
||||||
StripMarkdown,
|
|
||||||
FormattedDate,
|
|
||||||
Visibility,
|
|
||||||
} from "components";
|
|
||||||
import api from "api";
|
|
||||||
import { useQueryParam } from "query";
|
|
||||||
|
|
||||||
function TrackList({ privateTracks }: { privateTracks: boolean }) {
|
function TrackList({privateTracks}: {privateTracks: boolean}) {
|
||||||
const [page, setPage] = useQueryParam<number>("page", 1, Number);
|
const [page, setPage] = useQueryParam<number>('page', 1, Number)
|
||||||
|
|
||||||
const pageSize = 10;
|
const pageSize = 10
|
||||||
|
|
||||||
const data: {
|
const data: {
|
||||||
tracks: Track[];
|
tracks: Track[]
|
||||||
trackCount: number;
|
trackCount: number
|
||||||
} | null = useObservable(
|
} | null = useObservable(
|
||||||
(_$, inputs$) =>
|
(_$, inputs$) =>
|
||||||
inputs$.pipe(
|
inputs$.pipe(
|
||||||
map(([page, privateTracks]) => {
|
map(([page, privateTracks]) => {
|
||||||
const url = "/tracks" + (privateTracks ? "/feed" : "");
|
const url = '/tracks' + (privateTracks ? '/feed' : '')
|
||||||
const query = { limit: pageSize, offset: pageSize * (page - 1) };
|
const query = {limit: pageSize, offset: pageSize * (page - 1)}
|
||||||
return { url, query };
|
return {url, query}
|
||||||
}),
|
}),
|
||||||
distinctUntilChanged(_.isEqual),
|
distinctUntilChanged(_.isEqual),
|
||||||
switchMap((request) =>
|
switchMap((request) => concat(of(null), from(api.get(request.url, {query: request.query}))))
|
||||||
concat(of(null), from(api.get(request.url, { query: request.query })))
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
null,
|
null,
|
||||||
[page, privateTracks]
|
[page, privateTracks]
|
||||||
);
|
)
|
||||||
|
|
||||||
const { tracks, trackCount } = data || { tracks: [], trackCount: 0 };
|
const {tracks, trackCount} = data || {tracks: [], trackCount: 0}
|
||||||
const loading = !data;
|
const loading = !data
|
||||||
const totalPages = Math.ceil(trackCount / pageSize);
|
const totalPages = Math.ceil(trackCount / pageSize)
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Loader content={t("general.loading")} active={loading} />
|
<Loader content={t('general.loading')} active={loading} />
|
||||||
{!loading && totalPages > 1 && (
|
{!loading && totalPages > 1 && (
|
||||||
<Pagination
|
<Pagination
|
||||||
activePage={page}
|
activePage={page}
|
||||||
|
@ -71,14 +55,14 @@ function TrackList({ privateTracks }: { privateTracks: boolean }) {
|
||||||
{tracks && tracks.length ? (
|
{tracks && tracks.length ? (
|
||||||
<Item.Group divided>
|
<Item.Group divided>
|
||||||
{tracks.map((track: Track) => (
|
{tracks.map((track: Track) => (
|
||||||
<TrackListItem key={track.slug} {...{ track, privateTracks }} />
|
<TrackListItem key={track.slug} {...{track, privateTracks}} />
|
||||||
))}
|
))}
|
||||||
</Item.Group>
|
</Item.Group>
|
||||||
) : (
|
) : (
|
||||||
<NoPublicTracksMessage />
|
<NoPublicTracksMessage />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NoPublicTracksMessage() {
|
export function NoPublicTracksMessage() {
|
||||||
|
@ -88,27 +72,27 @@ export function NoPublicTracksMessage() {
|
||||||
No public tracks yet. <Link to="/upload">Upload the first!</Link>
|
No public tracks yet. <Link to="/upload">Upload the first!</Link>
|
||||||
</Translate>
|
</Translate>
|
||||||
</Message>
|
</Message>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function maxLength(t: string | null, max: number): string | null {
|
function maxLength(t: string | null, max: number): string | null {
|
||||||
if (t && t.length > max) {
|
if (t && t.length > max) {
|
||||||
return t.substring(0, max) + " ...";
|
return t.substring(0, max) + ' ...'
|
||||||
} else {
|
} else {
|
||||||
return t;
|
return t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLOR_BY_STATUS = {
|
const COLOR_BY_STATUS = {
|
||||||
error: "red",
|
error: 'red',
|
||||||
complete: "green",
|
complete: 'green',
|
||||||
created: "gray",
|
created: 'gray',
|
||||||
queued: "orange",
|
queued: 'orange',
|
||||||
processing: "orange",
|
processing: 'orange',
|
||||||
};
|
}
|
||||||
|
|
||||||
export function TrackListItem({ track, privateTracks = false }) {
|
export function TrackListItem({track, privateTracks = false}) {
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Item key={track.slug}>
|
<Item key={track.slug}>
|
||||||
|
@ -117,14 +101,10 @@ export function TrackListItem({ track, privateTracks = false }) {
|
||||||
</Item.Image>
|
</Item.Image>
|
||||||
<Item.Content>
|
<Item.Content>
|
||||||
<Item.Header as={Link} to={`/tracks/${track.slug}`}>
|
<Item.Header as={Link} to={`/tracks/${track.slug}`}>
|
||||||
{track.title || t("general.unnamedTrack")}
|
{track.title || t('general.unnamedTrack')}
|
||||||
</Item.Header>
|
</Item.Header>
|
||||||
<Item.Meta>
|
<Item.Meta>
|
||||||
{privateTracks ? null : (
|
{privateTracks ? null : <span>{t('TracksPage.createdBy', {author: track.author.displayName})}</span>}
|
||||||
<span>
|
|
||||||
{t("TracksPage.createdBy", { author: track.author.displayName })}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span>
|
<span>
|
||||||
<FormattedDate date={track.createdAt} />
|
<FormattedDate date={track.createdAt} />
|
||||||
</span>
|
</span>
|
||||||
|
@ -136,57 +116,44 @@ export function TrackListItem({ track, privateTracks = false }) {
|
||||||
<Item.Extra>
|
<Item.Extra>
|
||||||
<Visibility public={track.public} />
|
<Visibility public={track.public} />
|
||||||
|
|
||||||
<span style={{ marginLeft: "1em" }}>
|
<span style={{marginLeft: '1em'}}>
|
||||||
<Icon
|
<Icon color={COLOR_BY_STATUS[track.processingStatus]} name="bolt" fitted />{' '}
|
||||||
color={COLOR_BY_STATUS[track.processingStatus]}
|
|
||||||
name="bolt"
|
|
||||||
fitted
|
|
||||||
/>{" "}
|
|
||||||
{t(`TracksPage.processing.${track.processingStatus}`)}
|
{t(`TracksPage.processing.${track.processingStatus}`)}
|
||||||
</span>
|
</span>
|
||||||
</Item.Extra>
|
</Item.Extra>
|
||||||
)}
|
)}
|
||||||
</Item.Content>
|
</Item.Content>
|
||||||
</Item>
|
</Item>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function UploadButton({ navigate, ...props }) {
|
function UploadButton({navigate, ...props}) {
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
const onClick = useCallback(
|
const onClick = useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
navigate();
|
navigate()
|
||||||
},
|
},
|
||||||
[navigate]
|
[navigate]
|
||||||
);
|
)
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button onClick={onClick} {...props} color="green" style={{float: 'right'}}>
|
||||||
onClick={onClick}
|
{t('TracksPage.upload')}
|
||||||
{...props}
|
|
||||||
color="green"
|
|
||||||
style={{ float: "right" }}
|
|
||||||
>
|
|
||||||
{t("TracksPage.upload")}
|
|
||||||
</Button>
|
</Button>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const TracksPage = connect((state) => ({ login: (state as any).login }))(
|
const TracksPage = connect((state) => ({login: (state as any).login}))(function TracksPage({login, privateTracks}) {
|
||||||
function TracksPage({ login, privateTracks }) {
|
const {t} = useTranslation()
|
||||||
const { t } = useTranslation();
|
const title = privateTracks ? t('TracksPage.titleUser') : t('TracksPage.titlePublic')
|
||||||
const title = privateTracks
|
|
||||||
? t("TracksPage.titleUser")
|
|
||||||
: t("TracksPage.titlePublic");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page title={title}>
|
<Page title={title}>
|
||||||
<Header as="h2">{title}</Header>
|
<Header as="h2">{title}</Header>
|
||||||
{privateTracks && <Link component={UploadButton} to="/upload" />}
|
{privateTracks && <Link component={UploadButton} to="/upload" />}
|
||||||
<TrackList {...{ privateTracks }} />
|
<TrackList {...{privateTracks}} />
|
||||||
</Page>
|
</Page>
|
||||||
);
|
)
|
||||||
}
|
})
|
||||||
);
|
|
||||||
|
|
||||||
export default TracksPage;
|
export default TracksPage
|
||||||
|
|
|
@ -1,46 +1,46 @@
|
||||||
import _ from "lodash";
|
import _ from 'lodash'
|
||||||
import React from "react";
|
import React from 'react'
|
||||||
import { Header, List, Loader, Table, Icon } from "semantic-ui-react";
|
import {Header, List, Loader, Table, Icon} from 'semantic-ui-react'
|
||||||
import { Link } from "react-router-dom";
|
import {Link} from 'react-router-dom'
|
||||||
import { useTranslation } from "react-i18next";
|
import {useTranslation} from 'react-i18next'
|
||||||
|
|
||||||
import { FileUploadField, Page } from "components";
|
import {FileUploadField, Page} from 'components'
|
||||||
import type { Track } from "types";
|
import type {Track} from 'types'
|
||||||
import api from "api";
|
import api from 'api'
|
||||||
import configPromise from "config";
|
import configPromise from 'config'
|
||||||
|
|
||||||
function isSameFile(a: File, b: File) {
|
function isSameFile(a: File, b: File) {
|
||||||
return a.name === b.name && a.size === b.size;
|
return a.name === b.name && a.size === b.size
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFileSize(bytes: number) {
|
function formatFileSize(bytes: number) {
|
||||||
if (bytes < 1024) {
|
if (bytes < 1024) {
|
||||||
return `${bytes} bytes`;
|
return `${bytes} bytes`
|
||||||
}
|
}
|
||||||
|
|
||||||
bytes /= 1024;
|
bytes /= 1024
|
||||||
|
|
||||||
if (bytes < 1024) {
|
if (bytes < 1024) {
|
||||||
return `${bytes.toFixed(1)} KiB`;
|
return `${bytes.toFixed(1)} KiB`
|
||||||
}
|
}
|
||||||
|
|
||||||
bytes /= 1024;
|
bytes /= 1024
|
||||||
|
|
||||||
if (bytes < 1024) {
|
if (bytes < 1024) {
|
||||||
return `${bytes.toFixed(1)} MiB`;
|
return `${bytes.toFixed(1)} MiB`
|
||||||
}
|
}
|
||||||
|
|
||||||
bytes /= 1024;
|
bytes /= 1024
|
||||||
return `${bytes.toFixed(1)} GiB`;
|
return `${bytes.toFixed(1)} GiB`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileUploadResult =
|
type FileUploadResult =
|
||||||
| {
|
| {
|
||||||
track: Track;
|
track: Track
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
errors: Record<string, string>;
|
errors: Record<string, string>
|
||||||
};
|
}
|
||||||
|
|
||||||
export function FileUploadStatus({
|
export function FileUploadStatus({
|
||||||
id,
|
id,
|
||||||
|
@ -48,109 +48,101 @@ export function FileUploadStatus({
|
||||||
onComplete,
|
onComplete,
|
||||||
slug,
|
slug,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string
|
||||||
file: File;
|
file: File
|
||||||
onComplete: (id: string, result: FileUploadResult) => void;
|
onComplete: (id: string, result: FileUploadResult) => void
|
||||||
slug?: string;
|
slug?: string
|
||||||
}) {
|
}) {
|
||||||
const [progress, setProgress] = React.useState(0);
|
const [progress, setProgress] = React.useState(0)
|
||||||
|
|
||||||
React.useEffect(
|
React.useEffect(
|
||||||
() => {
|
() => {
|
||||||
let xhr;
|
let xhr
|
||||||
|
|
||||||
async function _work() {
|
async function _work() {
|
||||||
const formData = new FormData();
|
const formData = new FormData()
|
||||||
formData.append("body", file);
|
formData.append('body', file)
|
||||||
|
|
||||||
xhr = new XMLHttpRequest();
|
xhr = new XMLHttpRequest()
|
||||||
xhr.withCredentials = true;
|
xhr.withCredentials = true
|
||||||
|
|
||||||
const onProgress = (e) => {
|
const onProgress = (e) => {
|
||||||
const progress = (e.loaded || 0) / (e.total || 1);
|
const progress = (e.loaded || 0) / (e.total || 1)
|
||||||
setProgress(progress);
|
setProgress(progress)
|
||||||
};
|
}
|
||||||
|
|
||||||
const onLoad = (e) => {
|
const onLoad = (e) => {
|
||||||
onComplete(id, xhr.response);
|
onComplete(id, xhr.response)
|
||||||
};
|
}
|
||||||
|
|
||||||
xhr.responseType = "json";
|
xhr.responseType = 'json'
|
||||||
xhr.onload = onLoad;
|
xhr.onload = onLoad
|
||||||
xhr.upload.onprogress = onProgress;
|
xhr.upload.onprogress = onProgress
|
||||||
|
|
||||||
const config = await configPromise;
|
const config = await configPromise
|
||||||
if (slug) {
|
if (slug) {
|
||||||
xhr.open("PUT", `${config.apiUrl}/tracks/${slug}`);
|
xhr.open('PUT', `${config.apiUrl}/tracks/${slug}`)
|
||||||
} else {
|
} else {
|
||||||
xhr.open("POST", `${config.apiUrl}/tracks`);
|
xhr.open('POST', `${config.apiUrl}/tracks`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// const accessToken = await api.getValidAccessToken()
|
// const accessToken = await api.getValidAccessToken()
|
||||||
|
|
||||||
// xhr.setRequestHeader('Authorization', accessToken)
|
// xhr.setRequestHeader('Authorization', accessToken)
|
||||||
xhr.send(formData);
|
xhr.send(formData)
|
||||||
}
|
}
|
||||||
|
|
||||||
_work();
|
_work()
|
||||||
return () => xhr.abort();
|
return () => xhr.abort()
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[file]
|
[file]
|
||||||
);
|
)
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
<Loader inline size="mini" active />{" "}
|
<Loader inline size="mini" active />{' '}
|
||||||
{progress < 1
|
{progress < 1
|
||||||
? t("UploadPage.uploadProgress", {
|
? t('UploadPage.uploadProgress', {
|
||||||
progress: (progress * 100).toFixed(0),
|
progress: (progress * 100).toFixed(0),
|
||||||
})
|
})
|
||||||
: t("UploadPage.processing")}
|
: t('UploadPage.processing')}
|
||||||
</span>
|
</span>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileEntry = {
|
type FileEntry = {
|
||||||
id: string;
|
id: string
|
||||||
file?: File | null;
|
file?: File | null
|
||||||
size: number;
|
size: number
|
||||||
name: string;
|
name: string
|
||||||
result?: FileUploadResult;
|
result?: FileUploadResult
|
||||||
};
|
}
|
||||||
|
|
||||||
export default function UploadPage() {
|
export default function UploadPage() {
|
||||||
const [files, setFiles] = React.useState<FileEntry[]>([]);
|
const [files, setFiles] = React.useState<FileEntry[]>([])
|
||||||
|
|
||||||
const onCompleteFileUpload = React.useCallback(
|
const onCompleteFileUpload = React.useCallback(
|
||||||
(id, result) => {
|
(id, result) => {
|
||||||
setFiles((files) =>
|
setFiles((files) => files.map((file) => (file.id === id ? {...file, result, file: null} : file)))
|
||||||
files.map((file) =>
|
|
||||||
file.id === id ? { ...file, result, file: null } : file
|
|
||||||
)
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[setFiles]
|
[setFiles]
|
||||||
);
|
)
|
||||||
|
|
||||||
function onSelectFiles(fileList) {
|
function onSelectFiles(fileList) {
|
||||||
const newFiles = Array.from(fileList).map((file) => ({
|
const newFiles = Array.from(fileList).map((file) => ({
|
||||||
id: "file-" + String(Math.floor(Math.random() * 1000000)),
|
id: 'file-' + String(Math.floor(Math.random() * 1000000)),
|
||||||
file,
|
file,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
}));
|
}))
|
||||||
setFiles(
|
setFiles(files.filter((a) => !newFiles.some((b) => isSameFile(a, b))).concat(newFiles))
|
||||||
files
|
|
||||||
.filter((a) => !newFiles.some((b) => isSameFile(a, b)))
|
|
||||||
.concat(newFiles)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const {t} = useTranslation()
|
||||||
|
|
||||||
const title = t("UploadPage.title");
|
const title = t('UploadPage.title')
|
||||||
return (
|
return (
|
||||||
<Page title={title}>
|
<Page title={title}>
|
||||||
<Header as="h1">{title}</Header>
|
<Header as="h1">{title}</Header>
|
||||||
|
@ -158,19 +150,15 @@ export default function UploadPage() {
|
||||||
<Table>
|
<Table>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.HeaderCell>
|
<Table.HeaderCell>{t('UploadPage.table.filename')}</Table.HeaderCell>
|
||||||
{t("UploadPage.table.filename")}
|
<Table.HeaderCell>{t('UploadPage.table.size')}</Table.HeaderCell>
|
||||||
</Table.HeaderCell>
|
<Table.HeaderCell>{t('UploadPage.table.statusTitle')}</Table.HeaderCell>
|
||||||
<Table.HeaderCell>{t("UploadPage.table.size")}</Table.HeaderCell>
|
|
||||||
<Table.HeaderCell>
|
|
||||||
{t("UploadPage.table.statusTitle")}
|
|
||||||
</Table.HeaderCell>
|
|
||||||
<Table.HeaderCell colSpan={2}></Table.HeaderCell>
|
<Table.HeaderCell colSpan={2}></Table.HeaderCell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
|
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{files.map(({ id, name, size, file, result }) => (
|
{files.map(({id, name, size, file, result}) => (
|
||||||
<Table.Row key={id}>
|
<Table.Row key={id}>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Icon name="file" />
|
<Icon name="file" />
|
||||||
|
@ -181,9 +169,7 @@ export default function UploadPage() {
|
||||||
{result?.errors ? (
|
{result?.errors ? (
|
||||||
<List>
|
<List>
|
||||||
{_.sortBy(Object.entries(result.errors))
|
{_.sortBy(Object.entries(result.errors))
|
||||||
.filter(
|
.filter(([field, message]) => typeof message === 'string')
|
||||||
([field, message]) => typeof message === "string"
|
|
||||||
)
|
|
||||||
.map(([field, message]) => (
|
.map(([field, message]) => (
|
||||||
<List.Item key={field}>
|
<List.Item key={field}>
|
||||||
<List.Icon name="warning sign" color="red" />
|
<List.Icon name="warning sign" color="red" />
|
||||||
|
@ -193,29 +179,17 @@ export default function UploadPage() {
|
||||||
</List>
|
</List>
|
||||||
) : result ? (
|
) : result ? (
|
||||||
<>
|
<>
|
||||||
<Icon name="check" />{" "}
|
<Icon name="check" /> {result.track?.title || t('general.unnamedTrack')}
|
||||||
{result.track?.title || t("general.unnamedTrack")}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<FileUploadStatus
|
<FileUploadStatus {...{id, file}} onComplete={onCompleteFileUpload} />
|
||||||
{...{ id, file }}
|
|
||||||
onComplete={onCompleteFileUpload}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{result?.track ? (
|
{result?.track ? <Link to={`/tracks/${result.track.slug}`}>{t('general.show')}</Link> : null}
|
||||||
<Link to={`/tracks/${result.track.slug}`}>
|
|
||||||
{t("general.show")}
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{result?.track ? (
|
{result?.track ? <Link to={`/tracks/${result.track.slug}/edit`}>{t('general.edit')}</Link> : null}
|
||||||
<Link to={`/tracks/${result.track.slug}/edit`}>
|
|
||||||
{t("general.edit")}
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
))}
|
))}
|
||||||
|
@ -225,5 +199,5 @@ export default function UploadPage() {
|
||||||
|
|
||||||
<FileUploadField onSelect={onSelectFiles} multiple />
|
<FileUploadField onSelect={onSelectFiles} multiple />
|
||||||
</Page>
|
</Page>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
export { default as AcknowledgementsPage } from "./AcknowledgementsPage";
|
export {default as AcknowledgementsPage} from './AcknowledgementsPage'
|
||||||
export { default as ExportPage } from "./ExportPage";
|
export {default as ExportPage} from './ExportPage'
|
||||||
export { default as HomePage } from "./HomePage";
|
export {default as HomePage} from './HomePage'
|
||||||
export { default as LoginRedirectPage } from "./LoginRedirectPage";
|
export {default as LoginRedirectPage} from './LoginRedirectPage'
|
||||||
export { default as LogoutPage } from "./LogoutPage";
|
export {default as LogoutPage} from './LogoutPage'
|
||||||
export { default as MapPage } from "./MapPage";
|
export {default as MapPage} from './MapPage'
|
||||||
export { default as NotFoundPage } from "./NotFoundPage";
|
export {default as NotFoundPage} from './NotFoundPage'
|
||||||
export { default as SettingsPage } from "./SettingsPage";
|
export {default as SettingsPage} from './SettingsPage'
|
||||||
export { default as TrackEditor } from "./TrackEditor";
|
export {default as TrackEditor} from './TrackEditor'
|
||||||
export { default as TrackPage } from "./TrackPage";
|
export {default as TrackPage} from './TrackPage'
|
||||||
export { default as TracksPage } from "./TracksPage";
|
export {default as TracksPage} from './TracksPage'
|
||||||
export { default as MyTracksPage } from "./MyTracksPage";
|
export {default as MyTracksPage} from './MyTracksPage'
|
||||||
export { default as UploadPage } from "./UploadPage";
|
export {default as UploadPage} from './UploadPage'
|
||||||
|
|
|
@ -53,7 +53,7 @@ export function useQueryParam<T extends QueryValue>(
|
||||||
): [T, (newValue: T) => void] {
|
): [T, (newValue: T) => void] {
|
||||||
const history = useHistory()
|
const history = useHistory()
|
||||||
useLocation() // to trigger a reload when the url changes
|
useLocation() // to trigger a reload when the url changes
|
||||||
const {[name]: value = defaultValue} = (parseQuery(history.location.search) as unknown) as {
|
const {[name]: value = defaultValue} = parseQuery(history.location.search) as unknown as {
|
||||||
[name: string]: T
|
[name: string]: T
|
||||||
}
|
}
|
||||||
const setter = useMemo(
|
const setter = useMemo(
|
||||||
|
|
|
@ -1,67 +1,62 @@
|
||||||
import type { FeatureCollection, Feature, LineString, Point } from "geojson";
|
import type {FeatureCollection, Feature, LineString, Point} from 'geojson'
|
||||||
|
|
||||||
export interface UserProfile {
|
export interface UserProfile {
|
||||||
username: string;
|
username: string
|
||||||
displayName: string;
|
displayName: string
|
||||||
image?: string | null;
|
image?: string | null
|
||||||
bio?: string | null;
|
bio?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrackData {
|
export interface TrackData {
|
||||||
track: Feature<LineString>;
|
track: Feature<LineString>
|
||||||
measurements: FeatureCollection;
|
measurements: FeatureCollection
|
||||||
overtakingEvents: FeatureCollection;
|
overtakingEvents: FeatureCollection
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProcessingStatus =
|
export type ProcessingStatus = 'error' | 'complete' | 'created' | 'queued' | 'processing'
|
||||||
| "error"
|
|
||||||
| "complete"
|
|
||||||
| "created"
|
|
||||||
| "queued"
|
|
||||||
| "processing";
|
|
||||||
|
|
||||||
export interface Track {
|
export interface Track {
|
||||||
slug: string;
|
slug: string
|
||||||
author: UserProfile;
|
author: UserProfile
|
||||||
title: string;
|
title: string
|
||||||
description?: string;
|
description?: string
|
||||||
createdAt: string;
|
createdAt: string
|
||||||
processingStatus?: ProcessingStatus;
|
processingStatus?: ProcessingStatus
|
||||||
public?: boolean;
|
public?: boolean
|
||||||
recordedAt?: Date;
|
recordedAt?: Date
|
||||||
recordedUntil?: Date;
|
recordedUntil?: Date
|
||||||
duration?: number;
|
duration?: number
|
||||||
length?: number;
|
length?: number
|
||||||
segments?: number;
|
segments?: number
|
||||||
numEvents?: number;
|
numEvents?: number
|
||||||
numMeasurements?: number;
|
numMeasurements?: number
|
||||||
numValid?: number;
|
numValid?: number
|
||||||
userDeviceId?: number;
|
userDeviceId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrackPoint {
|
export interface TrackPoint {
|
||||||
type: "Feature";
|
type: 'Feature'
|
||||||
geometry: Point;
|
geometry: Point
|
||||||
properties: {
|
properties: {
|
||||||
distanceOvertaker: null | number;
|
distanceOvertaker: null | number
|
||||||
distanceStationary: null | number;
|
distanceStationary: null | number
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrackComment {
|
export interface TrackComment {
|
||||||
id: string;
|
id: string
|
||||||
body: string;
|
body: string
|
||||||
createdAt: string;
|
createdAt: string
|
||||||
author: UserProfile;
|
author: UserProfile
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Location {
|
export interface Location {
|
||||||
longitude: number;
|
longitude: number
|
||||||
latitude: number;
|
latitude: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserDevice {
|
export interface UserDevice {
|
||||||
id: number;
|
id: number
|
||||||
identifier: string;
|
identifier: string
|
||||||
displayName?: string;
|
displayName?: string
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,51 +1,49 @@
|
||||||
import { useRef, useCallback } from "react";
|
import {useRef, useCallback} from 'react'
|
||||||
import { Duration } from "luxon";
|
import {Duration} from 'luxon'
|
||||||
|
|
||||||
// Wraps the register callback from useForm into a new ref function, such that
|
// Wraps the register callback from useForm into a new ref function, such that
|
||||||
// any child of the provided element that is an input component will be
|
// any child of the provided element that is an input component will be
|
||||||
// registered.
|
// registered.
|
||||||
export function findInput(register) {
|
export function findInput(register) {
|
||||||
return (element) => {
|
return (element) => {
|
||||||
const found = element
|
const found = element ? element.querySelector('input, textarea, select, checkbox') : null
|
||||||
? element.querySelector("input, textarea, select, checkbox")
|
register(found)
|
||||||
: null;
|
}
|
||||||
register(found);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generates pairs from the input iterable
|
// Generates pairs from the input iterable
|
||||||
export function* pairwise(it) {
|
export function* pairwise(it) {
|
||||||
let lastValue;
|
let lastValue
|
||||||
let firstRound = true;
|
let firstRound = true
|
||||||
|
|
||||||
for (const i of it) {
|
for (const i of it) {
|
||||||
if (firstRound) {
|
if (firstRound) {
|
||||||
firstRound = false;
|
firstRound = false
|
||||||
} else {
|
} else {
|
||||||
yield [lastValue, i];
|
yield [lastValue, i]
|
||||||
}
|
}
|
||||||
lastValue = i;
|
lastValue = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCallbackRef(fn) {
|
export function useCallbackRef(fn) {
|
||||||
const fnRef = useRef();
|
const fnRef = useRef()
|
||||||
fnRef.current = fn;
|
fnRef.current = fn
|
||||||
return useCallback((...args) => fnRef.current(...args), []);
|
return useCallback((...args) => fnRef.current(...args), [])
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDuration(seconds) {
|
export function formatDuration(seconds) {
|
||||||
return Duration.fromMillis((seconds ?? 0) * 1000).toFormat("h'h' mm'm'");
|
return Duration.fromMillis((seconds ?? 0) * 1000).toFormat("h'h' mm'm'")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDistance(meters) {
|
export function formatDistance(meters) {
|
||||||
if (meters == null) return null;
|
if (meters == null) return null
|
||||||
|
|
||||||
if (meters < 0) return "-" + formatDistance(meters);
|
if (meters < 0) return '-' + formatDistance(meters)
|
||||||
|
|
||||||
if (meters < 1000) {
|
if (meters < 1000) {
|
||||||
return `${meters.toFixed(0)} m`;
|
return `${meters.toFixed(0)} m`
|
||||||
} else {
|
} else {
|
||||||
return `${(meters / 1000).toFixed(2)} km`;
|
return `${(meters / 1000).toFixed(2)} km`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -163,7 +163,7 @@ module.exports = function (webpackEnv) {
|
||||||
'/config.json': apiUrl,
|
'/config.json': apiUrl,
|
||||||
'/api': apiUrl,
|
'/api': apiUrl,
|
||||||
'/login': apiUrl,
|
'/login': apiUrl,
|
||||||
'/tiles': apiUrl
|
'/tiles': apiUrl,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
module: {
|
module: {
|
||||||
|
@ -210,10 +210,12 @@ module.exports = function (webpackEnv) {
|
||||||
{
|
{
|
||||||
test: /\.ya?ml$/,
|
test: /\.ya?ml$/,
|
||||||
type: 'json',
|
type: 'json',
|
||||||
use: [{
|
use: [
|
||||||
loader: 'yaml-loader',
|
{
|
||||||
options: {asJSON: true},
|
loader: 'yaml-loader',
|
||||||
}],
|
options: {asJSON: true},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
test: /\.css$/i,
|
test: /\.css$/i,
|
||||||
|
|
Loading…
Reference in a new issue