diff options
| author | 2023-02-03 12:07:40 +0100 | |
|---|---|---|
| committer | 2023-02-03 12:07:40 +0100 | |
| commit | a59dc855d94b332ca01b4a2477ef94ee68da9fe6 (patch) | |
| tree | 0f8397b591927d317a2400e6f2d7f6ef1ef527db /web/source/settings | |
| parent | [chore] Text formatting overhaul (#1406) (diff) | |
| download | gotosocial-a59dc855d94b332ca01b4a2477ef94ee68da9fe6.tar.xz | |
[feature/frogend] (Mastodon) domain block CSV import (#1390)
* checkbox-list styling with taller <p> element
* CSV import/export, UI/UX improvements to import-export interface
* minor styling tweaks
* csv export, clean up export type branching
* abstract domain block entry validation
* foundation for PSL check + suggestions
* Squashed commit of the following:
commit e3655ba4fbea1d55738b2f9e407d3378af26afe6
Author: f0x <f0x@cthu.lu>
Date:   Tue Jan 31 15:19:10 2023 +0100
    let debug depend on env (prod/debug) again
commit 79c792b832a2b59e472dcdff646bad6d71b42cc9
Author: f0x <f0x@cthu.lu>
Date:   Tue Jan 31 00:34:01 2023 +0100
    update checklist components
commit 4367960fe4be4e3978077af06e63a729d64e32fb
Author: f0x <f0x@cthu.lu>
Date:   Mon Jan 30 23:46:20 2023 +0100
    checklist performance improvements
commit 204a4c02d16ffad189a6e8a6001d5bf4ff95fc4e
Author: f0x <f0x@cthu.lu>
Date:   Mon Jan 30 20:05:34 2023 +0100
    checklist field: use reducer for state
* remove debug logging
* show and use domain block suggestion
* restructure import/export buttons
* updating suggestions
* suggestion overview
* restructure check-list behavior, domain import/export
Diffstat (limited to 'web/source/settings')
| -rw-r--r-- | web/source/settings/admin/emoji/remote/parse-from-toot.js | 21 | ||||
| -rw-r--r-- | web/source/settings/admin/federation/import-export.js | 307 | ||||
| -rw-r--r-- | web/source/settings/admin/federation/import-export/export-format-table.jsx | 64 | ||||
| -rw-r--r-- | web/source/settings/admin/federation/import-export/form.jsx | 123 | ||||
| -rw-r--r-- | web/source/settings/admin/federation/import-export/index.jsx | 78 | ||||
| -rw-r--r-- | web/source/settings/admin/federation/import-export/process.jsx | 327 | ||||
| -rw-r--r-- | web/source/settings/components/check-list.jsx | 82 | ||||
| -rw-r--r-- | web/source/settings/components/form/inputs.jsx | 3 | ||||
| -rw-r--r-- | web/source/settings/lib/domain-block.js | 50 | ||||
| -rw-r--r-- | web/source/settings/lib/form/check-list.jsx | 249 | ||||
| -rw-r--r-- | web/source/settings/lib/form/text.jsx | 31 | ||||
| -rw-r--r-- | web/source/settings/lib/query/admin/import-export.js | 109 | ||||
| -rw-r--r-- | web/source/settings/style.css | 94 | 
13 files changed, 1054 insertions, 484 deletions
| diff --git a/web/source/settings/admin/emoji/remote/parse-from-toot.js b/web/source/settings/admin/emoji/remote/parse-from-toot.js index 309619ea4..84bbbdc92 100644 --- a/web/source/settings/admin/emoji/remote/parse-from-toot.js +++ b/web/source/settings/admin/emoji/remote/parse-from-toot.js @@ -129,14 +129,16 @@ function CopyEmojiForm({ localEmojiCodes, type, emojiList }) {  			title: "No emoji selected, cannot perform any actions"  		}; +	const checkListExtraProps = React.useCallback(() => ({ localEmojiCodes }), [localEmojiCodes]); +  	return (  		<div className="parsed">  			<span>This {type == "statuses" ? "toot" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span>  			<form onSubmit={formSubmit}>  				<CheckList  					field={form.selectedEmoji} -					Component={EmojiEntry} -					localEmojiCodes={localEmojiCodes} +					EntryComponent={EmojiEntry} +					getExtraProps={checkListExtraProps}  				/>  				<CategorySelect @@ -170,7 +172,7 @@ function ErrorList({ errors }) {  	);  } -function EmojiEntry({ entry: emoji, localEmojiCodes, onChange }) { +function EmojiEntry({ entry: emoji, onChange, extraProps: { localEmojiCodes } }) {  	const shortcodeField = useTextInput("shortcode", {  		defaultValue: emoji.shortcode,  		validator: function validateShortcode(code) { @@ -181,9 +183,16 @@ function EmojiEntry({ entry: emoji, localEmojiCodes, onChange }) {  	});  	React.useEffect(() => { -		onChange({ valid: shortcodeField.valid }); -		/* eslint-disable-next-line react-hooks/exhaustive-deps */ -	}, [shortcodeField.valid]); +		if (emoji.valid != shortcodeField.valid) { +			onChange({ valid: shortcodeField.valid }); +		} +	}, [onChange, emoji.valid, shortcodeField.valid]); + +	React.useEffect(() => { +		shortcodeField.validate(); +		// only need this update if it's the emoji.checked that updated, not shortcodeField +		// eslint-disable-next-line react-hooks/exhaustive-deps +	}, [emoji.checked]);  	return (  		<> diff --git a/web/source/settings/admin/federation/import-export.js b/web/source/settings/admin/federation/import-export.js deleted file mode 100644 index a11174b1f..000000000 --- a/web/source/settings/admin/federation/import-export.js +++ /dev/null @@ -1,307 +0,0 @@ -/* -	GoToSocial -	Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org - -	This program is free software: you can redistribute it and/or modify -	it under the terms of the GNU Affero General Public License as published by -	the Free Software Foundation, either version 3 of the License, or -	(at your option) any later version. - -	This program is distributed in the hope that it will be useful, -	but WITHOUT ANY WARRANTY; without even the implied warranty of -	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the -	GNU Affero General Public License for more details. - -	You should have received a copy of the GNU Affero General Public License -	along with this program.  If not, see <http://www.gnu.org/licenses/>. -*/ - -"use strict"; - -const React = require("react"); -const { Switch, Route, Redirect, useLocation } = require("wouter"); - -const query = require("../../lib/query"); - -const { -	useTextInput, -	useBoolInput, -	useRadioInput, -	useCheckListInput -} = require("../../lib/form"); - -const useFormSubmit = require("../../lib/form/submit"); - -const { -	TextInput, -	TextArea, -	Checkbox, -	Select, -	RadioGroup -} = require("../../components/form/inputs"); - -const CheckList = require("../../components/check-list"); -const MutationButton = require("../../components/form/mutation-button"); -const isValidDomain = require("is-valid-domain"); -const FormWithData = require("../../lib/form/form-with-data"); -const { Error } = require("../../components/error"); - -const baseUrl = "/settings/admin/federation/import-export"; - -module.exports = function ImportExport() { -	const [updateFromFile, setUpdateFromFile] = React.useState(false); -	const form = { -		domains: useTextInput("domains"), -		exportType: useTextInput("exportType", { defaultValue: "plain", dontReset: true }) -	}; - -	const [submitParse, parseResult] = useFormSubmit(form, query.useProcessDomainListMutation()); -	const [submitExport, exportResult] = useFormSubmit(form, query.useExportDomainListMutation()); - -	function fileChanged(e) { -		const reader = new FileReader(); -		reader.onload = function (read) { -			form.domains.setter(read.target.result); -			setUpdateFromFile(true); -		}; -		reader.readAsText(e.target.files[0]); -	} - -	React.useEffect(() => { -		if (exportResult.isSuccess) { -			form.domains.setter(exportResult.data); -		} -		/* eslint-disable-next-line react-hooks/exhaustive-deps */ -	}, [exportResult]); - -	const [_location, setLocation] = useLocation(); - -	if (updateFromFile) { -		setUpdateFromFile(false); -		submitParse(); -	} - -	return ( -		<Switch> -			<Route path={`${baseUrl}/list`}> -				{!parseResult.isSuccess && <Redirect to={baseUrl} />} - -				<h1> -					<span className="button" onClick={() => { -						parseResult.reset(); -						setLocation(baseUrl); -					}}> -						< back -					</span> Confirm import: -				</h1> -				<FormWithData -					dataQuery={query.useInstanceBlocksQuery} -					DataForm={ImportList} -					list={parseResult.data} -				/> -			</Route> - -			<Route> -				{parseResult.isSuccess && <Redirect to={`${baseUrl}/list`} />} -				<h2>Import / Export suspended domains</h2> - -				<div> -					<form onSubmit={submitParse}> -						<TextArea -							field={form.domains} -							label="Domains, one per line (plaintext) or JSON" -							placeholder={`google.com\nfacebook.com`} -							rows={8} -						/> - -						<div className="row"> -							<MutationButton label="Import" result={parseResult} showError={false} /> -							<button type="button" className="with-padding"> -								<label> -									Import file -									<input className="hidden" type="file" onChange={fileChanged} accept="application/json,text/plain" /> -								</label> -							</button> -						</div> -					</form> -					<form onSubmit={submitExport}> -						<div className="row"> -							<MutationButton name="export" label="Export" result={exportResult} showError={false} /> -							<MutationButton name="export-file" label="Export file" result={exportResult} showError={false} /> -							<Select -								field={form.exportType} -								options={<> -									<option value="plain">Text</option> -									<option value="json">JSON</option> -								</>} -							/> -						</div> -					</form> -					{parseResult.error && <Error error={parseResult.error} />} -					{exportResult.error && <Error error={exportResult.error} />} -				</div> -			</Route> -		</Switch> -	); -}; - -function ImportList({ list, data: blockedInstances }) { -	const hasComment = React.useMemo(() => { -		let hasPublic = false; -		let hasPrivate = false; - -		list.some((entry) => { -			if (entry.public_comment?.length > 0) { -				hasPublic = true; -			} - -			if (entry.private_comment?.length > 0) { -				hasPrivate = true; -			} - -			return hasPublic && hasPrivate; -		}); - -		if (hasPublic && hasPrivate) { -			return { both: true }; -		} else if (hasPublic) { -			return { type: "public_comment" }; -		} else if (hasPrivate) { -			return { type: "private_comment" }; -		} else { -			return {}; -		} -	}, [list]); - -	const showComment = useTextInput("showComment", { defaultValue: hasComment.type ?? "public_comment" }); -	let commentName = ""; -	if (showComment.value == "public_comment") { commentName = "Public comment"; } -	if (showComment.value == "private_comment") { commentName = "Private comment"; } - -	const form = { -		domains: useCheckListInput("domains", { -			entries: list, -			uniqueKey: "domain" -		}), -		obfuscate: useBoolInput("obfuscate"), -		privateComment: useTextInput("private_comment", { -			defaultValue: `Imported on ${new Date().toLocaleString()}` -		}), -		privateCommentBehavior: useRadioInput("private_comment_behavior", { -			defaultValue: "append", -			options: { -				append: "Append to", -				replace: "Replace" -			} -		}), -		publicComment: useTextInput("public_comment"), -		publicCommentBehavior: useRadioInput("public_comment_behavior", { -			defaultValue: "append", -			options: { -				append: "Append to", -				replace: "Replace" -			} -		}), -	}; - -	const [importDomains, importResult] = useFormSubmit(form, query.useImportDomainListMutation(), { changedOnly: false }); - -	return ( -		<> -			<form onSubmit={importDomains} className="suspend-import-list"> -				<span>{list.length} domain{list.length != 1 ? "s" : ""} in this list</span> - -				{hasComment.both && -					<Select field={showComment} options={ -						<> -							<option value="public_comment">Show public comments</option> -							<option value="private_comment">Show private comments</option> -						</> -					} /> -				} - -				<CheckList -					field={form.domains} -					Component={DomainEntry} -					header={ -						<> -							<b>Domain</b> -							<b></b> -							<b>{commentName}</b> -						</> -					} -					blockedInstances={blockedInstances} -					commentType={showComment.value} -				/> - -				<TextArea -					field={form.privateComment} -					label="Private comment" -					rows={3} -				/> -				<RadioGroup -					field={form.privateCommentBehavior} -					label="imported private comment" -				/> - -				<TextArea -					field={form.publicComment} -					label="Public comment" -					rows={3} -				/> -				<RadioGroup -					field={form.publicCommentBehavior} -					label="imported public comment" -				/> - -				<Checkbox -					field={form.obfuscate} -					label="Obfuscate domains in public lists" -				/> - -				<MutationButton label="Import" result={importResult} /> -			</form> -		</> -	); -} - -function DomainEntry({ entry, onChange, blockedInstances, commentType }) { -	const domainField = useTextInput("domain", { -		defaultValue: entry.domain, -		validator: (value) => { -			return (entry.checked && !isValidDomain(value, { wildcard: true, allowUnicode: true })) -				? "Invalid domain" -				: ""; -		} -	}); - -	React.useEffect(() => { -		onChange({ valid: domainField.valid }); -		/* eslint-disable-next-line react-hooks/exhaustive-deps */ -	}, [domainField.valid]); - -	let icon = null; - -	if (blockedInstances[domainField.value] != undefined) { -		icon = ( -			<> -				<i className="fa fa-history already-blocked" aria-hidden="true" title="Domain block already exists"></i> -				<span className="sr-only">Domain block already exists.</span> -			</> -		); -	} - -	return ( -		<> -			<TextInput -				field={domainField} -				onChange={(e) => { -					domainField.onChange(e); -					onChange({ domain: e.target.value, checked: true }); -				}} -			/> -			<span id="icon">{icon}</span> -			<p>{entry[commentType]}</p> -		</> -	); -}
\ No newline at end of file diff --git a/web/source/settings/admin/federation/import-export/export-format-table.jsx b/web/source/settings/admin/federation/import-export/export-format-table.jsx new file mode 100644 index 000000000..72b2db04f --- /dev/null +++ b/web/source/settings/admin/federation/import-export/export-format-table.jsx @@ -0,0 +1,64 @@ +/* +	GoToSocial +	Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org + +	This program is free software: you can redistribute it and/or modify +	it under the terms of the GNU Affero General Public License as published by +	the Free Software Foundation, either version 3 of the License, or +	(at your option) any later version. + +	This program is distributed in the hope that it will be useful, +	but WITHOUT ANY WARRANTY; without even the implied warranty of +	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +	GNU Affero General Public License for more details. + +	You should have received a copy of the GNU Affero General Public License +	along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +"use strict"; + +const React = require("react"); + +module.exports = function ExportFormatTable() { +	return ( +		<table className="export-format-table"> +			<thead> +				<tr> +					<th rowSpan={2} /> +					<th colSpan={2}>Includes</th> +					<th colSpan={2}>Importable by</th> +				</tr> +				<tr> +					<th>Domain</th> +					<th>Public comment</th> +					<th>GoToSocial</th> +					<th>Mastodon</th> +				</tr> +			</thead> +			<tbody> +				<Format name="Text" info={[true, false, true, false]} /> +				<Format name="JSON" info={[true, true, true, false]} /> +				<Format name="CSV" info={[true, true, true, true]} /> +			</tbody> +		</table> +	); +}; + +function Format({ name, info }) { +	return ( +		<tr> +			<td><b>{name}</b></td> +			{info.map((b, key) => <td key={key} className="bool">{bool(b)}</td>)} +		</tr> +	); +} + +function bool(val) { +	return ( +		<> +			<i className={`fa fa-${val ? "check" : "times"}`} aria-hidden="true"></i> +			<span className="sr-only">{val ? "Yes" : "No"}</span> +		</> +	); +}
\ No newline at end of file diff --git a/web/source/settings/admin/federation/import-export/form.jsx b/web/source/settings/admin/federation/import-export/form.jsx new file mode 100644 index 000000000..afd2d775d --- /dev/null +++ b/web/source/settings/admin/federation/import-export/form.jsx @@ -0,0 +1,123 @@ +/* +	GoToSocial +	Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org + +	This program is free software: you can redistribute it and/or modify +	it under the terms of the GNU Affero General Public License as published by +	the Free Software Foundation, either version 3 of the License, or +	(at your option) any later version. + +	This program is distributed in the hope that it will be useful, +	but WITHOUT ANY WARRANTY; without even the implied warranty of +	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +	GNU Affero General Public License for more details. + +	You should have received a copy of the GNU Affero General Public License +	along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +"use strict"; + +const React = require("react"); + +const query = require("../../../lib/query"); +const useFormSubmit = require("../../../lib/form/submit"); + +const { +	TextArea, +	Select, +} = require("../../../components/form/inputs"); + +const MutationButton = require("../../../components/form/mutation-button"); + +const { Error } = require("../../../components/error"); +const ExportFormatTable = require("./export-format-table"); + +module.exports = function ImportExportForm({ form, submitParse, parseResult }) { +	const [submitExport, exportResult] = useFormSubmit(form, query.useExportDomainListMutation()); + +	const [updateFromFile, setUpdateFromFile] = React.useState(false); + +	function fileChanged(e) { +		const reader = new FileReader(); +		reader.onload = function (read) { +			form.domains.setter(read.target.result); +			setUpdateFromFile(true); +		}; +		reader.readAsText(e.target.files[0]); +	} + +	React.useEffect(() => { +		if (exportResult.isSuccess) { +			form.domains.setter(exportResult.data); +		} +		/* eslint-disable-next-line react-hooks/exhaustive-deps */ +	}, [exportResult]); + +	if (updateFromFile) { +		setUpdateFromFile(false); +		submitParse(); +	} +	return ( +		<> +			<h1>Import / Export suspended domains</h1> +			<p> +				This page can be used to import and export lists of domains to suspend. +				Exports can be done in various formats, with varying functionality and support in other software. +				Imports will automatically detect what format is being processed. +			</p> +			<ExportFormatTable /> +			<div className="import-export"> +				<TextArea +					field={form.domains} +					label="Domains" +					placeholder={`google.com\nfacebook.com`} +					rows={8} +				/> + +				<div className="button-grid"> +					<MutationButton +						label="Import" +						type="button" +						onClick={() => submitParse()} +						result={parseResult} +						showError={false} +					/> +					<label className="button"> +						Import file +						<input +							type="file" +							className="hidden" +							onChange={fileChanged} +							accept="application/json,text/plain,text/csv" +						/> +					</label> +					<b /> {/* grid filler */} +					<MutationButton +						label="Export" +						type="button" +						onClick={() => submitExport("export")} +						result={exportResult} showError={false} +					/> +					<MutationButton label="Export to file" type="button" onClick={() => submitExport("export-file")} result={exportResult} showError={false} /> +					<div className="export-file"> +						<span> +							as +						</span> +						<Select +							field={form.exportType} +							options={<> +								<option value="plain">Text</option> +								<option value="json">JSON</option> +								<option value="csv">CSV</option> +							</>} +						/> +					</div> +				</div> + +				{parseResult.error && <Error error={parseResult.error} />} +				{exportResult.error && <Error error={exportResult.error} />} +			</div> +		</> +	); +};
\ No newline at end of file diff --git a/web/source/settings/admin/federation/import-export/index.jsx b/web/source/settings/admin/federation/import-export/index.jsx new file mode 100644 index 000000000..3039b98f3 --- /dev/null +++ b/web/source/settings/admin/federation/import-export/index.jsx @@ -0,0 +1,78 @@ +/* +	GoToSocial +	Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org + +	This program is free software: you can redistribute it and/or modify +	it under the terms of the GNU Affero General Public License as published by +	the Free Software Foundation, either version 3 of the License, or +	(at your option) any later version. + +	This program is distributed in the hope that it will be useful, +	but WITHOUT ANY WARRANTY; without even the implied warranty of +	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +	GNU Affero General Public License for more details. + +	You should have received a copy of the GNU Affero General Public License +	along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +"use strict"; + +const React = require("react"); +const { Switch, Route, Redirect, useLocation } = require("wouter"); + +const query = require("../../../lib/query"); + +const { +	useTextInput, +} = require("../../../lib/form"); + +const useFormSubmit = require("../../../lib/form/submit"); + +const ProcessImport = require("./process"); +const ImportExportForm = require("./form"); + +const baseUrl = "/settings/admin/federation/import-export"; + +module.exports = function ImportExport() { +	const form = { +		domains: useTextInput("domains"), +		exportType: useTextInput("exportType", { defaultValue: "plain", dontReset: true }) +	}; + +	const [submitParse, parseResult] = useFormSubmit(form, query.useProcessDomainListMutation()); + +	const [_location, setLocation] = useLocation(); + +	return ( +		<Switch> +			<Route path={`${baseUrl}/process`}> +				{parseResult.isSuccess ? ( +					<> +						<h1> +							<span className="button" onClick={() => { +								parseResult.reset(); +								setLocation(baseUrl); +							}}> +								< back +							</span> Confirm import: +						</h1> +						<ProcessImport +							list={parseResult.data} +						/> +					</> +				) : <Redirect to={baseUrl} />} +			</Route> + +			<Route> +				{!parseResult.isSuccess ? ( +					<ImportExportForm +						form={form} +						submitParse={submitParse} +						parseResult={parseResult} +					/> +				) : <Redirect to={`${baseUrl}/process`} />} +			</Route> +		</Switch> +	); +};
\ No newline at end of file diff --git a/web/source/settings/admin/federation/import-export/process.jsx b/web/source/settings/admin/federation/import-export/process.jsx new file mode 100644 index 000000000..0b2d10099 --- /dev/null +++ b/web/source/settings/admin/federation/import-export/process.jsx @@ -0,0 +1,327 @@ +/* +	GoToSocial +	Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org + +	This program is free software: you can redistribute it and/or modify +	it under the terms of the GNU Affero General Public License as published by +	the Free Software Foundation, either version 3 of the License, or +	(at your option) any later version. + +	This program is distributed in the hope that it will be useful, +	but WITHOUT ANY WARRANTY; without even the implied warranty of +	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +	GNU Affero General Public License for more details. + +	You should have received a copy of the GNU Affero General Public License +	along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +"use strict"; + +const React = require("react"); + +const query = require("../../../lib/query"); +const { isValidDomainBlock, hasBetterScope } = require("../../../lib/domain-block"); + +const { +	useTextInput, +	useBoolInput, +	useRadioInput, +	useCheckListInput +} = require("../../../lib/form"); + +const useFormSubmit = require("../../../lib/form/submit"); + +const { +	TextInput, +	TextArea, +	Checkbox, +	Select, +	RadioGroup +} = require("../../../components/form/inputs"); + +const CheckList = require("../../../components/check-list"); +const MutationButton = require("../../../components/form/mutation-button"); +const FormWithData = require("../../../lib/form/form-with-data"); + +module.exports = React.memo( +	function ProcessImport({ list }) { +		return ( +			<div className="without-border"> +				<FormWithData +					dataQuery={query.useInstanceBlocksQuery} +					DataForm={ImportList} +					list={list} +				/> +			</div> +		); +	} +); + +function ImportList({ list, data: blockedInstances }) { +	const hasComment = React.useMemo(() => { +		let hasPublic = false; +		let hasPrivate = false; + +		list.some((entry) => { +			if (entry.public_comment?.length > 0) { +				hasPublic = true; +			} + +			if (entry.private_comment?.length > 0) { +				hasPrivate = true; +			} + +			return hasPublic && hasPrivate; +		}); + +		if (hasPublic && hasPrivate) { +			return { both: true }; +		} else if (hasPublic) { +			return { type: "public_comment" }; +		} else if (hasPrivate) { +			return { type: "private_comment" }; +		} else { +			return {}; +		} +	}, [list]); + +	const showComment = useTextInput("showComment", { defaultValue: hasComment.type ?? "public_comment" }); + +	const form = { +		domains: useCheckListInput("domains", { entries: list }), +		obfuscate: useBoolInput("obfuscate"), +		privateComment: useTextInput("private_comment", { +			defaultValue: `Imported on ${new Date().toLocaleString()}` +		}), +		privateCommentBehavior: useRadioInput("private_comment_behavior", { +			defaultValue: "append", +			options: { +				append: "Append to", +				replace: "Replace" +			} +		}), +		publicComment: useTextInput("public_comment"), +		publicCommentBehavior: useRadioInput("public_comment_behavior", { +			defaultValue: "append", +			options: { +				append: "Append to", +				replace: "Replace" +			} +		}), +	}; + +	const [importDomains, importResult] = useFormSubmit(form, query.useImportDomainListMutation(), { changedOnly: false }); + +	return ( +		<> +			<form onSubmit={importDomains} className="suspend-import-list"> +				<span>{list.length} domain{list.length != 1 ? "s" : ""} in this list</span> + +				{hasComment.both && +					<Select field={showComment} options={ +						<> +							<option value="public_comment">Show public comments</option> +							<option value="private_comment">Show private comments</option> +						</> +					} /> +				} + +				<DomainCheckList +					field={form.domains} +					blockedInstances={blockedInstances} +					commentType={showComment.value} +				/> + +				<TextArea +					field={form.privateComment} +					label="Private comment" +					rows={3} +				/> +				<RadioGroup +					field={form.privateCommentBehavior} +					label="imported private comment" +				/> + +				<TextArea +					field={form.publicComment} +					label="Public comment" +					rows={3} +				/> +				<RadioGroup +					field={form.publicCommentBehavior} +					label="imported public comment" +				/> + +				<Checkbox +					field={form.obfuscate} +					label="Obfuscate domains in public lists" +				/> + +				<MutationButton label="Import" result={importResult} /> +			</form> +		</> +	); +} + +function DomainCheckList({ field, blockedInstances, commentType }) { +	const getExtraProps = React.useCallback((entry) => { +		return { +			comment: entry[commentType], +			alreadyExists: blockedInstances[entry.domain] != undefined +		}; +	}, [blockedInstances, commentType]); + +	const entriesWithSuggestions = React.useMemo(() => ( +		Object.values(field.value).filter((entry) => entry.suggest) +	), [field.value]); + +	return ( +		<> +			<CheckList +				field={field} +				header={<> +					<b>Domain</b> +					<b></b> +					<b> +						{commentType == "public_comment" && "Public comment"} +						{commentType == "private_comment" && "Private comment"} +					</b> +				</>} +				EntryComponent={DomainEntry} +				getExtraProps={getExtraProps} +			/> +			<UpdateHint +				entries={entriesWithSuggestions} +				updateEntry={field.onChange} +				updateMultiple={field.updateMultiple} +			/> +		</> +	); +} + +const UpdateHint = React.memo( +	function UpdateHint({ entries, updateEntry, updateMultiple }) { +		if (entries.length == 0) { +			return null; +		} + +		function changeAll() { +			updateMultiple( +				entries.map((entry) => [entry.key, { domain: entry.suggest, suggest: null }]) +			); +		} + +		return ( +			<div className="update-hints"> +				<p> +					{entries.length} {entries.length == 1 ? "entry uses" : "entries use"} a specific subdomain, +					which you might want to change to the main domain, as that includes all it's (future) subdomains. +				</p> +				<div className="hints"> +					{entries.map((entry) => ( +						<UpdateableEntry key={entry.key} entry={entry} updateEntry={updateEntry} /> +					))} +				</div> +				{entries.length > 0 && <a onClick={changeAll}>change all</a>} +			</div> +		); +	} +); + +const UpdateableEntry = React.memo( +	function UpdateableEntry({ entry, updateEntry }) { +		return ( +			<> +				<span className="text-cutoff">{entry.domain}</span> +				<i class="fa fa-long-arrow-right" aria-hidden="true"></i> +				<span>{entry.suggest}</span> +				<a role="button" onClick={() => +					updateEntry(entry.key, { domain: entry.suggest, suggest: null }) +				}>change</a> +			</> +		); +	} +); + +function domainValidationError(isValid) { +	return isValid ? "" : "Invalid domain"; +} + +function DomainEntry({ entry, onChange, extraProps: { alreadyExists, comment } }) { +	const domainField = useTextInput("domain", { +		defaultValue: entry.domain, +		showValidation: entry.checked, +		initValidation: domainValidationError(entry.valid), +		validator: (value) => domainValidationError(isValidDomainBlock(value)) +	}); + +	React.useEffect(() => { +		if (entry.valid != domainField.valid) { +			onChange({ valid: domainField.valid }); +		} +	}, [onChange, entry.valid, domainField.valid]); + +	React.useEffect(() => { +		if (entry.domain != domainField.value) { +			domainField.setter(entry.domain); +		} +		// domainField.setter is enough, eslint wants domainField +		// eslint-disable-next-line react-hooks/exhaustive-deps +	}, [entry.domain, domainField.setter]); + +	React.useEffect(() => { +		onChange({ suggest: hasBetterScope(domainField.value) }); +		// only need this update if it's the entry.checked that updated, not onChange +		// eslint-disable-next-line react-hooks/exhaustive-deps +	}, [domainField.value]); + +	function clickIcon(e) { +		if (entry.suggest) { +			e.stopPropagation(); +			e.preventDefault(); +			domainField.setter(entry.suggest); +			onChange({ domain: entry.suggest, checked: true }); +		} +	} + +	return ( +		<> +			<TextInput +				field={domainField} +				onChange={(e) => { +					domainField.onChange(e); +					onChange({ domain: e.target.value, checked: true }); +				}} +			/> +			<span id="icon" onClick={clickIcon}> +				<DomainEntryIcon alreadyExists={alreadyExists} suggestion={entry.suggest} onChange={onChange} /> +			</span> +			<p>{comment}</p> +		</> +	); +} + +function DomainEntryIcon({ alreadyExists, suggestion }) { +	let icon; +	let text; + +	if (suggestion) { +		icon = "fa-info-circle suggest-changes"; +		text = `Entry targets a specific subdomain, consider changing it to '${suggestion}'.`; +	} else if (alreadyExists) { +		icon = "fa-history already-blocked"; +		text = "Domain block already exists."; +	} + +	if (!icon) { +		return null; +	} + +	return ( +		<> +			<i className={`fa ${icon}`} aria-hidden="true" title={text}></i> +			<span className="sr-only">{text}</span> +		</> +	); +}
\ No newline at end of file diff --git a/web/source/settings/components/check-list.jsx b/web/source/settings/components/check-list.jsx index 1276d5dbf..569cd7470 100644 --- a/web/source/settings/components/check-list.jsx +++ b/web/source/settings/components/check-list.jsx @@ -20,39 +20,71 @@  const React = require("react"); -module.exports = function CheckList({ field, Component, header = " All", ...componentProps }) { +module.exports = function CheckList({ field, header = "All", EntryComponent, getExtraProps }) {  	return (  		<div className="checkbox-list list"> -			<label className="header"> -				<input -					ref={field.toggleAll.ref} -					type="checkbox" -					onChange={field.toggleAll.onChange} -					checked={field.toggleAll.value === 1} -				/> {header} -			</label> -			{Object.values(field.value).map((entry) => ( -				<CheckListEntry -					key={entry.key} -					onChange={(value) => field.onChange(entry.key, value)} -					entry={entry} -					Component={Component} -					componentProps={componentProps} -				/> -			))} +			<CheckListHeader toggleAll={field.toggleAll}>	{header}</CheckListHeader> +			<CheckListEntries +				entries={field.value} +				updateValue={field.onChange} +				EntryComponent={EntryComponent} +				getExtraProps={getExtraProps} +			/>  		</div>  	);  }; -function CheckListEntry({ entry, onChange, Component, componentProps }) { +function CheckListHeader({ toggleAll, children }) {  	return ( -		<label className="entry"> +		<label className="header entry">  			<input +				ref={toggleAll.ref}  				type="checkbox" -				onChange={(e) => onChange({ checked: e.target.checked })} -				checked={entry.checked} -			/> -			<Component entry={entry} onChange={onChange} {...componentProps} /> +				onChange={toggleAll.onChange} +			/> {children}  		</label>  	); -}
\ No newline at end of file +} + +const CheckListEntries = React.memo( +	function CheckListEntries({ entries, updateValue, EntryComponent, getExtraProps }) { +		const deferredEntries = React.useDeferredValue(entries); + +		return Object.values(deferredEntries).map((entry) => ( +			<CheckListEntry +				key={entry.key} +				entry={entry} +				updateValue={updateValue} +				EntryComponent={EntryComponent} +				getExtraProps={getExtraProps} +			/> +		)); +	} +); + +/* +	React.memo is a performance optimization that only re-renders a CheckListEntry +	when it's props actually change, instead of every time anything +	in the list (CheckListEntries) updates +*/ +const CheckListEntry = React.memo( +	function CheckListEntry({ entry, updateValue, getExtraProps, EntryComponent }) { +		const onChange = React.useCallback( +			(value) => updateValue(entry.key, value), +			[updateValue, entry.key] +		); + +		const extraProps = React.useMemo(() => getExtraProps?.(entry), [getExtraProps, entry]); + +		return ( +			<label className="entry"> +				<input +					type="checkbox" +					onChange={(e) => onChange({ checked: e.target.checked })} +					checked={entry.checked} +				/> +				<EntryComponent entry={entry} onChange={onChange} extraProps={extraProps} /> +			</label> +		); +	} +);
\ No newline at end of file diff --git a/web/source/settings/components/form/inputs.jsx b/web/source/settings/components/form/inputs.jsx index eef375ee8..19386b6f2 100644 --- a/web/source/settings/components/form/inputs.jsx +++ b/web/source/settings/components/form/inputs.jsx @@ -22,9 +22,10 @@ const React = require("react");  function TextInput({ label, field, ...inputProps }) {  	const { onChange, value, ref } = field; +	console.log(field.name, field.valid, field.value);  	return ( -		<div className="form-field text"> +		<div className={`form-field text${field.valid ? "" : " invalid"}`}>  			<label>  				{label}  				<input diff --git a/web/source/settings/lib/domain-block.js b/web/source/settings/lib/domain-block.js new file mode 100644 index 000000000..d13c029d2 --- /dev/null +++ b/web/source/settings/lib/domain-block.js @@ -0,0 +1,50 @@ +/* +	GoToSocial +	Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org + +	This program is free software: you can redistribute it and/or modify +	it under the terms of the GNU Affero General Public License as published by +	the Free Software Foundation, either version 3 of the License, or +	(at your option) any later version. + +	This program is distributed in the hope that it will be useful, +	but WITHOUT ANY WARRANTY; without even the implied warranty of +	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +	GNU Affero General Public License for more details. + +	You should have received a copy of the GNU Affero General Public License +	along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +"use strict"; + +const isValidDomain = require("is-valid-domain"); +const psl = require("psl"); + +function isValidDomainBlock(domain) { +	return isValidDomain(domain, { +		/*  +			Wildcard prefix *. can be stripped since it's equivalent to not having it, +			but wildcard anywhere else in the domain is not handled by the backend so it's invalid. +		*/ +		wildcard: false, +		allowUnicode: true +	}); +} + +/*  +	Still can't think of a better function name for this, +	but we're checking a domain against the Public Suffix List <https://publicsuffix.org/> +	to see if we should suggest removing subdomain(s) since they're likely owned/ran by the same party +	social.example.com -> suggests example.com +*/ +function hasBetterScope(domain) { +	const lookup = psl.get(domain); +	if (lookup && lookup != domain) { +		return lookup; +	} else { +		return false; +	} +} + +module.exports = { isValidDomainBlock, hasBetterScope };
\ No newline at end of file diff --git a/web/source/settings/lib/form/check-list.jsx b/web/source/settings/lib/form/check-list.jsx index c1233273d..b19e17a29 100644 --- a/web/source/settings/lib/form/check-list.jsx +++ b/web/source/settings/lib/form/check-list.jsx @@ -20,128 +20,163 @@  const React = require("react");  const syncpipe = require("syncpipe"); - -function createState(entries, uniqueKey, oldState, defaultValue) { -	return syncpipe(entries, [ -		(_) => _.map((entry) => { -			let key = entry[uniqueKey]; -			return [ -				key, -				{ -					...entry, -					key, -					checked: oldState[key]?.checked ?? entry.checked ?? defaultValue +const { createSlice } = require("@reduxjs/toolkit"); +const { enableMapSet } = require("immer"); + +enableMapSet(); // for use in reducers + +const { reducer, actions } = createSlice({ +	name: "checklist", +	initialState: {}, // not handled by slice itself +	reducers: { +		updateAll: (state, { payload: checked }) => { +			const selectedEntries = new Set(); +			return { +				entries: syncpipe(state.entries, [ +					(_) => Object.values(_), +					(_) => _.map((entry) => { +						if (checked) { +							selectedEntries.add(entry.key); +						} +						return [entry.key, { +							...entry, +							checked +						}]; +					}), +					(_) => Object.fromEntries(_) +				]), +				selectedEntries +			}; +		}, +		update: (state, { payload: { key, value } }) => { +			if (value.checked !== undefined) { +				if (value.checked === true) { +					state.selectedEntries.add(key); +				} else { +					state.selectedEntries.delete(key); +				} +			} + +			state.entries[key] = { +				...state.entries[key], +				...value +			}; +		}, +		updateMultiple: (state, { payload }) => { +			payload.forEach(([key, value]) => { +				if (value.checked !== undefined) { +					if (value.checked === true) { +						state.selectedEntries.add(key); +					} else { +						state.selectedEntries.delete(key); +					}  				} -			]; -		}), -		(_) => Object.fromEntries(_) -	]); -} -function updateAllState(state, newValue) { -	return syncpipe(state, [ -		(_) => Object.values(_), -		(_) => _.map((entry) => [entry.key, { -			...entry, -			checked: newValue -		}]), -		(_) => Object.fromEntries(_) -	]); -} +				state.entries[key] = { +					...state.entries[key], +					...value +				}; +			}); +		} +	} +}); -function updateState(state, key, newValue) { +function initialState({ entries, uniqueKey, defaultValue }) { +	const selectedEntries = new Set();  	return { -		...state, -		[key]: { -			...state[key], -			...newValue -		} +		entries: syncpipe(entries, [ +			(_) => _.map((entry) => { +				let key = entry[uniqueKey]; +				let checked = entry.checked ?? defaultValue; + +				if (checked) { +					selectedEntries.add(key); +				} else { +					selectedEntries.delete(key); +				} + +				return [ +					key, +					{ +						...entry, +						key, +						checked +					} +				]; +			}), +			(_) => Object.fromEntries(_) +		]), +		selectedEntries  	};  }  module.exports = function useCheckListInput({ name }, { entries, uniqueKey = "key", defaultValue = false }) { -	const [state, setState] = React.useState({}); +	const [state, dispatch] = React.useReducer(reducer, null, +		() => initialState({ entries, uniqueKey, defaultValue }) // initial state +	); -	const [someSelected, setSomeSelected] = React.useState(false); -	const [toggleAllState, setToggleAllState] = React.useState(0);  	const toggleAllRef = React.useRef(null);  	React.useEffect(() => { -		/*  -			entries changed, update state, -			re-using old state if available for key -		*/ -		setState(createState(entries, uniqueKey, state, defaultValue)); - -		/* eslint-disable-next-line react-hooks/exhaustive-deps */ -	}, [entries]); - -	React.useEffect(() => { -		/* Updates (un)check all checkbox, based on shortcode checkboxes -			 Can be 0 (not checked), 1 (checked) or 2 (indeterminate) -		 */ -		if (toggleAllRef.current == null) { -			return; +		if (toggleAllRef.current != null) { +			let some = state.selectedEntries.size > 0; +			let all = false; +			if (some) { +				all = state.selectedEntries.size == Object.values(state.entries).length; +			} +			toggleAllRef.current.checked = all; +			toggleAllRef.current.indeterminate = some && !all;  		} - -		let values = Object.values(state); -		/* one or more boxes are checked */ -		let some = values.some((v) => v.checked); - -		let all = false; -		if (some) { -			/* there's not at least one unchecked box */ -			all = !values.some((v) => v.checked == false); +		// only needs to update when state.selectedEntries changes, not state.entries +		// eslint-disable-next-line react-hooks/exhaustive-deps +	}, [state.selectedEntries]); + +	const reset = React.useCallback( +		() => dispatch(actions.updateAll(defaultValue)), +		[defaultValue] +	); + +	const onChange = React.useCallback( +		(key, value) => dispatch(actions.update({ key, value })), +		[] +	); + +	const updateMultiple = React.useCallback( +		(entries) => dispatch(actions.updateMultiple(entries)), +		[] +	); + +	return React.useMemo(() => { +		function toggleAll(e) { +			let checked = e.target.checked; +			if (e.target.indeterminate) { +				checked = false; +			} +			dispatch(actions.updateAll(checked));  		} -		setSomeSelected(some); - -		if (some && !all) { -			setToggleAllState(2); -			toggleAllRef.current.indeterminate = true; -		} else { -			setToggleAllState(all ? 1 : 0); -			toggleAllRef.current.indeterminate = false; +		function selectedValues() { +			return Array.from((state.selectedEntries)).map((key) => ({ +				...state.entries[key] // returned as new object, because reducer state is immutable +			}));  		} -	}, [state, toggleAllRef]); - -	function toggleAll(e) { -		let selectAll = e.target.checked; -		if (toggleAllState == 2) { // indeterminate -			selectAll = false; -		} - -		setState(updateAllState(state, selectAll)); -		setToggleAllState(selectAll); -	} - -	function reset() { -		setState(updateAllState(state, defaultValue)); -	} - -	function selectedValues() { -		return syncpipe(state, [ -			(_) => Object.values(_), -			(_) => _.filter((entry) => entry.checked) -		]); -	} - -	return Object.assign([ -		state, -		reset, -		{ name } -	], { -		name, -		value: state, -		onChange: (key, newValue) => setState(updateState(state, key, newValue)), -		selectedValues, -		reset, -		someSelected, -		toggleAll: { -			ref: toggleAllRef, -			value: toggleAllState, -			onChange: toggleAll -		} -	}); +		return Object.assign([ +			state, +			reset, +			{ name } +		], { +			name, +			value: state.entries, +			onChange, +			selectedValues, +			reset, +			someSelected: state.someChecked, +			updateMultiple, +			toggleAll: { +				ref: toggleAllRef, +				onChange: toggleAll +			} +		}); +	}, [state, reset, name, onChange, updateMultiple]);  };
\ No newline at end of file diff --git a/web/source/settings/lib/form/text.jsx b/web/source/settings/lib/form/text.jsx index 70e61657c..d9a9ab28c 100644 --- a/web/source/settings/lib/form/text.jsx +++ b/web/source/settings/lib/form/text.jsx @@ -20,14 +20,30 @@  const React = require("react"); -module.exports = function useTextInput({ name, Name }, { validator, defaultValue = "", dontReset = false } = {}) { +module.exports = function useTextInput({ name, Name }, { +	defaultValue = "", +	dontReset = false, +	validator, +	showValidation = true, +	initValidation +} = {}) { +  	const [text, setText] = React.useState(defaultValue); -	const [valid, setValid] = React.useState(true);  	const textRef = React.useRef(null); +	const [validation, setValidation] = React.useState(initValidation ?? ""); +	const [_isValidating, startValidation] = React.useTransition(); +	let valid = validation == ""; +  	function onChange(e) {  		let input = e.target.value;  		setText(input); + +		if (validator) { +			startValidation(() => { +				setValidation(validator(input)); +			}); +		}  	}  	function reset() { @@ -38,11 +54,13 @@ module.exports = function useTextInput({ name, Name }, { validator, defaultValue  	React.useEffect(() => {  		if (validator && textRef.current) { -			let res = validator(text); -			setValid(res == ""); -			textRef.current.setCustomValidity(res); +			if (showValidation) { +				textRef.current.setCustomValidity(validation); +			} else { +				textRef.current.setCustomValidity(""); +			}  		} -	}, [text, textRef, validator]); +	}, [validation, validator, showValidation]);  	// Array / Object hybrid, for easier access in different contexts  	return Object.assign([ @@ -62,6 +80,7 @@ module.exports = function useTextInput({ name, Name }, { validator, defaultValue  		ref: textRef,  		setter: setText,  		valid, +		validate: () => setValidation(validator(text)),  		hasChanged: () => text != defaultValue  	});  };
\ No newline at end of file diff --git a/web/source/settings/lib/query/admin/import-export.js b/web/source/settings/lib/query/admin/import-export.js index 94e462bd2..a4a8b65e3 100644 --- a/web/source/settings/lib/query/admin/import-export.js +++ b/web/source/settings/lib/query/admin/import-export.js @@ -19,8 +19,11 @@  "use strict";  const Promise = require("bluebird"); -const isValidDomain = require("is-valid-domain");  const fileDownload = require("js-file-download"); +const csv = require("papaparse"); +const { nanoid } = require("nanoid"); + +const { isValidDomainBlock, hasBetterScope } = require("../../domain-block");  const {  	replaceCacheOnMutation, @@ -31,6 +34,23 @@ const {  function parseDomainList(list) {  	if (list[0] == "[") {  		return JSON.parse(list); +	} else if (list.startsWith("#domain")) { // Mastodon CSV +		const { data, errors } = csv.parse(list, { +			header: true, +			transformHeader: (header) => header.slice(1), // removes starting '#' +			skipEmptyLines: true, +			dynamicTyping: true +		}); + +		if (errors.length > 0) { +			let error = ""; +			errors.forEach((err) => { +				error += `${err.message} (line ${err.row})`; +			}); +			throw error; +		} + +		return data;  	} else {  		return list.split("\n").map((line) => {  			let domain = line.trim(); @@ -51,7 +71,15 @@ function parseDomainList(list) {  function validateDomainList(list) {  	list.forEach((entry) => { -		entry.valid = (entry.valid !== false) && isValidDomain(entry.domain, { wildcard: true, allowUnicode: true }); +		if (entry.domain.startsWith("*.")) { +			// domain block always includes all subdomains, wildcard is meaningless here +			entry.domain = entry.domain.slice(2); +		} + +		entry.valid = (entry.valid !== false) && isValidDomainBlock(entry.domain); +		if (entry.valid) { +			entry.suggest = hasBetterScope(entry.domain); +		}  		entry.checked = entry.valid;  	}); @@ -83,6 +111,9 @@ module.exports = (build) => ({  			}).then((deduped) => {  				return validateDomainList(deduped);  			}).then((data) => { +				data.forEach((entry) => { +					entry.key = nanoid(); // unique id that stays stable even if domain gets modified by user +				});  				return { data };  			}).catch((e) => {  				return { error: e.toString() }; @@ -91,27 +122,53 @@ module.exports = (build) => ({  	}),  	exportDomainList: build.mutation({  		queryFn: (formData, api, _extraOpts, baseQuery) => { +			let process; + +			if (formData.exportType == "json") { +				process = { +					transformEntry: (entry) => ({ +						domain: entry.domain, +						public_comment: entry.public_comment, +						obfuscate: entry.obfuscate +					}), +					stringify: (list) => JSON.stringify(list), +					extension: ".json", +					mime: "application/json" +				}; +			} else if (formData.exportType == "csv") { +				process = { +					transformEntry: (entry) => [ +						entry.domain, +						"suspend", // severity +						false, // reject_media +						false, // reject_reports +						entry.public_comment, +						entry.obfuscate ?? false +					], +					stringify: (list) => csv.unparse({ +						fields: "#domain,#severity,#reject_media,#reject_reports,#public_comment,#obfuscate".split(","), +						data: list +					}), +					extension: ".csv", +					mime: "text/csv" +				}; +			} else { +				process = { +					transformEntry: (entry) => entry.domain, +					stringify: (list) => list.join("\n"), +					extension: ".txt", +					mime: "text/plain" +				}; +			} +  			return Promise.try(() => {  				return baseQuery({  					url: `/api/v1/admin/domain_blocks`  				});  			}).then(unwrapRes).then((blockedInstances) => { -				return blockedInstances.map((entry) => { -					if (formData.exportType == "json") { -						return { -							domain: entry.domain, -							public_comment: entry.public_comment -						}; -					} else { -						return entry.domain; -					} -				}); +				return blockedInstances.map(process.transformEntry);  			}).then((exportList) => { -				if (formData.exportType == "json") { -					return JSON.stringify(exportList); -				} else { -					return exportList.join("\n"); -				} +				return process.stringify(exportList);  			}).then((exportAsString) => {  				if (formData.action == "export") {  					return { @@ -120,7 +177,6 @@ module.exports = (build) => ({  				} else if (formData.action == "export-file") {  					let domain = new URL(api.getState().oauth.instance).host;  					let date = new Date(); -					let mime;  					let filename = [  						domain, @@ -130,15 +186,11 @@ module.exports = (build) => ({  						date.getDate().toString().padStart(2, "0"),  					].join("-"); -					if (formData.exportType == "json") { -						filename += ".json"; -						mime = "application/json"; -					} else { -						filename += ".txt"; -						mime = "text/plain"; -					} - -					fileDownload(exportAsString, filename, mime); +					fileDownload( +						exportAsString, +						filename + process.extension, +						process.mime +					);  				}  				return { data: null };  			}).catch((e) => { @@ -171,6 +223,7 @@ module.exports = (build) => ({  	})  }); +const internalKeys = new Set("key,suggest,valid,checked".split(","));  function entryProcessor(formData) {  	let funcs = []; @@ -204,7 +257,7 @@ function entryProcessor(formData) {  		entry.obfuscate = formData.obfuscate;  		Object.entries(entry).forEach(([key, val]) => { -			if (val == undefined) { +			if (internalKeys.has(key) || val == undefined) {  				delete entry[key];  			}  		}); diff --git a/web/source/settings/style.css b/web/source/settings/style.css index d46df683a..7affd8269 100644 --- a/web/source/settings/style.css +++ b/web/source/settings/style.css @@ -69,6 +69,10 @@ section {  			&:last-child {  				margin-bottom: 0;  			} + +			&.without-border { +				border-left: 0; +			}  		}  	} @@ -370,7 +374,8 @@ span.form-info {  .checkbox-list {  	.header, .entry { -		gap: 1rem; +		display: grid; +		gap: 0 1rem;  	}  } @@ -629,7 +634,6 @@ span.form-info {  		.checkbox-list {  			.entry { -				display: grid;  				grid-template-columns: auto auto 1fr;  			} @@ -688,9 +692,14 @@ button.with-padding {  .suspend-import-list {  	.checkbox-list { -		.header, .entry { -			display: grid; +		.entry {  			grid-template-columns: auto 25ch auto 1fr; +			grid-template-rows: auto 1fr; + +			p { +				grid-column: 4; +				grid-row: 1 / span 2; +			}  		}  	} @@ -704,6 +713,10 @@ button.with-padding {  			color: $green1;  		} +		#icon .suggest-changes { +			color: $orange2; +		} +  		p {  			align-self: center;  			margin: 0; @@ -711,6 +724,75 @@ button.with-padding {  	}  } +.import-export { +	p { +		margin: 0; +	} + +	.export-file { +		display: flex; +		gap: 0.7rem; +		align-items: center; +	} + +	.button-grid { +		display: inline-grid; +		grid-template-columns: auto auto auto; +		align-self: start; +		gap: 0.5rem; + +		button { +			width: 100%; +		} +	} +} + +.update-hints { +	background: $list-entry-alternate-bg; +	border: 0.1rem solid $border-accent; +	/* border-radius: $br; */ +	padding: 0.5rem; +	display: flex; +	flex-direction: column; + +	.hints { +		max-width: 100%; +		align-self: start; +		align-items: center; +		margin: 1rem 0; +		display: inline-grid; +		grid-template-columns: auto auto auto auto; +		gap: 1rem; +	} +} + +.export-format-table { +	width: 100%; +	background: $list-entry-alternate-bg; +	border-collapse: collapse; + +	th, td { +		border: 0.1rem solid $gray1; +		padding: 0.3rem; +	} + +	th { +		background: $list-entry-bg; +	} + +	td { +		text-align: center; + +		.fa-check { +			color: $green1; +		} + +		.fa-times { +			color: $error3; +		} +	} +} +  .form-field.radio {  	&, label {  		display: flex; @@ -723,6 +805,10 @@ button.with-padding {  	}  } +[role="button"] { +	cursor: pointer; +} +  @keyframes fadeout {  	from {  		opacity: 1; | 
