Split settings page
This commit is contained in:
parent
4fe7d45dec
commit
141460c79f
9 changed files with 377 additions and 292 deletions
|
@ -1,118 +1,145 @@
|
||||||
import React, {useState, useCallback} from 'react'
|
import React, { useState, useCallback } from "react";
|
||||||
import {pickBy} from 'lodash'
|
import { pickBy } from "lodash";
|
||||||
import {Loader, Statistic, Segment, Header, Menu} from 'semantic-ui-react'
|
import { Loader, Statistic, Segment, Header, Menu } from "semantic-ui-react";
|
||||||
import {useObservable} from 'rxjs-hooks'
|
import { useObservable } from "rxjs-hooks";
|
||||||
import {of, from, concat, combineLatest} from 'rxjs'
|
import { of, from, concat, combineLatest } from "rxjs";
|
||||||
import {map, switchMap, distinctUntilChanged} from 'rxjs/operators'
|
import { map, switchMap, distinctUntilChanged } from "rxjs/operators";
|
||||||
import {Duration, DateTime} from 'luxon'
|
import { Duration, DateTime } from "luxon";
|
||||||
import {useTranslation} from 'react-i18next'
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import api from 'api'
|
import api from "api";
|
||||||
|
|
||||||
function formatDuration(seconds) {
|
function formatDuration(seconds) {
|
||||||
return (
|
return (
|
||||||
Duration.fromMillis((seconds ?? 0) * 1000)
|
Duration.fromMillis((seconds ?? 0) * 1000)
|
||||||
.as('hours')
|
.as("hours")
|
||||||
.toFixed(1) + ' h'
|
.toFixed(1) + " h"
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Stats({user = null}: {user?: null | string}) {
|
export default function Stats({ user = null }: { user?: null | string }) {
|
||||||
const {t} = useTranslation()
|
const { t } = useTranslation();
|
||||||
const [timeframe, setTimeframe] = useState('all_time')
|
const [timeframe, setTimeframe] = useState("all_time");
|
||||||
const onClick = useCallback((_e, {name}) => setTimeframe(name), [setTimeframe])
|
const onClick = useCallback(
|
||||||
|
(_e, { name }) => setTimeframe(name),
|
||||||
|
[setTimeframe]
|
||||||
|
);
|
||||||
|
|
||||||
const stats = useObservable(
|
const stats = useObservable(
|
||||||
(_$, inputs$) => {
|
(_$, inputs$) => {
|
||||||
const timeframe$ = inputs$.pipe(
|
const timeframe$ = inputs$.pipe(
|
||||||
map((inputs) => inputs[0]),
|
map((inputs) => inputs[0]),
|
||||||
distinctUntilChanged()
|
distinctUntilChanged()
|
||||||
)
|
);
|
||||||
|
|
||||||
const user$ = inputs$.pipe(
|
const user$ = inputs$.pipe(
|
||||||
map((inputs) => inputs[1]),
|
map((inputs) => inputs[1]),
|
||||||
distinctUntilChanged()
|
distinctUntilChanged()
|
||||||
)
|
);
|
||||||
|
|
||||||
return combineLatest(timeframe$, user$).pipe(
|
return combineLatest(timeframe$, user$).pipe(
|
||||||
map(([timeframe_, user_]) => {
|
map(([timeframe_, user_]) => {
|
||||||
const now = DateTime.now()
|
const now = DateTime.now();
|
||||||
|
|
||||||
let start, end
|
let start, end;
|
||||||
|
|
||||||
switch (timeframe_) {
|
switch (timeframe_) {
|
||||||
case 'this_month':
|
case "this_month":
|
||||||
start = now.startOf('month')
|
start = now.startOf("month");
|
||||||
end = now.endOf('month')
|
end = now.endOf("month");
|
||||||
break
|
break;
|
||||||
|
|
||||||
case 'this_year':
|
case "this_year":
|
||||||
start = now.startOf('year')
|
start = now.startOf("year");
|
||||||
end = now.endOf('year')
|
end = now.endOf("year");
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return pickBy({
|
return pickBy({
|
||||||
start: start?.toISODate(),
|
start: start?.toISODate(),
|
||||||
end: end?.toISODate(),
|
end: end?.toISODate(),
|
||||||
user: user_,
|
user: user_,
|
||||||
})
|
});
|
||||||
}),
|
}),
|
||||||
switchMap((query) => concat(of(null), from(api.get('/stats', {query}))))
|
switchMap((query) =>
|
||||||
)
|
concat(of(null), from(api.get("/stats", { query })))
|
||||||
|
)
|
||||||
|
);
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
[timeframe, user]
|
[timeframe, user]
|
||||||
)
|
);
|
||||||
|
|
||||||
const placeholder = t('Stats.placeholder')
|
const placeholder = t("Stats.placeholder");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header as="h2">{user ? t('Stats.titleUser') : t('Stats.title')}</Header>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Segment attached="top">
|
<Segment attached="top">
|
||||||
<Loader active={stats == null} />
|
<Loader active={stats == null} />
|
||||||
<Statistic.Group widths={2} size="tiny">
|
<Statistic.Group widths={2} size="tiny">
|
||||||
<Statistic>
|
<Statistic>
|
||||||
<Statistic.Value>{stats ? `${Number(stats?.trackLength / 1000).toFixed(1)} km` : placeholder}</Statistic.Value>
|
<Statistic.Value>
|
||||||
<Statistic.Label>{t('Stats.totalTrackLength')}</Statistic.Label>
|
{stats
|
||||||
|
? `${Number(stats?.trackLength / 1000).toFixed(1)} km`
|
||||||
|
: placeholder}
|
||||||
|
</Statistic.Value>
|
||||||
|
<Statistic.Label>{t("Stats.totalTrackLength")}</Statistic.Label>
|
||||||
</Statistic>
|
</Statistic>
|
||||||
<Statistic>
|
<Statistic>
|
||||||
<Statistic.Value>{stats ? formatDuration(stats?.trackDuration) : placeholder}</Statistic.Value>
|
<Statistic.Value>
|
||||||
<Statistic.Label>{t('Stats.timeRecorded')}</Statistic.Label>
|
{stats ? formatDuration(stats?.trackDuration) : placeholder}
|
||||||
|
</Statistic.Value>
|
||||||
|
<Statistic.Label>{t("Stats.timeRecorded")}</Statistic.Label>
|
||||||
</Statistic>
|
</Statistic>
|
||||||
<Statistic>
|
<Statistic>
|
||||||
<Statistic.Value>{stats?.numEvents ?? placeholder}</Statistic.Value>
|
<Statistic.Value>
|
||||||
<Statistic.Label>{t('Stats.eventsConfirmed')}</Statistic.Label>
|
{stats?.numEvents ?? placeholder}
|
||||||
|
</Statistic.Value>
|
||||||
|
<Statistic.Label>{t("Stats.eventsConfirmed")}</Statistic.Label>
|
||||||
</Statistic>
|
</Statistic>
|
||||||
{user ? (
|
{user ? (
|
||||||
<Statistic>
|
<Statistic>
|
||||||
<Statistic.Value>{stats?.trackCount ?? placeholder}</Statistic.Value>
|
<Statistic.Value>
|
||||||
<Statistic.Label>{t('Stats.tracksRecorded')}</Statistic.Label>
|
{stats?.trackCount ?? placeholder}
|
||||||
|
</Statistic.Value>
|
||||||
|
<Statistic.Label>{t("Stats.tracksRecorded")}</Statistic.Label>
|
||||||
</Statistic>
|
</Statistic>
|
||||||
) : (
|
) : (
|
||||||
<Statistic>
|
<Statistic>
|
||||||
<Statistic.Value>{stats?.userCount ?? placeholder}</Statistic.Value>
|
<Statistic.Value>
|
||||||
<Statistic.Label>{t('Stats.membersJoined')}</Statistic.Label>
|
{stats?.userCount ?? placeholder}
|
||||||
|
</Statistic.Value>
|
||||||
|
<Statistic.Label>{t("Stats.membersJoined")}</Statistic.Label>
|
||||||
</Statistic>
|
</Statistic>
|
||||||
)}
|
)}
|
||||||
</Statistic.Group>
|
</Statistic.Group>
|
||||||
</Segment>
|
</Segment>
|
||||||
|
|
||||||
<Menu widths={3} attached="bottom" size="small">
|
<Menu widths={3} attached="bottom" size="small">
|
||||||
<Menu.Item name="this_month" active={timeframe === 'this_month'} onClick={onClick}>
|
<Menu.Item
|
||||||
{t('Stats.thisMonth')}
|
name="this_month"
|
||||||
|
active={timeframe === "this_month"}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{t("Stats.thisMonth")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item name="this_year" active={timeframe === 'this_year'} onClick={onClick}>
|
<Menu.Item
|
||||||
{t('Stats.thisYear')}
|
name="this_year"
|
||||||
|
active={timeframe === "this_year"}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{t("Stats.thisYear")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item name="all_time" active={timeframe === 'all_time'} onClick={onClick}>
|
<Menu.Item
|
||||||
{t('Stats.allTime')}
|
name="all_time"
|
||||||
|
active={timeframe === "all_time"}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{t("Stats.allTime")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -271,7 +271,7 @@ function TracksTable({ title }) {
|
||||||
<Link component={UploadButton} to="/upload" />
|
<Link component={UploadButton} to="/upload" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Header as="h2">{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} />
|
||||||
|
|
||||||
|
|
|
@ -1,227 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { connect } from "react-redux";
|
|
||||||
import {
|
|
||||||
Message,
|
|
||||||
Icon,
|
|
||||||
Grid,
|
|
||||||
Form,
|
|
||||||
Button,
|
|
||||||
TextArea,
|
|
||||||
Ref,
|
|
||||||
Input,
|
|
||||||
Header,
|
|
||||||
Divider,
|
|
||||||
Popup,
|
|
||||||
} from "semantic-ui-react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import Markdown from "react-markdown";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { setLogin } from "reducers/login";
|
|
||||||
import { Page, Stats } from "components";
|
|
||||||
import api from "api";
|
|
||||||
import { findInput } from "utils";
|
|
||||||
import { useConfig } from "config";
|
|
||||||
|
|
||||||
const SettingsPage = connect((state) => ({ login: state.login }), { setLogin })(
|
|
||||||
function SettingsPage({ login, setLogin }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { register, handleSubmit } = useForm();
|
|
||||||
const [loading, setLoading] = React.useState(false);
|
|
||||||
const [errors, setErrors] = React.useState(null);
|
|
||||||
|
|
||||||
const onSave = React.useCallback(
|
|
||||||
async (changes) => {
|
|
||||||
setLoading(true);
|
|
||||||
setErrors(null);
|
|
||||||
try {
|
|
||||||
const response = await api.put("/user", { body: changes });
|
|
||||||
setLogin(response);
|
|
||||||
} catch (err) {
|
|
||||||
setErrors(err.errors);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setLoading, setLogin, setErrors]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onGenerateNewKey = React.useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setErrors(null);
|
|
||||||
try {
|
|
||||||
const response = await api.put("/user", {
|
|
||||||
body: { updateApiKey: true },
|
|
||||||
});
|
|
||||||
setLogin(response);
|
|
||||||
} catch (err) {
|
|
||||||
setErrors(err.errors);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [setLoading, setLogin, setErrors]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page title={t("SettingsPage.title")}>
|
|
||||||
<Grid centered relaxed divided stackable>
|
|
||||||
<Grid.Row>
|
|
||||||
<Grid.Column width={8}>
|
|
||||||
<Header as="h2">{t("SettingsPage.profile.title")}</Header>
|
|
||||||
|
|
||||||
<Form onSubmit={handleSubmit(onSave)} loading={loading}>
|
|
||||||
<Form.Field error={errors?.username}>
|
|
||||||
<label>{t("SettingsPage.profile.username.label")}</label>
|
|
||||||
<Ref innerRef={findInput(register)}>
|
|
||||||
<Input
|
|
||||||
name="username"
|
|
||||||
defaultValue={login.username}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</Ref>
|
|
||||||
<small>{t("SettingsPage.profile.username.hint")}</small>
|
|
||||||
</Form.Field>
|
|
||||||
|
|
||||||
<Message info visible>
|
|
||||||
{t("SettingsPage.profile.publicNotice")}
|
|
||||||
</Message>
|
|
||||||
|
|
||||||
<Form.Field error={errors?.displayName}>
|
|
||||||
<label>{t("SettingsPage.profile.displayName.label")}</label>
|
|
||||||
<Ref innerRef={findInput(register)}>
|
|
||||||
<Input
|
|
||||||
name="displayName"
|
|
||||||
defaultValue={login.displayName}
|
|
||||||
placeholder={login.username}
|
|
||||||
/>
|
|
||||||
</Ref>
|
|
||||||
<small>
|
|
||||||
{t("SettingsPage.profile.displayName.fallbackNotice")}
|
|
||||||
</small>
|
|
||||||
</Form.Field>
|
|
||||||
|
|
||||||
<Form.Field error={errors?.bio}>
|
|
||||||
<label>{t("SettingsPage.profile.bio.label")}</label>
|
|
||||||
<Ref innerRef={register}>
|
|
||||||
<TextArea name="bio" rows={4} defaultValue={login.bio} />
|
|
||||||
</Ref>
|
|
||||||
</Form.Field>
|
|
||||||
<Form.Field error={errors?.image}>
|
|
||||||
<label>{t("SettingsPage.profile.avatarUrl.label")}</label>
|
|
||||||
<Ref innerRef={findInput(register)}>
|
|
||||||
<Input name="image" defaultValue={login.image} />
|
|
||||||
</Ref>
|
|
||||||
</Form.Field>
|
|
||||||
|
|
||||||
<Button type="submit" primary>
|
|
||||||
{t("general.save")}
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</Grid.Column>
|
|
||||||
<Grid.Column width={6}>
|
|
||||||
<ApiKeyDialog {...{ login, onGenerateNewKey }} />
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<Stats user={login.id} />
|
|
||||||
</Grid.Column>
|
|
||||||
</Grid.Row>
|
|
||||||
</Grid>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
function CopyInput({ value, ...props }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [success, setSuccess] = React.useState(null);
|
|
||||||
const onClick = async () => {
|
|
||||||
try {
|
|
||||||
await window.navigator?.clipboard?.writeText(value);
|
|
||||||
setSuccess(true);
|
|
||||||
} catch (err) {
|
|
||||||
setSuccess(false);
|
|
||||||
} finally {
|
|
||||||
setTimeout(() => {
|
|
||||||
setSuccess(null);
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popup
|
|
||||||
trigger={
|
|
||||||
<Input
|
|
||||||
{...props}
|
|
||||||
value={value}
|
|
||||||
fluid
|
|
||||||
action={{ icon: "copy", onClick }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
position="top right"
|
|
||||||
open={success != null}
|
|
||||||
content={success ? t("general.copied") : t("general.copyError")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectField = findInput((ref) => ref?.select());
|
|
||||||
|
|
||||||
function ApiKeyDialog({ login, onGenerateNewKey }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const config = useConfig();
|
|
||||||
const [show, setShow] = React.useState(false);
|
|
||||||
const onClick = React.useCallback(
|
|
||||||
(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setShow(true);
|
|
||||||
},
|
|
||||||
[setShow]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onGenerateNewKeyInner = React.useCallback(
|
|
||||||
(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onGenerateNewKey();
|
|
||||||
},
|
|
||||||
[onGenerateNewKey]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Header as="h2">{t("SettingsPage.apiKey.title")}</Header>
|
|
||||||
<Markdown>{t("SettingsPage.apiKey.description")}</Markdown>
|
|
||||||
<div style={{ minHeight: 40, marginBottom: 16 }}>
|
|
||||||
{show ? (
|
|
||||||
login.apiKey ? (
|
|
||||||
<Ref innerRef={selectField}>
|
|
||||||
<CopyInput
|
|
||||||
label={t("SettingsPage.apiKey.key.label")}
|
|
||||||
value={login.apiKey}
|
|
||||||
/>
|
|
||||||
</Ref>
|
|
||||||
) : (
|
|
||||||
<Message warning content={t("SettingsPage.apiKey.key.empty")} />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<Button onClick={onClick}>
|
|
||||||
<Icon name="lock" /> {t("SettingsPage.apiKey.key.show")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Markdown>{t("SettingsPage.apiKey.urlDescription")}</Markdown>
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<CopyInput
|
|
||||||
label={t("SettingsPage.apiKey.url.label")}
|
|
||||||
value={config?.apiUrl?.replace(/\/api$/, "") ?? "..."}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Markdown>{t("SettingsPage.apiKey.generateDescription")}</Markdown>
|
|
||||||
<p></p>
|
|
||||||
<Button onClick={onGenerateNewKeyInner}>
|
|
||||||
{t("SettingsPage.apiKey.generate")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SettingsPage;
|
|
125
frontend/src/pages/SettingsPage/ApiKeySettings.tsx
Normal file
125
frontend/src/pages/SettingsPage/ApiKeySettings.tsx
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
import React from "react";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import {
|
||||||
|
Message,
|
||||||
|
Icon,
|
||||||
|
Button,
|
||||||
|
Ref,
|
||||||
|
Input,
|
||||||
|
Segment,
|
||||||
|
Popup,
|
||||||
|
} from "semantic-ui-react";
|
||||||
|
import Markdown from "react-markdown";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { setLogin } from "reducers/login";
|
||||||
|
import api from "api";
|
||||||
|
import { findInput } from "utils";
|
||||||
|
import { useConfig } from "config";
|
||||||
|
|
||||||
|
function CopyInput({ value, ...props }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [success, setSuccess] = React.useState(null);
|
||||||
|
const onClick = async () => {
|
||||||
|
try {
|
||||||
|
await window.navigator?.clipboard?.writeText(value);
|
||||||
|
setSuccess(true);
|
||||||
|
} catch (err) {
|
||||||
|
setSuccess(false);
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => {
|
||||||
|
setSuccess(null);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popup
|
||||||
|
trigger={
|
||||||
|
<Input
|
||||||
|
{...props}
|
||||||
|
value={value}
|
||||||
|
fluid
|
||||||
|
action={{ icon: "copy", onClick }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
position="top right"
|
||||||
|
open={success != null}
|
||||||
|
content={success ? t("general.copied") : t("general.copyError")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectField = findInput((ref) => ref?.select());
|
||||||
|
|
||||||
|
const ApiKeySettings = connect((state) => ({ login: state.login }), {
|
||||||
|
setLogin,
|
||||||
|
})(function ApiKeySettings({ login, setLogin, setErrors }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const config = useConfig();
|
||||||
|
const [show, setShow] = React.useState(false);
|
||||||
|
const onClick = React.useCallback(
|
||||||
|
(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setShow(true);
|
||||||
|
},
|
||||||
|
[setShow]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onGenerateNewKey = React.useCallback(
|
||||||
|
async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.put("/user", {
|
||||||
|
body: { updateApiKey: true },
|
||||||
|
});
|
||||||
|
setLogin(response);
|
||||||
|
} catch (err) {
|
||||||
|
setErrors(err.errors);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setLoading, setLogin, setErrors]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Segment style={{ maxWidth: 600, margin: "24px auto" }}>
|
||||||
|
<Markdown>{t("SettingsPage.apiKey.description")}</Markdown>
|
||||||
|
<div style={{ minHeight: 40, marginBottom: 16 }}>
|
||||||
|
{show ? (
|
||||||
|
login.apiKey ? (
|
||||||
|
<Ref innerRef={selectField}>
|
||||||
|
<CopyInput
|
||||||
|
label={t("SettingsPage.apiKey.key.label")}
|
||||||
|
value={login.apiKey}
|
||||||
|
/>
|
||||||
|
</Ref>
|
||||||
|
) : (
|
||||||
|
<Message warning content={t("SettingsPage.apiKey.key.empty")} />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Button onClick={onClick}>
|
||||||
|
<Icon name="lock" /> {t("SettingsPage.apiKey.key.show")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Markdown>{t("SettingsPage.apiKey.urlDescription")}</Markdown>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<CopyInput
|
||||||
|
label={t("SettingsPage.apiKey.url.label")}
|
||||||
|
value={config?.apiUrl?.replace(/\/api$/, "") ?? "..."}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Markdown>{t("SettingsPage.apiKey.generateDescription")}</Markdown>
|
||||||
|
<p></p>
|
||||||
|
<Button onClick={onGenerateNewKey}>
|
||||||
|
{t("SettingsPage.apiKey.generate")}
|
||||||
|
</Button>
|
||||||
|
</Segment>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ApiKeySettings;
|
89
frontend/src/pages/SettingsPage/UserSettingsForm.tsx
Normal file
89
frontend/src/pages/SettingsPage/UserSettingsForm.tsx
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
import React from "react";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import {
|
||||||
|
Segment,
|
||||||
|
Message,
|
||||||
|
Form,
|
||||||
|
Button,
|
||||||
|
TextArea,
|
||||||
|
Ref,
|
||||||
|
Input,
|
||||||
|
} from "semantic-ui-react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { setLogin } from "reducers/login";
|
||||||
|
import api from "api";
|
||||||
|
import { findInput } from "utils";
|
||||||
|
|
||||||
|
const UserSettingsForm = connect((state) => ({ login: state.login }), {
|
||||||
|
setLogin,
|
||||||
|
})(function UserSettingsForm({ login, setLogin, errors, setErrors }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { register, handleSubmit } = useForm();
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
|
||||||
|
const onSave = React.useCallback(
|
||||||
|
async (changes) => {
|
||||||
|
setLoading(true);
|
||||||
|
setErrors(null);
|
||||||
|
try {
|
||||||
|
const response = await api.put("/user", { body: changes });
|
||||||
|
setLogin(response);
|
||||||
|
} catch (err) {
|
||||||
|
setErrors(err.errors);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setLoading, setLogin, setErrors]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Segment style={{ maxWidth: 600 }}>
|
||||||
|
<Form onSubmit={handleSubmit(onSave)} loading={loading}>
|
||||||
|
<Form.Field error={errors?.username}>
|
||||||
|
<label>{t("SettingsPage.profile.username.label")}</label>
|
||||||
|
<Ref innerRef={findInput(register)}>
|
||||||
|
<Input name="username" defaultValue={login.username} disabled />
|
||||||
|
</Ref>
|
||||||
|
<small>{t("SettingsPage.profile.username.hint")}</small>
|
||||||
|
</Form.Field>
|
||||||
|
|
||||||
|
<Message info visible>
|
||||||
|
{t("SettingsPage.profile.publicNotice")}
|
||||||
|
</Message>
|
||||||
|
|
||||||
|
<Form.Field error={errors?.displayName}>
|
||||||
|
<label>{t("SettingsPage.profile.displayName.label")}</label>
|
||||||
|
<Ref innerRef={findInput(register)}>
|
||||||
|
<Input
|
||||||
|
name="displayName"
|
||||||
|
defaultValue={login.displayName}
|
||||||
|
placeholder={login.username}
|
||||||
|
/>
|
||||||
|
</Ref>
|
||||||
|
<small>{t("SettingsPage.profile.displayName.fallbackNotice")}</small>
|
||||||
|
</Form.Field>
|
||||||
|
|
||||||
|
<Form.Field error={errors?.bio}>
|
||||||
|
<label>{t("SettingsPage.profile.bio.label")}</label>
|
||||||
|
<Ref innerRef={register}>
|
||||||
|
<TextArea name="bio" rows={4} defaultValue={login.bio} />
|
||||||
|
</Ref>
|
||||||
|
</Form.Field>
|
||||||
|
<Form.Field error={errors?.image}>
|
||||||
|
<label>{t("SettingsPage.profile.avatarUrl.label")}</label>
|
||||||
|
<Ref innerRef={findInput(register)}>
|
||||||
|
<Input name="image" defaultValue={login.image} />
|
||||||
|
</Ref>
|
||||||
|
</Form.Field>
|
||||||
|
|
||||||
|
<Button type="submit" primary>
|
||||||
|
{t("general.save")}
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Segment>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
export default UserSettingsForm;
|
64
frontend/src/pages/SettingsPage/index.tsx
Normal file
64
frontend/src/pages/SettingsPage/index.tsx
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
import React from "react";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import { Header, Tab } from "semantic-ui-react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { setLogin } from "reducers/login";
|
||||||
|
import { Page, Stats } from "components";
|
||||||
|
import api from "api";
|
||||||
|
|
||||||
|
import ApiKeySettings from "./ApiKeySettings";
|
||||||
|
|
||||||
|
import UserSettingsForm from "./UserSettingsForm";
|
||||||
|
|
||||||
|
const SettingsPage = connect((state) => ({ login: state.login }), { setLogin })(
|
||||||
|
function SettingsPage({ login, setLogin }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { register, handleSubmit } = useForm();
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [errors, setErrors] = React.useState(null);
|
||||||
|
|
||||||
|
const onGenerateNewKey = React.useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setErrors(null);
|
||||||
|
try {
|
||||||
|
const response = await api.put("/user", {
|
||||||
|
body: { updateApiKey: true },
|
||||||
|
});
|
||||||
|
setLogin(response);
|
||||||
|
} catch (err) {
|
||||||
|
setErrors(err.errors);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [setLoading, setLogin, setErrors]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page title={t("SettingsPage.title")}>
|
||||||
|
<Header as="h1">{t("SettingsPage.title")}</Header>
|
||||||
|
<Tab
|
||||||
|
menu={{ secondary: true, pointing: true }}
|
||||||
|
panes={[
|
||||||
|
{
|
||||||
|
menuItem: t("SettingsPage.profile.title"),
|
||||||
|
render: () => <UserSettingsForm {...{ errors, setErrors }} />,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
menuItem: t("SettingsPage.apiKey.title"),
|
||||||
|
render: () => <ApiKeySettings {...{ errors, setErrors }} />,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
menuItem: t("SettingsPage.stats.title"),
|
||||||
|
render: () => <Stats user={login.id} />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default SettingsPage;
|
|
@ -1,6 +1,6 @@
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { List, Loader, Table, Icon } from "semantic-ui-react";
|
import { Header, List, Loader, Table, Icon } from "semantic-ui-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
@ -150,8 +150,10 @@ export default function UploadPage() {
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const title = t("UploadPage.title");
|
||||||
return (
|
return (
|
||||||
<Page title="Upload">
|
<Page title={title}>
|
||||||
|
<Header as="h1">{title}</Header>
|
||||||
{files.length ? (
|
{files.length ? (
|
||||||
<Table>
|
<Table>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
|
|
|
@ -49,11 +49,10 @@ LoginButton:
|
||||||
login: Anmelden
|
login: Anmelden
|
||||||
|
|
||||||
HomePage:
|
HomePage:
|
||||||
|
stats: Statistik
|
||||||
mostRecentTrack: Neueste Fahrt
|
mostRecentTrack: Neueste Fahrt
|
||||||
|
|
||||||
Stats:
|
Stats:
|
||||||
title: Statistik
|
|
||||||
titleUser: Meine Statistik
|
|
||||||
placeholder: "..."
|
placeholder: "..."
|
||||||
totalTrackLength: Gesamtfahrstrecke
|
totalTrackLength: Gesamtfahrstrecke
|
||||||
timeRecorded: Aufzeichnungszeit
|
timeRecorded: Aufzeichnungszeit
|
||||||
|
@ -104,6 +103,7 @@ ExportPage:
|
||||||
label: Geografischer Bereich
|
label: Geografischer Bereich
|
||||||
|
|
||||||
UploadPage:
|
UploadPage:
|
||||||
|
title: Fahrten hochladen
|
||||||
uploadProgress: Lade hoch {{progress}}%
|
uploadProgress: Lade hoch {{progress}}%
|
||||||
processing: Verarbeiten...
|
processing: Verarbeiten...
|
||||||
|
|
||||||
|
@ -212,10 +212,10 @@ MapPage:
|
||||||
eventCount: Anzahl Überholungen
|
eventCount: Anzahl Überholungen
|
||||||
|
|
||||||
SettingsPage:
|
SettingsPage:
|
||||||
title: Einstellungen
|
title: Mein Konto
|
||||||
|
|
||||||
profile:
|
profile:
|
||||||
title: Mein Profil
|
title: Profil
|
||||||
publicNotice: Alle Informationen ab hier sind öffentlich.
|
publicNotice: Alle Informationen ab hier sind öffentlich.
|
||||||
username:
|
username:
|
||||||
label: Kontoname
|
label: Kontoname
|
||||||
|
@ -231,7 +231,7 @@ SettingsPage:
|
||||||
label: Avatar URL
|
label: Avatar URL
|
||||||
|
|
||||||
apiKey:
|
apiKey:
|
||||||
title: Mein API-Schlüssel
|
title: API-Schlüssel
|
||||||
description: |
|
description: |
|
||||||
Hier findest du deinen API-Schlüssel für die Nutzung mit dem
|
Hier findest du deinen API-Schlüssel für die Nutzung mit dem
|
||||||
OpenBikeSensor. Du kannst ihn dir herauskopieren und in der Seite für die
|
OpenBikeSensor. Du kannst ihn dir herauskopieren und in der Seite für die
|
||||||
|
@ -258,6 +258,9 @@ SettingsPage:
|
||||||
|
|
||||||
generate: Neuen API-Schlüssel erstellen
|
generate: Neuen API-Schlüssel erstellen
|
||||||
|
|
||||||
|
stats:
|
||||||
|
title: Statistik
|
||||||
|
|
||||||
TrackPage:
|
TrackPage:
|
||||||
downloadFailed: Download fehlgeschlagen
|
downloadFailed: Download fehlgeschlagen
|
||||||
downloadError: Diese Fahrt wurde vermutlich nicht korrekt importiert, oder in letzter Zeit nicht aktualisiert. Bitte frage den Administrator um Hilfe mit diesem Problem.
|
downloadError: Diese Fahrt wurde vermutlich nicht korrekt importiert, oder in letzter Zeit nicht aktualisiert. Bitte frage den Administrator um Hilfe mit diesem Problem.
|
||||||
|
|
|
@ -54,11 +54,10 @@ LoginButton:
|
||||||
login: Login
|
login: Login
|
||||||
|
|
||||||
HomePage:
|
HomePage:
|
||||||
|
stats: Statistics
|
||||||
mostRecentTrack: Most recent track
|
mostRecentTrack: Most recent track
|
||||||
|
|
||||||
Stats:
|
Stats:
|
||||||
title: Statistics
|
|
||||||
titleUser: My Statistic
|
|
||||||
placeholder: "..."
|
placeholder: "..."
|
||||||
totalTrackLength: Total track length
|
totalTrackLength: Total track length
|
||||||
timeRecorded: Time recorded
|
timeRecorded: Time recorded
|
||||||
|
@ -110,6 +109,7 @@ ExportPage:
|
||||||
label: Bounding Box
|
label: Bounding Box
|
||||||
|
|
||||||
UploadPage:
|
UploadPage:
|
||||||
|
title: Upload tracks
|
||||||
uploadProgress: Uploading {{progress}}%
|
uploadProgress: Uploading {{progress}}%
|
||||||
processing: Processing...
|
processing: Processing...
|
||||||
|
|
||||||
|
@ -217,10 +217,10 @@ MapPage:
|
||||||
eventCount: Event count
|
eventCount: Event count
|
||||||
|
|
||||||
SettingsPage:
|
SettingsPage:
|
||||||
title: Settings
|
title: My Account
|
||||||
|
|
||||||
profile:
|
profile:
|
||||||
title: My profile
|
title: Profile
|
||||||
publicNotice: All of the information below is public.
|
publicNotice: All of the information below is public.
|
||||||
username:
|
username:
|
||||||
label: Username
|
label: Username
|
||||||
|
@ -236,7 +236,7 @@ SettingsPage:
|
||||||
label: Avatar URL
|
label: Avatar URL
|
||||||
|
|
||||||
apiKey:
|
apiKey:
|
||||||
title: My API Key
|
title: API Key
|
||||||
description: |
|
description: |
|
||||||
Here you find your API Key, for use in the OpenBikeSensor. You can to
|
Here you find your API Key, for use in the OpenBikeSensor. You can to
|
||||||
copy and paste it into your sensor's configuration interface to allow
|
copy and paste it into your sensor's configuration interface to allow
|
||||||
|
@ -260,6 +260,8 @@ SettingsPage:
|
||||||
|
|
||||||
generate: Generate new API key
|
generate: Generate new API key
|
||||||
|
|
||||||
|
stats:
|
||||||
|
title: Statistics
|
||||||
|
|
||||||
TrackPage:
|
TrackPage:
|
||||||
downloadFailed: Download failed
|
downloadFailed: Download failed
|
||||||
|
|
Loading…
Add table
Reference in a new issue