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

View file

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

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

View file

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

View file

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