Split settings page

This commit is contained in:
Paul Bienkowski 2022-11-27 16:47:12 +01:00
parent 4fe7d45dec
commit 141460c79f
9 changed files with 377 additions and 292 deletions

View file

@ -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>
</>
)
);
}

View file

@ -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} />

View file

@ -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;

View 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;

View 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;

View 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;

View file

@ -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>

View file

@ -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.

View file

@ -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