Split settings page
This commit is contained in:
parent
4fe7d45dec
commit
141460c79f
|
@ -1,118 +1,145 @@
|
|||
import React, {useState, useCallback} from 'react'
|
||||
import {pickBy} from 'lodash'
|
||||
import {Loader, Statistic, Segment, Header, Menu} from 'semantic-ui-react'
|
||||
import {useObservable} from 'rxjs-hooks'
|
||||
import {of, from, concat, combineLatest} from 'rxjs'
|
||||
import {map, switchMap, distinctUntilChanged} from 'rxjs/operators'
|
||||
import {Duration, DateTime} from 'luxon'
|
||||
import {useTranslation} from 'react-i18next'
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { pickBy } from "lodash";
|
||||
import { Loader, Statistic, Segment, Header, Menu } from "semantic-ui-react";
|
||||
import { useObservable } from "rxjs-hooks";
|
||||
import { of, from, concat, combineLatest } from "rxjs";
|
||||
import { map, switchMap, distinctUntilChanged } from "rxjs/operators";
|
||||
import { Duration, DateTime } from "luxon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import api from 'api'
|
||||
import api from "api";
|
||||
|
||||
function formatDuration(seconds) {
|
||||
return (
|
||||
Duration.fromMillis((seconds ?? 0) * 1000)
|
||||
.as('hours')
|
||||
.toFixed(1) + ' h'
|
||||
)
|
||||
.as("hours")
|
||||
.toFixed(1) + " h"
|
||||
);
|
||||
}
|
||||
|
||||
export default function Stats({user = null}: {user?: null | string}) {
|
||||
const {t} = useTranslation()
|
||||
const [timeframe, setTimeframe] = useState('all_time')
|
||||
const onClick = useCallback((_e, {name}) => setTimeframe(name), [setTimeframe])
|
||||
export default function Stats({ user = null }: { user?: null | string }) {
|
||||
const { t } = useTranslation();
|
||||
const [timeframe, setTimeframe] = useState("all_time");
|
||||
const onClick = useCallback(
|
||||
(_e, { name }) => setTimeframe(name),
|
||||
[setTimeframe]
|
||||
);
|
||||
|
||||
const stats = useObservable(
|
||||
(_$, inputs$) => {
|
||||
const timeframe$ = inputs$.pipe(
|
||||
map((inputs) => inputs[0]),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
);
|
||||
|
||||
const user$ = inputs$.pipe(
|
||||
map((inputs) => inputs[1]),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
);
|
||||
|
||||
return combineLatest(timeframe$, user$).pipe(
|
||||
map(([timeframe_, user_]) => {
|
||||
const now = DateTime.now()
|
||||
const now = DateTime.now();
|
||||
|
||||
let start, end
|
||||
let start, end;
|
||||
|
||||
switch (timeframe_) {
|
||||
case 'this_month':
|
||||
start = now.startOf('month')
|
||||
end = now.endOf('month')
|
||||
break
|
||||
case "this_month":
|
||||
start = now.startOf("month");
|
||||
end = now.endOf("month");
|
||||
break;
|
||||
|
||||
case 'this_year':
|
||||
start = now.startOf('year')
|
||||
end = now.endOf('year')
|
||||
break
|
||||
case "this_year":
|
||||
start = now.startOf("year");
|
||||
end = now.endOf("year");
|
||||
break;
|
||||
}
|
||||
|
||||
return pickBy({
|
||||
start: start?.toISODate(),
|
||||
end: end?.toISODate(),
|
||||
user: user_,
|
||||
})
|
||||
});
|
||||
}),
|
||||
switchMap((query) => concat(of(null), from(api.get('/stats', {query}))))
|
||||
)
|
||||
switchMap((query) =>
|
||||
concat(of(null), from(api.get("/stats", { query })))
|
||||
)
|
||||
);
|
||||
},
|
||||
null,
|
||||
[timeframe, user]
|
||||
)
|
||||
);
|
||||
|
||||
const placeholder = t('Stats.placeholder')
|
||||
const placeholder = t("Stats.placeholder");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header as="h2">{user ? t('Stats.titleUser') : t('Stats.title')}</Header>
|
||||
|
||||
<div>
|
||||
<Segment attached="top">
|
||||
<Loader active={stats == null} />
|
||||
<Statistic.Group widths={2} size="tiny">
|
||||
<Statistic>
|
||||
<Statistic.Value>{stats ? `${Number(stats?.trackLength / 1000).toFixed(1)} km` : placeholder}</Statistic.Value>
|
||||
<Statistic.Label>{t('Stats.totalTrackLength')}</Statistic.Label>
|
||||
<Statistic.Value>
|
||||
{stats
|
||||
? `${Number(stats?.trackLength / 1000).toFixed(1)} km`
|
||||
: placeholder}
|
||||
</Statistic.Value>
|
||||
<Statistic.Label>{t("Stats.totalTrackLength")}</Statistic.Label>
|
||||
</Statistic>
|
||||
<Statistic>
|
||||
<Statistic.Value>{stats ? formatDuration(stats?.trackDuration) : placeholder}</Statistic.Value>
|
||||
<Statistic.Label>{t('Stats.timeRecorded')}</Statistic.Label>
|
||||
<Statistic.Value>
|
||||
{stats ? formatDuration(stats?.trackDuration) : placeholder}
|
||||
</Statistic.Value>
|
||||
<Statistic.Label>{t("Stats.timeRecorded")}</Statistic.Label>
|
||||
</Statistic>
|
||||
<Statistic>
|
||||
<Statistic.Value>{stats?.numEvents ?? placeholder}</Statistic.Value>
|
||||
<Statistic.Label>{t('Stats.eventsConfirmed')}</Statistic.Label>
|
||||
<Statistic.Value>
|
||||
{stats?.numEvents ?? placeholder}
|
||||
</Statistic.Value>
|
||||
<Statistic.Label>{t("Stats.eventsConfirmed")}</Statistic.Label>
|
||||
</Statistic>
|
||||
{user ? (
|
||||
<Statistic>
|
||||
<Statistic.Value>{stats?.trackCount ?? placeholder}</Statistic.Value>
|
||||
<Statistic.Label>{t('Stats.tracksRecorded')}</Statistic.Label>
|
||||
<Statistic.Value>
|
||||
{stats?.trackCount ?? placeholder}
|
||||
</Statistic.Value>
|
||||
<Statistic.Label>{t("Stats.tracksRecorded")}</Statistic.Label>
|
||||
</Statistic>
|
||||
) : (
|
||||
<Statistic>
|
||||
<Statistic.Value>{stats?.userCount ?? placeholder}</Statistic.Value>
|
||||
<Statistic.Label>{t('Stats.membersJoined')}</Statistic.Label>
|
||||
<Statistic.Value>
|
||||
{stats?.userCount ?? placeholder}
|
||||
</Statistic.Value>
|
||||
<Statistic.Label>{t("Stats.membersJoined")}</Statistic.Label>
|
||||
</Statistic>
|
||||
)}
|
||||
</Statistic.Group>
|
||||
</Segment>
|
||||
|
||||
<Menu widths={3} attached="bottom" size="small">
|
||||
<Menu.Item name="this_month" active={timeframe === 'this_month'} onClick={onClick}>
|
||||
{t('Stats.thisMonth')}
|
||||
<Menu.Item
|
||||
name="this_month"
|
||||
active={timeframe === "this_month"}
|
||||
onClick={onClick}
|
||||
>
|
||||
{t("Stats.thisMonth")}
|
||||
</Menu.Item>
|
||||
<Menu.Item name="this_year" active={timeframe === 'this_year'} onClick={onClick}>
|
||||
{t('Stats.thisYear')}
|
||||
<Menu.Item
|
||||
name="this_year"
|
||||
active={timeframe === "this_year"}
|
||||
onClick={onClick}
|
||||
>
|
||||
{t("Stats.thisYear")}
|
||||
</Menu.Item>
|
||||
<Menu.Item name="all_time" active={timeframe === 'all_time'} onClick={onClick}>
|
||||
{t('Stats.allTime')}
|
||||
<Menu.Item
|
||||
name="all_time"
|
||||
active={timeframe === "all_time"}
|
||||
onClick={onClick}
|
||||
>
|
||||
{t("Stats.allTime")}
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -271,7 +271,7 @@ function TracksTable({ title }) {
|
|||
<Link component={UploadButton} to="/upload" />
|
||||
</div>
|
||||
|
||||
<Header as="h2">{title}</Header>
|
||||
<Header as="h1">{title}</Header>
|
||||
<div style={{ clear: "both" }}>
|
||||
<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 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 { useTranslation } from "react-i18next";
|
||||
|
||||
|
@ -150,8 +150,10 @@ export default function UploadPage() {
|
|||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const title = t("UploadPage.title");
|
||||
return (
|
||||
<Page title="Upload">
|
||||
<Page title={title}>
|
||||
<Header as="h1">{title}</Header>
|
||||
{files.length ? (
|
||||
<Table>
|
||||
<Table.Header>
|
||||
|
|
|
@ -49,11 +49,10 @@ LoginButton:
|
|||
login: Anmelden
|
||||
|
||||
HomePage:
|
||||
stats: Statistik
|
||||
mostRecentTrack: Neueste Fahrt
|
||||
|
||||
Stats:
|
||||
title: Statistik
|
||||
titleUser: Meine Statistik
|
||||
placeholder: "..."
|
||||
totalTrackLength: Gesamtfahrstrecke
|
||||
timeRecorded: Aufzeichnungszeit
|
||||
|
@ -104,6 +103,7 @@ ExportPage:
|
|||
label: Geografischer Bereich
|
||||
|
||||
UploadPage:
|
||||
title: Fahrten hochladen
|
||||
uploadProgress: Lade hoch {{progress}}%
|
||||
processing: Verarbeiten...
|
||||
|
||||
|
@ -212,10 +212,10 @@ MapPage:
|
|||
eventCount: Anzahl Überholungen
|
||||
|
||||
SettingsPage:
|
||||
title: Einstellungen
|
||||
title: Mein Konto
|
||||
|
||||
profile:
|
||||
title: Mein Profil
|
||||
title: Profil
|
||||
publicNotice: Alle Informationen ab hier sind öffentlich.
|
||||
username:
|
||||
label: Kontoname
|
||||
|
@ -231,7 +231,7 @@ SettingsPage:
|
|||
label: Avatar URL
|
||||
|
||||
apiKey:
|
||||
title: Mein API-Schlüssel
|
||||
title: API-Schlüssel
|
||||
description: |
|
||||
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
|
||||
|
@ -258,6 +258,9 @@ SettingsPage:
|
|||
|
||||
generate: Neuen API-Schlüssel erstellen
|
||||
|
||||
stats:
|
||||
title: Statistik
|
||||
|
||||
TrackPage:
|
||||
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.
|
||||
|
|
|
@ -54,11 +54,10 @@ LoginButton:
|
|||
login: Login
|
||||
|
||||
HomePage:
|
||||
stats: Statistics
|
||||
mostRecentTrack: Most recent track
|
||||
|
||||
Stats:
|
||||
title: Statistics
|
||||
titleUser: My Statistic
|
||||
placeholder: "..."
|
||||
totalTrackLength: Total track length
|
||||
timeRecorded: Time recorded
|
||||
|
@ -110,6 +109,7 @@ ExportPage:
|
|||
label: Bounding Box
|
||||
|
||||
UploadPage:
|
||||
title: Upload tracks
|
||||
uploadProgress: Uploading {{progress}}%
|
||||
processing: Processing...
|
||||
|
||||
|
@ -217,10 +217,10 @@ MapPage:
|
|||
eventCount: Event count
|
||||
|
||||
SettingsPage:
|
||||
title: Settings
|
||||
title: My Account
|
||||
|
||||
profile:
|
||||
title: My profile
|
||||
title: Profile
|
||||
publicNotice: All of the information below is public.
|
||||
username:
|
||||
label: Username
|
||||
|
@ -236,7 +236,7 @@ SettingsPage:
|
|||
label: Avatar URL
|
||||
|
||||
apiKey:
|
||||
title: My API Key
|
||||
title: API Key
|
||||
description: |
|
||||
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
|
||||
|
@ -260,6 +260,8 @@ SettingsPage:
|
|||
|
||||
generate: Generate new API key
|
||||
|
||||
stats:
|
||||
title: Statistics
|
||||
|
||||
TrackPage:
|
||||
downloadFailed: Download failed
|
||||
|
|
Loading…
Reference in a new issue