gtsocial-umbx

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit a59dc855d94b332ca01b4a2477ef94ee68da9fe6
parent 49beb17a8fbdbf3517c103a477a5459a3bba404d
Author: f0x52 <f0x@cthu.lu>
Date:   Fri,  3 Feb 2023 12:07:40 +0100

[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:
Mweb/source/css/base.css | 2+-
Mweb/source/index.js | 7++++++-
Mweb/source/package.json | 3+++
Mweb/source/settings/admin/emoji/remote/parse-from-toot.js | 21+++++++++++++++------
Dweb/source/settings/admin/federation/import-export.js | 308-------------------------------------------------------------------------------
Aweb/source/settings/admin/federation/import-export/export-format-table.jsx | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/admin/federation/import-export/form.jsx | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/admin/federation/import-export/index.jsx | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/admin/federation/import-export/process.jsx | 328+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mweb/source/settings/components/check-list.jsx | 84++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mweb/source/settings/components/form/inputs.jsx | 3++-
Aweb/source/settings/lib/domain-block.js | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Mweb/source/settings/lib/form/check-list.jsx | 249+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mweb/source/settings/lib/form/text.jsx | 31+++++++++++++++++++++++++------
Mweb/source/settings/lib/query/admin/import-export.js | 109++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mweb/source/settings/style.css | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mweb/source/yarn.lock | 15+++++++++++++++
17 files changed, 1085 insertions(+), 488 deletions(-)

diff --git a/web/source/css/base.css b/web/source/css/base.css @@ -315,7 +315,7 @@ input, select, textarea, .input { border-color: $input-focus-border; } - &:invalid { + &:invalid, .invalid & { border-color: $input-error-border; } diff --git a/web/source/index.js b/web/source/index.js @@ -66,10 +66,15 @@ skulk({ ], }, settings: { - debug: false, entryFile: "settings", outputFile: "settings.js", prodCfg: prodCfg, + transform: [ + ["babelify", { + global: true, + ignore: [/node_modules\/(?!nanoid)/] + }] + ], presets: [ "react", ["postcss", { diff --git a/web/source/package.json b/web/source/package.json @@ -19,9 +19,12 @@ "langs": "^2.0.0", "match-sorter": "^6.3.1", "modern-normalize": "^1.1.0", + "nanoid": "^4.0.0", + "papaparse": "^5.3.2", "photoswipe": "^5.3.3", "photoswipe-dynamic-caption-plugin": "^1.2.7", "photoswipe-video-plugin": "^1.0.2", + "psl": "^1.9.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-error-boundary": "^3.1.4", diff --git 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 @@ -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); - }}> - &lt; 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 @@ -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 @@ -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 @@ -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); + }}> + &lt; 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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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; diff --git a/web/source/yarn.lock b/web/source/yarn.lock @@ -3953,6 +3953,11 @@ nanoid@^3.3.4: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== +nanoid@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-4.0.0.tgz#6e144dee117609232c3f415c34b0e550e64999a5" + integrity sha512-IgBP8piMxe/gf73RTQx7hmnhwz0aaEXYakvqZyE302IXW3HyVNhdNGC+O2MwMAVhLEnvXlvKtGbtJf6wvHihCg== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -4117,6 +4122,11 @@ pako@~1.0.5: resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== +papaparse@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-5.3.2.tgz#d1abed498a0ee299f103130a6109720404fbd467" + integrity sha512-6dNZu0Ki+gyV0eBsFKJhYr+MdQYAzFUGlBMNj3GNrmHxmz1lfRa24CjFObPXtjcetlOv5Ad299MhIK0znp3afw== + parent-module@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" @@ -4348,6 +4358,11 @@ proxy-addr@~2.0.7: forwarded "0.2.0" ipaddr.js "1.9.1" +psl@^1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" + integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag== + public-encrypt@^4.0.0: version "4.0.3" resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.3.tgz#4fcc9d77a07e48ba7527e7cbe0de33d0701331e0"