mirror of
https://github.com/LemmyNet/lemmy-ui.git
synced 2025-01-10 20:15:50 +00:00
Settings Import/export (#2223)
* Add UI for import/export of settings * Make settings update after import without requiring manual browser refresh * Address PR feedback * Add translations
This commit is contained in:
parent
9fcd5ef54f
commit
4be7310441
2 changed files with 191 additions and 2 deletions
|
@ -1 +1 @@
|
||||||
Subproject commit b0d80808047eb547aa995e923e9c742e87cd8be7
|
Subproject commit 277e3c335bbb9c00c4a600bd4d655b273393f84a
|
|
@ -17,7 +17,7 @@ import { capitalizeFirstLetter, debounce } from "@utils/helpers";
|
||||||
import { Choice, RouteDataResponse } from "@utils/types";
|
import { Choice, RouteDataResponse } from "@utils/types";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { NoOptionI18nKeys } from "i18next";
|
import { NoOptionI18nKeys } from "i18next";
|
||||||
import { Component, linkEvent } from "inferno";
|
import { Component, createRef, linkEvent } from "inferno";
|
||||||
import {
|
import {
|
||||||
BlockCommunityResponse,
|
BlockCommunityResponse,
|
||||||
BlockInstanceResponse,
|
BlockInstanceResponse,
|
||||||
|
@ -60,6 +60,7 @@ import { CommunityLink } from "../community/community-link";
|
||||||
import { PersonListing } from "./person-listing";
|
import { PersonListing } from "./person-listing";
|
||||||
import { InitialFetchRequest } from "../../interfaces";
|
import { InitialFetchRequest } from "../../interfaces";
|
||||||
import TotpModal from "../common/totp-modal";
|
import TotpModal from "../common/totp-modal";
|
||||||
|
import { LoadingEllipses } from "../common/loading-ellipses";
|
||||||
|
|
||||||
type SettingsData = RouteDataResponse<{
|
type SettingsData = RouteDataResponse<{
|
||||||
instancesRes: GetFederatedInstancesResponse;
|
instancesRes: GetFederatedInstancesResponse;
|
||||||
|
@ -119,6 +120,9 @@ interface SettingsState {
|
||||||
searchInstanceOptions: Choice[];
|
searchInstanceOptions: Choice[];
|
||||||
isIsomorphic: boolean;
|
isIsomorphic: boolean;
|
||||||
show2faModal: boolean;
|
show2faModal: boolean;
|
||||||
|
importSettingsRes: RequestState<any>;
|
||||||
|
exportSettingsRes: RequestState<any>;
|
||||||
|
settingsFile?: File;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FilterType = "user" | "community" | "instance";
|
type FilterType = "user" | "community" | "instance";
|
||||||
|
@ -183,6 +187,8 @@ function handleClose2faModal(i: Settings) {
|
||||||
|
|
||||||
export class Settings extends Component<any, SettingsState> {
|
export class Settings extends Component<any, SettingsState> {
|
||||||
private isoData = setIsoData<SettingsData>(this.context);
|
private isoData = setIsoData<SettingsData>(this.context);
|
||||||
|
exportSettingsLink = createRef<HTMLAnchorElement>();
|
||||||
|
|
||||||
state: SettingsState = {
|
state: SettingsState = {
|
||||||
saveRes: EMPTY_REQUEST,
|
saveRes: EMPTY_REQUEST,
|
||||||
deleteAccountRes: EMPTY_REQUEST,
|
deleteAccountRes: EMPTY_REQUEST,
|
||||||
|
@ -207,6 +213,8 @@ export class Settings extends Component<any, SettingsState> {
|
||||||
generateTotpRes: EMPTY_REQUEST,
|
generateTotpRes: EMPTY_REQUEST,
|
||||||
updateTotpRes: EMPTY_REQUEST,
|
updateTotpRes: EMPTY_REQUEST,
|
||||||
show2faModal: false,
|
show2faModal: false,
|
||||||
|
importSettingsRes: EMPTY_REQUEST,
|
||||||
|
exportSettingsRes: EMPTY_REQUEST,
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(props: any, context: any) {
|
constructor(props: any, context: any) {
|
||||||
|
@ -251,6 +259,7 @@ export class Settings extends Component<any, SettingsState> {
|
||||||
show_read_posts,
|
show_read_posts,
|
||||||
send_notifications_to_email,
|
send_notifications_to_email,
|
||||||
email,
|
email,
|
||||||
|
open_links_in_new_tab,
|
||||||
},
|
},
|
||||||
person: {
|
person: {
|
||||||
avatar,
|
avatar,
|
||||||
|
@ -288,6 +297,7 @@ export class Settings extends Component<any, SettingsState> {
|
||||||
bio,
|
bio,
|
||||||
send_notifications_to_email,
|
send_notifications_to_email,
|
||||||
matrix_user_id,
|
matrix_user_id,
|
||||||
|
open_links_in_new_tab,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -332,8 +342,18 @@ export class Settings extends Component<any, SettingsState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
/* eslint-disable jsx-a11y/anchor-has-content, jsx-a11y/anchor-is-valid */
|
||||||
return (
|
return (
|
||||||
<div className="person-settings container-lg">
|
<div className="person-settings container-lg">
|
||||||
|
<a
|
||||||
|
ref={this.exportSettingsLink}
|
||||||
|
download={`${I18NextService.i18n.t("export_file_name")}_${new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replace(/:|-/g, "")}.json`}
|
||||||
|
className="d-none"
|
||||||
|
href="javascript:void(0)"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
<HtmlTags
|
<HtmlTags
|
||||||
title={this.documentTitle}
|
title={this.documentTitle}
|
||||||
path={this.context.router.route.match.url}
|
path={this.context.router.route.match.url}
|
||||||
|
@ -356,6 +376,7 @@ export class Settings extends Component<any, SettingsState> {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
/* eslint-enable jsx-a11y/anchor-has-content, jsx-a11y/anchor-is-valid */
|
||||||
}
|
}
|
||||||
|
|
||||||
userSettings(isSelected: boolean) {
|
userSettings(isSelected: boolean) {
|
||||||
|
@ -377,6 +398,9 @@ export class Settings extends Component<any, SettingsState> {
|
||||||
<div className="card border-secondary mb-3">
|
<div className="card border-secondary mb-3">
|
||||||
<div className="card-body">{this.changePasswordHtmlForm()}</div>
|
<div className="card-body">{this.changePasswordHtmlForm()}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="card border-secondary mb-3">
|
||||||
|
<div className="card-body">{this.importExport()}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -595,6 +619,58 @@ export class Settings extends Component<any, SettingsState> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
importExport() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className="h5">
|
||||||
|
{I18NextService.i18n.t("import_export_section_title")}
|
||||||
|
</h2>
|
||||||
|
<p>{I18NextService.i18n.t("import_export_section_description")}</p>
|
||||||
|
{!(
|
||||||
|
this.state.importSettingsRes.state === "loading" ||
|
||||||
|
this.state.exportSettingsRes.state === "loading"
|
||||||
|
) ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary w-100 mb-4"
|
||||||
|
onClick={linkEvent(this, this.handleExportSettings)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{I18NextService.i18n.t("export")}
|
||||||
|
</button>
|
||||||
|
<fieldset className="border border-secondary rounded p-3 bg-dark bg-opacity-25">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="application/json"
|
||||||
|
className="form-control"
|
||||||
|
aria-label="Import settings file input"
|
||||||
|
onChange={linkEvent(this, this.handleImportFileChange)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary w-100 mt-3"
|
||||||
|
onClick={linkEvent(this, this.handleImportSettings)}
|
||||||
|
type="button"
|
||||||
|
disabled={!this.state.settingsFile}
|
||||||
|
>
|
||||||
|
{I18NextService.i18n.t("import")}
|
||||||
|
</button>
|
||||||
|
</fieldset>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="text-center">
|
||||||
|
{this.state.exportSettingsRes.state === "loading"
|
||||||
|
? I18NextService.i18n.t("exporting")
|
||||||
|
: I18NextService.i18n.t("importing")}
|
||||||
|
<LoadingEllipses />
|
||||||
|
</div>
|
||||||
|
<Spinner large />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
saveUserSettingsHtmlForm() {
|
saveUserSettingsHtmlForm() {
|
||||||
const selectedLangs = this.state.saveUserSettingsForm.discussion_languages;
|
const selectedLangs = this.state.saveUserSettingsForm.discussion_languages;
|
||||||
|
|
||||||
|
@ -1096,6 +1172,7 @@ export class Settings extends Component<any, SettingsState> {
|
||||||
this.setState({ show2faModal: false });
|
this.setState({ show2faModal: false });
|
||||||
|
|
||||||
const siteRes = await HttpService.client.getSite();
|
const siteRes = await HttpService.client.getSite();
|
||||||
|
|
||||||
UserService.Instance.myUserInfo!.local_user_view.local_user.totp_2fa_enabled =
|
UserService.Instance.myUserInfo!.local_user_view.local_user.totp_2fa_enabled =
|
||||||
enabled;
|
enabled;
|
||||||
|
|
||||||
|
@ -1474,6 +1551,118 @@ export class Settings extends Component<any, SettingsState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleImportFileChange(i: Settings, event) {
|
||||||
|
i.setState({ settingsFile: event.target.files?.item(0) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleExportSettings(i: Settings) {
|
||||||
|
i.setState({ exportSettingsRes: LOADING_REQUEST });
|
||||||
|
const res = await HttpService.client.exportSettings();
|
||||||
|
|
||||||
|
if (res.state === "success") {
|
||||||
|
i.exportSettingsLink.current!.href = encodeURI(
|
||||||
|
`data:application/json,${JSON.stringify(res.data)}`,
|
||||||
|
);
|
||||||
|
i.exportSettingsLink.current?.click();
|
||||||
|
} else if (res.state === "failed") {
|
||||||
|
toast(
|
||||||
|
res.err.message === "rate_limit_error"
|
||||||
|
? I18NextService.i18n.t("import_export_rate_limit_error")
|
||||||
|
: I18NextService.i18n.t("export_error"),
|
||||||
|
"danger",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
i.setState({ exportSettingsRes: EMPTY_REQUEST });
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleImportSettings(i: Settings) {
|
||||||
|
i.setState({ importSettingsRes: LOADING_REQUEST });
|
||||||
|
|
||||||
|
const res = await HttpService.client.importSettings(
|
||||||
|
JSON.parse(await i.state.settingsFile!.text()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.state === "success") {
|
||||||
|
toast(I18NextService.i18n.t("import_success"), "success");
|
||||||
|
|
||||||
|
const saveRes = i.state.saveRes;
|
||||||
|
i.setState({ saveRes: LOADING_REQUEST });
|
||||||
|
|
||||||
|
const siteRes = await HttpService.client.getSite();
|
||||||
|
i.setState({ saveRes });
|
||||||
|
|
||||||
|
if (siteRes.state === "success") {
|
||||||
|
const {
|
||||||
|
local_user: {
|
||||||
|
show_nsfw,
|
||||||
|
blur_nsfw,
|
||||||
|
auto_expand,
|
||||||
|
theme,
|
||||||
|
default_sort_type,
|
||||||
|
default_listing_type,
|
||||||
|
interface_language,
|
||||||
|
show_avatars,
|
||||||
|
show_bot_accounts,
|
||||||
|
show_scores,
|
||||||
|
show_read_posts,
|
||||||
|
send_notifications_to_email,
|
||||||
|
email,
|
||||||
|
open_links_in_new_tab,
|
||||||
|
},
|
||||||
|
person: {
|
||||||
|
avatar,
|
||||||
|
banner,
|
||||||
|
display_name,
|
||||||
|
bot_account,
|
||||||
|
bio,
|
||||||
|
matrix_user_id,
|
||||||
|
},
|
||||||
|
} = siteRes.data.my_user!.local_user_view;
|
||||||
|
|
||||||
|
UserService.Instance.myUserInfo = siteRes.data.my_user;
|
||||||
|
|
||||||
|
i.setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
siteRes: siteRes.data,
|
||||||
|
saveUserSettingsForm: {
|
||||||
|
...prev.saveUserSettingsForm,
|
||||||
|
show_avatars,
|
||||||
|
show_bot_accounts,
|
||||||
|
show_nsfw,
|
||||||
|
teme: theme ?? "browser",
|
||||||
|
avatar,
|
||||||
|
banner,
|
||||||
|
display_name,
|
||||||
|
bio,
|
||||||
|
matrix_user_id,
|
||||||
|
auto_expand,
|
||||||
|
blur_nsfw,
|
||||||
|
bot_account,
|
||||||
|
default_listing_type,
|
||||||
|
default_sort_type,
|
||||||
|
discussion_languages: siteRes.data.my_user?.discussion_languages,
|
||||||
|
email,
|
||||||
|
interface_language,
|
||||||
|
open_links_in_new_tab,
|
||||||
|
send_notifications_to_email,
|
||||||
|
show_read_posts,
|
||||||
|
show_scores,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else if (res.state === "failed") {
|
||||||
|
toast(
|
||||||
|
res.err.message === "rate_limit_error"
|
||||||
|
? I18NextService.i18n.t("import_export_rate_limit_error")
|
||||||
|
: I18NextService.i18n.t("import_error"),
|
||||||
|
"danger",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
i.setState({ importSettingsRes: EMPTY_REQUEST, settingsFile: undefined });
|
||||||
|
}
|
||||||
|
|
||||||
handleDeleteAccountShowConfirmToggle(i: Settings) {
|
handleDeleteAccountShowConfirmToggle(i: Settings) {
|
||||||
i.setState({ deleteAccountShowConfirm: !i.state.deleteAccountShowConfirm });
|
i.setState({ deleteAccountShowConfirm: !i.state.deleteAccountShowConfirm });
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue