diff --git a/web/source/css/_colors.css b/web/source/css/_colors.css index ca17f5798..86ef80c20 100644 --- a/web/source/css/_colors.css +++ b/web/source/css/_colors.css @@ -47,7 +47,11 @@ $blue3: #89caff; /* hover/selected accent to $blue2, can be used with $gray1 (7. $error1: #860000; /* Error border/foreground text, can be used with $error2 (5.0), $white1 (10), $white2 (5.1) */ $error2: #ff9796; /* Error background text, can be used with $error1 (5.0), $gray1 (6.6), $gray2 (5.3), $gray3 (4.8) */ $error3: #dd2c2c; /* Error button background text, can be used with $white1 (4.51) */ -$error-link: #185F8C; /* Error link text, can be used with $error2 (5.54) */ +$error-link: #01318C; /* Error link text, can be used with $error2 (5.56) */ + +$info-fg: $gray1; +$info-bg: #b3ddff; +$info-link: $error-link; $fg: $white1; $bg: $gray1; @@ -92,6 +96,7 @@ $avatar-border: $orange2; $input-bg: $gray4; $input-disabled-bg: $gray2; $input-border: $blue1; +$input-error-border: $error3; $input-focus-border: $blue3; $settings-nav-bg: $bg-accent; @@ -107,5 +112,6 @@ $settings-nav-bg-active: $gray2; $error-fg: $error1; $error-bg: $error2; -$settings-entry-bg: $gray3; +$settings-entry-bg: $gray2; +$settings-entry-alternate-bg: $gray3; $settings-entry-hover-bg: $gray4; \ No newline at end of file diff --git a/web/source/css/base.css b/web/source/css/base.css index 340052b5a..e0350577d 100644 --- a/web/source/css/base.css +++ b/web/source/css/base.css @@ -311,12 +311,16 @@ input, select, textarea, .input { font-size: 1rem; padding: 0.3rem; - &:focus { + &:focus, &:active { border-color: $input-focus-border; } + &:invalid { + border-color: $input-error-border; + } + &:disabled { - background: $input-disabled-bg; + background: transparent; } } diff --git a/web/source/settings/admin/actions.js b/web/source/settings/admin/actions.js index 7bbdf548c..b91d81e14 100644 --- a/web/source/settings/admin/actions.js +++ b/web/source/settings/admin/actions.js @@ -28,7 +28,7 @@ const { TextInput } = require("../components/form/inputs"); const MutationButton = require("../components/form/mutation-button"); module.exports = function AdminActionPanel() { - const daysField = useTextInput("days", {defaultValue: 30}); + const daysField = useTextInput("days", { defaultValue: 30 }); const [mediaCleanup, mediaCleanupResult] = query.useMediaCleanupMutation(); @@ -54,7 +54,7 @@ module.exports = function AdminActionPanel() { min="0" placeholder="30" /> - + > ); diff --git a/web/source/settings/admin/emoji/local/detail.js b/web/source/settings/admin/emoji/local/detail.js index 0687680b4..c509ab4f4 100644 --- a/web/source/settings/admin/emoji/local/detail.js +++ b/web/source/settings/admin/emoji/local/detail.js @@ -34,19 +34,19 @@ const base = "/settings/custom-emoji/local"; module.exports = function EmojiDetailRoute() { let [_match, params] = useRoute(`${base}/:emojiId`); if (params?.emojiId == undefined) { - return ; + return ; } else { return ( < go back - + ); } }; -function EmojiDetailData({emojiId}) { - const {currentData: emoji, isLoading, error} = query.useGetEmojiQuery(emojiId); +function EmojiDetailData({ emojiId }) { + const { currentData: emoji, isLoading, error } = query.useGetEmojiQuery(emojiId); if (error) { return ( @@ -57,20 +57,20 @@ function EmojiDetailData({emojiId}) { } else if (isLoading) { return ( - + ); } else { - return ; + return ; } } -function EmojiDetail({emoji}) { +function EmojiDetail({ emoji }) { const [modifyEmoji, modifyResult] = query.useEditEmojiMutation(); const [isNewCategory, setIsNewCategory] = React.useState(false); - const [categoryState, _resetCategory, { category }] = useComboBoxInput("category", {defaultValue: emoji.category}); + const [categoryState, _resetCategory, { category }] = useComboBoxInput("category", { defaultValue: emoji.category }); const [onFileChange, _resetFile, { image, imageURL, imageInfo }] = useFileInput("image", { withPreview: true, @@ -78,26 +78,26 @@ function EmojiDetail({emoji}) { }); function modifyCategory() { - modifyEmoji({id: emoji.id, category: category.trim()}); + modifyEmoji({ id: emoji.id, category: category.trim() }); } function modifyImage() { - modifyEmoji({id: emoji.id, image: image}); + modifyEmoji({ id: emoji.id, image: image }); } React.useEffect(() => { - if (category != emoji.category && !categoryState.open && !isNewCategory && category.trim().length > 0) { - modifyEmoji({id: emoji.id, category: category.trim()}); + if (category != emoji.category && !categoryState.open && !isNewCategory && category?.trim().length > 0) { + modifyEmoji({ id: emoji.id, category: category.trim() }); } }, [isNewCategory, category, categoryState.open, emoji.category, emoji.id, modifyEmoji]); return ( <> - + {emoji.shortcode} - + @@ -114,7 +114,7 @@ function EmojiDetail({emoji}) { categoryState={categoryState} setIsNew={setIsNewCategory} > - + Create @@ -153,7 +153,7 @@ function EmojiDetail({emoji}) { ); } -function DeleteButton({id}) { +function DeleteButton({ id }) { // TODO: confirmation dialog? const [deleteEmoji, deleteResult] = query.useDeleteEmojiMutation(); @@ -163,7 +163,7 @@ function DeleteButton({id}) { } if (deleteResult.isSuccess) { - return ; + return ; } return ( diff --git a/web/source/settings/admin/emoji/local/new-emoji.js b/web/source/settings/admin/emoji/local/new-emoji.js index e340e8559..3a539686b 100644 --- a/web/source/settings/admin/emoji/local/new-emoji.js +++ b/web/source/settings/admin/emoji/local/new-emoji.js @@ -50,7 +50,7 @@ module.exports = function NewEmojiForm({ emoji }) { const [onShortcodeChange, resetShortcode, { shortcode, setShortcode, shortcodeRef }] = useTextInput("shortcode", { validator: function validateShortcode(code) { // technically invalid, but hacky fix to prevent validation error on page load - if (shortcode == "") {return "";} + if (shortcode == "") { return ""; } if (emojiCodes.has(code)) { return "Shortcode already in use"; @@ -161,7 +161,7 @@ module.exports = function NewEmojiForm({ emoji }) { categoryState={categoryState} /> - + ); diff --git a/web/source/settings/admin/federation.js b/web/source/settings/admin/federation.js deleted file mode 100644 index 5cfe2254c..000000000 --- a/web/source/settings/admin/federation.js +++ /dev/null @@ -1,386 +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 . -*/ - -"use strict"; - -const Promise = require("bluebird"); -const React = require("react"); -const Redux = require("react-redux"); -const {Switch, Route, Link, Redirect, useRoute, useLocation} = require("wouter"); -const fileDownload = require("js-file-download"); - -const { formFields } = require("../components/form-fields"); - -const api = require("../lib/api"); -const adminActions = require("../redux/reducers/admin").actions; -const submit = require("../lib/submit"); -const BackButton = require("../components/back-button"); -const Loading = require("../components/loading"); -const { matchSorter } = require("match-sorter"); - -const base = "/settings/admin/federation"; - -module.exports = function AdminSettings() { - const dispatch = Redux.useDispatch(); - const loadedBlockedInstances = Redux.useSelector(state => state.admin.loadedBlockedInstances); - - React.useEffect(() => { - if (!loadedBlockedInstances ) { - Promise.try(() => { - return dispatch(api.admin.fetchDomainBlocks()); - }); - } - }, [dispatch, loadedBlockedInstances]); - - if (!loadedBlockedInstances) { - return ( - - Federation - - - - - ); - } - - return ( - - - - - - - ); -}; - -function InstanceOverview() { - const [filter, setFilter] = React.useState(""); - const blockedInstances = Redux.useSelector(state => state.admin.blockedInstances); - const [_location, setLocation] = useLocation(); - - const filteredInstances = React.useMemo(() => { - return matchSorter(Object.values(blockedInstances), filter, {keys: ["domain"]}); - }, [blockedInstances, filter]); - - function filterFormSubmit(e) { - e.preventDefault(); - setLocation(`${base}/${filter}`); - } - - return ( - <> - Federation - Here you can see an overview of blocked instances. - - - Blocked instances - - setFilter(e.target.value)}/> - Add block - - - {filteredInstances.map((entry) => { - return ( - - - - {entry.domain} - - - {new Date(entry.created_at).toLocaleString()} - - - - ); - })} - - - - - > - ); -} - -const Bulk = formFields(adminActions.updateBulkBlockVal, (state) => state.admin.bulkBlock); -function BulkBlocking() { - const dispatch = Redux.useDispatch(); - const {bulkBlock, blockedInstances} = Redux.useSelector(state => state.admin); - - const [errorMsg, setError] = React.useState(""); - const [statusMsg, setStatus] = React.useState(""); - - function importBlocks() { - setStatus("Processing"); - setError(""); - return Promise.try(() => { - return dispatch(api.admin.bulkDomainBlock()); - }).then(({success, invalidDomains}) => { - return Promise.try(() => { - return resetBulk(); - }).then(() => { - dispatch(adminActions.updateBulkBlockVal(["list", invalidDomains.join("\n")])); - - let stat = ""; - if (success == 0) { - return setError("No valid domains in import"); - } else if (success == 1) { - stat = "Imported 1 domain"; - } else { - stat = `Imported ${success} domains`; - } - - if (invalidDomains.length > 0) { - if (invalidDomains.length == 1) { - stat += ", input contained 1 invalid domain."; - } else { - stat += `, input contained ${invalidDomains.length} invalid domains.`; - } - } else { - stat += "!"; - } - - setStatus(stat); - }); - }).catch((e) => { - setError(e.message); - setStatus(""); - }); - } - - function exportBlocks() { - return Promise.try(() => { - setStatus("Exporting"); - setError(""); - let asJSON = bulkBlock.exportType.startsWith("json"); - let _asCSV = bulkBlock.exportType.startsWith("csv"); - - let exportList = Object.values(blockedInstances).map((entry) => { - if (asJSON) { - return { - domain: entry.domain, - public_comment: entry.public_comment - }; - } else { - return entry.domain; - } - }); - - if (bulkBlock.exportType == "json") { - return dispatch(adminActions.updateBulkBlockVal(["list", JSON.stringify(exportList)])); - } else if (bulkBlock.exportType == "json-download") { - return fileDownload(JSON.stringify(exportList), "block-export.json"); - } else if (bulkBlock.exportType == "plain") { - return dispatch(adminActions.updateBulkBlockVal(["list", exportList.join("\n")])); - } - }).then(() => { - setStatus("Exported!"); - }).catch((e) => { - setError(e.message); - setStatus(""); - }); - } - - function resetBulk(e) { - if (e != undefined) { - e.preventDefault(); - } - return dispatch(adminActions.resetBulkBlockVal()); - } - - function disableInfoFields(props={}) { - if (bulkBlock.list[0] == "[") { - return { - ...props, - disabled: true, - placeHolder: "Domain list is a JSON import, input disabled" - }; - } else { - return props; - } - } - - return ( - - Import / Export reset - - - - - - - - - - - - - - - Import - - - - Export - - - One per line in text field - JSON in text field - JSON file download - CSV in text field (glitch-soc) - CSV file download (glitch-soc) - > - }/> - - - - {errorMsg.length > 0 && - {errorMsg} - } - {statusMsg.length > 0 && - {statusMsg} - } - - - - ); -} - -function InstancePageWrapped() { - /* We wrap the component to generate formFields with a setter depending on the domain - if formFields() is used inside the same component that is re-rendered with their state, - inputs get re-created on every change, causing them to lose focus, and bad performance - */ - let [_match, {domain}] = useRoute(`${base}/:domain`); - - if (domain == "view") { // from form field submission - let realDomain = (new URL(document.location)).searchParams.get("domain"); - if (realDomain == undefined) { - return ; - } else { - domain = realDomain; - } - } - - function alterDomain([key, val]) { - return adminActions.updateDomainBlockVal([domain, key, val]); - } - - const fields = formFields(alterDomain, (state) => state.admin.newInstanceBlocks[domain]); - - return ; -} - -function InstancePage({domain, Form}) { - const dispatch = Redux.useDispatch(); - const entry = Redux.useSelector(state => state.admin.newInstanceBlocks[domain]); - const [_location, setLocation] = useLocation(); - - React.useEffect(() => { - if (entry == undefined) { - dispatch(api.admin.getEditableDomainBlock(domain)); - } - }, [dispatch, domain, entry]); - - const [errorMsg, setError] = React.useState(""); - const [statusMsg, setStatus] = React.useState(""); - - if (entry == undefined) { - return ; - } - - const updateBlock = submit( - () => dispatch(api.admin.updateDomainBlock(domain)), - {setStatus, setError} - ); - - const removeBlock = submit( - () => dispatch(api.admin.removeDomainBlock(domain)), - {setStatus, setError, startStatus: "Removing", successStatus: "Removed!", onSuccess: () => { - setLocation(base); - }} - ); - - return ( - - Federation settings for: {domain} - {entry.new - ? "No stored block yet, you can add one below:" - : Editing domain blocks is not implemented yet, check here for progress. - } - - - - - - - - - {entry.new - ? {entry.new ? "Add block" : "Save block"} - : Remove block - } - - {errorMsg.length > 0 && - {errorMsg} - } - {statusMsg.length > 0 && - {statusMsg} - } - - - ); -} \ No newline at end of file diff --git a/web/source/settings/admin/federation/detail.js b/web/source/settings/admin/federation/detail.js new file mode 100644 index 000000000..eb33960a7 --- /dev/null +++ b/web/source/settings/admin/federation/detail.js @@ -0,0 +1,146 @@ +/* + GoToSocial + Copyright (C) 2021-2022 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 . +*/ + +"use strict"; + +const React = require("react"); +const { useRoute, Redirect } = require("wouter"); + +const query = require("../../lib/query"); + +const { useTextInput, useBoolInput } = require("../../lib/form"); + +const useFormSubmit = require("../../lib/form/submit"); + +const { TextInput, Checkbox, TextArea } = require("../../components/form/inputs"); + +const Loading = require("../../components/loading"); +const BackButton = require("../../components/back-button"); +const MutationButton = require("../../components/form/mutation-button"); + +module.exports = function InstanceDetail({ baseUrl }) { + const { data: blockedInstances = [], isLoading } = query.useInstanceBlocksQuery(); + + let [_match, { domain }] = useRoute(`${baseUrl}/:domain`); + + if (domain == "view") { // from form field submission + domain = (new URL(document.location)).searchParams.get("domain"); + } + + const existingBlock = React.useMemo(() => { + return blockedInstances.find((block) => block.domain == domain); + }, [blockedInstances, domain]); + + if (domain == undefined) { + return ; + } + + let infoContent = null; + + if (isLoading) { + infoContent = ; + } else if (existingBlock == undefined) { + infoContent = No stored block yet, you can add one below:; + } else { + infoContent = ( + + + Editing domain blocks isn't implemented yet, check here for progress + + ); + } + + return ( + + Federation settings for: {domain} + {infoContent} + + + ); +}; + +function DomainBlockForm({ defaultDomain, block = {} }) { + const isExistingBlock = block.domain != undefined; + + const disabledForm = isExistingBlock + ? { + disabled: true, + title: "Domain suspensions currently cannot be edited." + } + : {}; + + const form = { + domain: useTextInput("domain", { defaultValue: block.domain ?? defaultDomain }), + obfuscate: useBoolInput("obfuscate", { defaultValue: block.obfuscate }), + commentPrivate: useTextInput("private_comment", { defaultValue: block.private_comment }), + commentPublic: useTextInput("public_comment", { defaultValue: block.public_comment }) + }; + + const [submitForm, addResult] = useFormSubmit(form, query.useAddInstanceBlockMutation(), { changedOnly: false }); + + const [removeBlock, removeResult] = query.useRemoveInstanceBlockMutation({ fixedCacheKey: block.id }); + + return ( + + + + + + + + + + + + { + isExistingBlock && + removeBlock(block.id)} + label="Remove" + result={removeResult} + className="button danger" + /> + } + + + ); +} \ No newline at end of file diff --git a/web/source/settings/admin/federation/import-export.js b/web/source/settings/admin/federation/import-export.js new file mode 100644 index 000000000..8962827d0 --- /dev/null +++ b/web/source/settings/admin/federation/import-export.js @@ -0,0 +1,88 @@ +/* + GoToSocial + Copyright (C) 2021-2022 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 . +*/ + +"use strict"; + +const React = require("react"); + +const query = require("../../lib/query"); + +const { + useTextInput, + useBoolInput, + useFileInput +} = require("../../lib/form"); + +const useFormSubmit = require("../../lib/form/submit"); + +const { + TextInput, + TextArea, + Checkbox, + FileInput +} = require("../../components/form/inputs"); +const FormWithData = require("../../lib/form/form-with-data"); + +module.exports = function ImportExport() { + return ( + + Import / Export + + + ); +}; + +function ImportExportForm({ data: blockedInstances }) { + const form = { + list: useTextInput("list"), + obfuscate: useBoolInput("obfuscate"), + commentPrivate: useTextInput("private_comment"), + commentPublic: useTextInput("public_comment"), + json: useFileInput("json") + }; + + return ( + + + + + + + + + + ); +} \ No newline at end of file diff --git a/web/source/settings/lib/submit.js b/web/source/settings/admin/federation/index.js similarity index 58% rename from web/source/settings/lib/submit.js rename to web/source/settings/admin/federation/index.js index 6bb8836fc..0f1664e5c 100644 --- a/web/source/settings/lib/submit.js +++ b/web/source/settings/admin/federation/index.js @@ -18,31 +18,26 @@ "use strict"; -const Promise = require("bluebird"); +const React = require("react"); +const { Switch, Route } = require("wouter"); -module.exports = function submit(func, { - setStatus, setError, - startStatus="PATCHing", successStatus="Saved!", - onSuccess, - onError -}) { - return function() { - setStatus(startStatus); - setError(""); - return Promise.try(() => { - return func(); - }).then(() => { - setStatus(successStatus); - if (onSuccess != undefined) { - return onSuccess(); - } - }).catch((e) => { - setError(e.message); - setStatus(""); - console.error(e); - if (onError != undefined) { - onError(e); - } - }); - }; +const baseUrl = `/settings/admin/federation`; + +const InstanceOverview = require("./overview"); +const InstanceDetail = require("./detail"); + +module.exports = function Federation({ }) { + return ( + + {/* + + */} + + + + + + + + ); }; \ No newline at end of file diff --git a/web/source/settings/admin/federation/overview.js b/web/source/settings/admin/federation/overview.js new file mode 100644 index 000000000..e330da996 --- /dev/null +++ b/web/source/settings/admin/federation/overview.js @@ -0,0 +1,97 @@ +/* + GoToSocial + Copyright (C) 2021-2022 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 . +*/ + +"use strict"; + +const React = require("react"); +const { Link, useLocation } = require("wouter"); +const { matchSorter } = require("match-sorter"); + +const { useTextInput } = require("../../lib/form"); + +const { TextInput } = require("../../components/form/inputs"); + +const query = require("../../lib/query"); + +const Loading = require("../../components/loading"); +const ImportExport = require("./import-export"); + +module.exports = function InstanceOverview({ baseUrl }) { + const { data: blockedInstances = [], isLoading } = query.useInstanceBlocksQuery(); + + const [_location, setLocation] = useLocation(); + + const filterField = useTextInput("filter"); + const filter = filterField.value; + + const filteredInstances = React.useMemo(() => { + return matchSorter(Object.values(blockedInstances), filter, { keys: ["domain"] }); + }, [blockedInstances, filter]); + + let filtered = blockedInstances.length - filteredInstances.length; + + function filterFormSubmit(e) { + e.preventDefault(); + setLocation(`${baseUrl}/${filter}`); + } + + if (isLoading) { + return ; + } + + return ( + <> + Federation + + + Suspended instances + + Suspending a domain blocks all current and future accounts on that instance. Stored content will be removed, + and no more data is sent to the remote server. + + + + Suspend + + + + {blockedInstances.length} blocked instance{blockedInstances.length != 1 ? "s" : ""} {filtered > 0 && `(${filtered} filtered)`} + + + {filteredInstances.map((entry) => { + return ( + + + + {entry.domain} + + + {new Date(entry.created_at).toLocaleString()} + + + + ); + })} + + + + + + > + ); +}; \ No newline at end of file diff --git a/web/source/settings/admin/settings.js b/web/source/settings/admin/settings.js index e8f322c65..c0a9eabbe 100644 --- a/web/source/settings/admin/settings.js +++ b/web/source/settings/admin/settings.js @@ -47,19 +47,19 @@ module.exports = function AdminSettings() { ); }; -function AdminSettingsForm({data: instance}) { +function AdminSettingsForm({ data: instance }) { const form = { - title: useTextInput("title", {defaultValue: instance.title}), - thumbnail: useFileInput("thumbnail", {withPreview: true}), - thumbnailDesc: useTextInput("thumbnail_description", {defaultValue: instance.thumbnail_description}), - shortDesc: useTextInput("short_description", {defaultValue: instance.short_description}), - description: useTextInput("description", {defaultValue: instance.description}), - contactUser: useTextInput("contact_username", {defaultValue: instance.contact_account?.username}), - contactEmail: useTextInput("contact_email", {defaultValue: instance.email}), - terms: useTextInput("terms", {defaultValue: instance.terms}) + title: useTextInput("title", { defaultValue: instance.title }), + thumbnail: useFileInput("thumbnail", { withPreview: true }), + thumbnailDesc: useTextInput("thumbnail_description", { defaultValue: instance.thumbnail_description }), + shortDesc: useTextInput("short_description", { defaultValue: instance.short_description }), + description: useTextInput("description", { defaultValue: instance.description }), + contactUser: useTextInput("contact_username", { defaultValue: instance.contact_account?.username }), + contactEmail: useTextInput("contact_email", { defaultValue: instance.email }), + terms: useTextInput("terms", { defaultValue: instance.terms }) }; - const [result, submitForm] = useFormSubmit(form, query.useUpdateInstanceMutation()); + const [submitForm, result] = useFormSubmit(form, query.useUpdateInstanceMutation()); return ( @@ -117,7 +117,7 @@ function AdminSettingsForm({data: instance}) { placeholder="" /> - + ); } \ No newline at end of file diff --git a/web/source/settings/components/form/mutation-button.jsx b/web/source/settings/components/form/mutation-button.jsx index f0b776703..1490453af 100644 --- a/web/source/settings/components/form/mutation-button.jsx +++ b/web/source/settings/components/form/mutation-button.jsx @@ -20,24 +20,28 @@ const React = require("react"); -module.exports = function MutateButton({text, result}) { - let buttonText = text; +module.exports = function MutationButton({ label, result, disabled, ...inputProps }) { + let iconClass = ""; + + console.log(label, result); if (result.isLoading) { - buttonText = "Processing..."; + iconClass = "fa-spin fa-refresh"; + } else if (result.isSuccess) { + iconClass = "fa-check fadeout"; } return ( - {result.error && + {result.error && {result.error.status}: {result.error.data.error} } - - {result.isSuccess && "Success!"} + + + {result.isLoading + ? "Processing..." + : label + } + ); }; \ No newline at end of file diff --git a/web/source/settings/index.js b/web/source/settings/index.js index fb9eb79f7..f59539bff 100644 --- a/web/source/settings/index.js +++ b/web/source/settings/index.js @@ -46,7 +46,7 @@ const nav = { adminOnly: true, "Instance Settings": require("./admin/settings.js"), "Actions": require("./admin/actions"), - "Federation": require("./admin/federation.js"), + "Federation": require("./admin/federation"), }, "Custom Emoji": { adminOnly: true, @@ -172,7 +172,7 @@ function App() { function Main() { return ( - } persistor={persistor}> + } persistor={persistor}> diff --git a/web/source/settings/lib/form/file.jsx b/web/source/settings/lib/form/file.jsx index e6492198c..85f23e274 100644 --- a/web/source/settings/lib/form/file.jsx +++ b/web/source/settings/lib/form/file.jsx @@ -21,11 +21,11 @@ const React = require("react"); const prettierBytes = require("prettier-bytes"); -module.exports = function useFileInput({name, _Name}, { +module.exports = function useFileInput({ name, _Name }, { withPreview, maxSize, initialInfo = "no file selected" -}) { +} = {}) { const [file, setFile] = React.useState(); const [imageURL, setImageURL] = React.useState(); const [info, setInfo] = React.useState(); @@ -40,7 +40,7 @@ module.exports = function useFileInput({name, _Name}, { if (withPreview) { setImageURL(URL.createObjectURL(file)); } - + let size = prettierBytes(file.size); if (maxSize && file.size > maxSize) { size = {size}; diff --git a/web/source/settings/lib/form/submit.js b/web/source/settings/lib/form/submit.js index ebb3068ce..f26da6885 100644 --- a/web/source/settings/lib/form/submit.js +++ b/web/source/settings/lib/form/submit.js @@ -20,9 +20,8 @@ const syncpipe = require("syncpipe"); -module.exports = function useFormSubmit(form, [mutationQuery, result]) { +module.exports = function useFormSubmit(form, [mutationQuery, result], { changedOnly = true } = {}) { return [ - result, function submitForm(e) { e.preventDefault(); @@ -31,7 +30,7 @@ module.exports = function useFormSubmit(form, [mutationQuery, result]) { const mutationData = syncpipe(form, [ (_) => Object.values(_), (_) => _.map((field) => { - if (field.hasChanged()) { + if (!changedOnly || field.hasChanged()) { updatedFields.push(field); return [field.name, field.value]; } else { @@ -42,9 +41,8 @@ module.exports = function useFormSubmit(form, [mutationQuery, result]) { (_) => Object.fromEntries(_) ]); - if (updatedFields.length > 0) { - return mutationQuery(mutationData); - } + return mutationQuery(mutationData); }, + result ]; }; \ No newline at end of file diff --git a/web/source/settings/lib/query/admin.js b/web/source/settings/lib/query/admin.js index e96d91762..c679e684a 100644 --- a/web/source/settings/lib/query/admin.js +++ b/web/source/settings/lib/query/admin.js @@ -18,7 +18,11 @@ "use strict"; -const { updateCacheOnMutation } = require("./lib"); +const { + replaceCacheOnMutation, + appendCacheOnMutation, + spliceCacheOnMutation +} = require("./lib"); const base = require("./base"); const endpoints = (build) => ({ @@ -27,19 +31,46 @@ const endpoints = (build) => ({ method: "PATCH", url: `/api/v1/instance`, asForm: true, - body: formData + body: formData, + discardEmpty: true }), - ...updateCacheOnMutation("instance") + ...replaceCacheOnMutation("instance") }), mediaCleanup: build.mutation({ query: (days) => ({ method: "POST", url: `/api/v1/admin/media_cleanup`, params: { - remote_cache_days: days + remote_cache_days: days + } + }) + }), + instanceBlocks: build.query({ + query: () => ({ + url: `/api/v1/admin/domain_blocks` + }) + }), + addInstanceBlock: build.mutation({ + query: (formData) => ({ + method: "POST", + url: `/api/v1/admin/domain_blocks`, + asForm: true, + body: formData, + discardEmpty: true + }), + ...appendCacheOnMutation("instanceBlocks") + }), + removeInstanceBlock: build.mutation({ + query: (id) => ({ + method: "DELETE", + url: `/api/v1/admin/domain_blocks/${id}`, + }), + ...spliceCacheOnMutation("instanceBlocks", { + findKey: (draft, newData) => { + return draft.findIndex((block) => block.id == newData.id); } }) }) }); -module.exports = base.injectEndpoints({endpoints}); \ No newline at end of file +module.exports = base.injectEndpoints({ endpoints }); \ No newline at end of file diff --git a/web/source/settings/lib/query/base.js b/web/source/settings/lib/query/base.js index 2a23f539f..8159b5cdf 100644 --- a/web/source/settings/lib/query/base.js +++ b/web/source/settings/lib/query/base.js @@ -24,12 +24,19 @@ const { convertToForm } = require("../api"); function instanceBasedQuery(args, api, extraOptions) { const state = api.getState(); - const {instance, token} = state.oauth; + const { instance, token } = state.oauth; if (args.baseUrl == undefined) { args.baseUrl = instance; } + if (args.discardEmpty) { + if (args.body == undefined || Object.keys(args.body).length == 0) { + return { data: null }; + } + delete args.discardEmpty; + } + if (args.asForm) { delete args.asForm; args.body = convertToForm(args.body); diff --git a/web/source/settings/lib/query/lib.js b/web/source/settings/lib/query/lib.js index de9da1059..631019bb2 100644 --- a/web/source/settings/lib/query/lib.js +++ b/web/source/settings/lib/query/lib.js @@ -28,16 +28,37 @@ module.exports = { return res.data; } }, - updateCacheOnMutation(queryName, arg = undefined) { - // https://redux-toolkit.js.org/rtk-query/usage/manual-cache-updates#pessimistic-updates + replaceCacheOnMutation: makeCacheMutation((draft, newData) => { + Object.assign(draft, newData); + }), + appendCacheOnMutation: makeCacheMutation((draft, newData) => { + draft.push(newData); + }), + spliceCacheOnMutation: makeCacheMutation((draft, newData, key) => { + draft.splice(key, 1); + }), + updateCacheOnMutation: makeCacheMutation((draft, newData, key) => { + draft[key] = newData; + }), + removeFromCacheOnMutation: makeCacheMutation((draft, newData, key) => { + delete draft[key]; + }) +}; + +// https://redux-toolkit.js.org/rtk-query/usage/manual-cache-updates#pessimistic-updates +function makeCacheMutation(action) { + return function cacheMutation(queryName, { key, findKey, arg } = {}) { return { - onQueryStarted: (_, { dispatch, queryFulfilled}) => { - queryFulfilled.then(({data: newData}) => { + onQueryStarted: (_, { dispatch, queryFulfilled }) => { + queryFulfilled.then(({ data: newData }) => { dispatch(base.util.updateQueryData(queryName, arg, (draft) => { - Object.assign(draft, newData); + if (findKey != undefined) { + key = findKey(draft, newData); + } + action(draft, newData, key); })); }); } }; - } -}; \ No newline at end of file + }; +} \ No newline at end of file diff --git a/web/source/settings/lib/query/user.js b/web/source/settings/lib/query/user.js index b97731f92..40b31c6e7 100644 --- a/web/source/settings/lib/query/user.js +++ b/web/source/settings/lib/query/user.js @@ -18,7 +18,7 @@ "use strict"; -const { updateCacheOnMutation } = require("./lib"); +const { replaceCacheOnMutation } = require("./lib"); const base = require("./base"); const endpoints = (build) => ({ @@ -32,9 +32,10 @@ const endpoints = (build) => ({ method: "PATCH", url: `/api/v1/accounts/update_credentials`, asForm: true, - body: formData + body: formData, + discardEmpty: true }), - ...updateCacheOnMutation("verifyCredentials") + ...replaceCacheOnMutation("verifyCredentials") }), passwordChange: build.mutation({ query: (data) => ({ @@ -45,4 +46,4 @@ const endpoints = (build) => ({ }) }); -module.exports = base.injectEndpoints({endpoints}); \ No newline at end of file +module.exports = base.injectEndpoints({ endpoints }); \ No newline at end of file diff --git a/web/source/settings/style.css b/web/source/settings/style.css index cfce9660b..c2b697f97 100644 --- a/web/source/settings/style.css +++ b/web/source/settings/style.css @@ -218,7 +218,7 @@ section.with-sidebar > div, section.with-sidebar > form { flex-direction: column; gap: 1rem; - input, textarea { + input, textarea, button { width: 100%; line-height: 1.5rem; } @@ -228,14 +228,6 @@ section.with-sidebar > div, section.with-sidebar > form { width: initial; } - input:read-only { - border: none; - } - - input:invalid { - border-color: red; - } - textarea { width: 100%; } @@ -364,7 +356,6 @@ span.form-info { .list { display: flex; flex-direction: column; - margin-top: 0.5rem; max-height: 40rem; overflow: auto; @@ -372,10 +363,19 @@ span.form-info { display: flex; flex-wrap: wrap; background: $settings-entry-bg; + border: 0.1rem solid transparent; + + &:nth-child(even) { + background: $settings-entry-alternate-bg; + } &:hover { background: $settings-entry-hover-bg; } + + &:active, &:focus, &:hover { + border-color: $fg-accent; + } } } @@ -383,15 +383,10 @@ span.form-info { .filter { display: flex; gap: 0.5rem; - - input { - width: auto; - flex: 1 1 auto; - } } .entry { - padding: 0.3rem; + padding: 0.5rem; margin: 0.2rem 0; #domain { @@ -652,4 +647,43 @@ span.form-info { } } } +} + +.info { + color: $info-fg; + background: $info-bg; + padding: 0.5rem; + border-radius: $br; + + display: flex; + gap: 0.5rem; + align-items: center; + + i { + margin-top: 0.1em; + } + + a { + color: $info-link; + } +} + +button .fa-fw { + margin-left: -1.28571429em; +} + +.fadeout { + animation-name: fadeout; + animation-duration: 0.5s; + animation-delay: 2s; + animation-fill-mode: forwards; +} + +@keyframes fadeout { + from { + opacity: 1; + } + to { + opacity: 0; + } } \ No newline at end of file diff --git a/web/source/settings/user/profile.js b/web/source/settings/user/profile.js index 85e13bdd1..3b788479c 100644 --- a/web/source/settings/user/profile.js +++ b/web/source/settings/user/profile.js @@ -51,7 +51,7 @@ module.exports = function UserProfile() { ); }; -function UserProfileForm({data: profile}) { +function UserProfileForm({ data: profile }) { /* User profile update form keys - bool bot @@ -65,18 +65,18 @@ function UserProfileForm({data: profile}) { */ const form = { - avatar: useFileInput("avatar", {withPreview: true}), - header: useFileInput("header", {withPreview: true}), - displayName: useTextInput("display_name", {defaultValue: profile.display_name}), - note: useTextInput("note", {defaultValue: profile.source?.note}), - customCSS: useTextInput("custom_css", {defaultValue: profile.custom_css}), - bot: useBoolInput("bot", {defaultValue: profile.bot}), - locked: useBoolInput("locked", {defaultValue: profile.locked}), - enableRSS: useBoolInput("enable_rss", {defaultValue: profile.enable_rss}), + avatar: useFileInput("avatar", { withPreview: true }), + header: useFileInput("header", { withPreview: true }), + displayName: useTextInput("display_name", { defaultValue: profile.display_name }), + note: useTextInput("note", { defaultValue: profile.source?.note }), + customCSS: useTextInput("custom_css", { defaultValue: profile.custom_css }), + bot: useBoolInput("bot", { defaultValue: profile.bot }), + locked: useBoolInput("locked", { defaultValue: profile.locked }), + enableRSS: useBoolInput("enable_rss", { defaultValue: profile.enable_rss }), }; const allowCustomCSS = Redux.useSelector(state => state.instances.current.configuration.accounts.allow_custom_css); - const [result, submitForm] = useFormSubmit(form, query.useUpdateCredentialsMutation()); + const [submitForm, result] = useFormSubmit(form, query.useUpdateCredentialsMutation()); return ( @@ -125,7 +125,7 @@ function UserProfileForm({data: profile}) { field={form.enableRSS} label="Enable RSS feed of Public posts" /> - { !allowCustomCSS ? null : + {!allowCustomCSS ? null : Learn more about custom profile CSS (opens in a new tab) } - + ); } \ No newline at end of file diff --git a/web/source/settings/user/settings.js b/web/source/settings/user/settings.js index 3c11a04b1..c6fd035e1 100644 --- a/web/source/settings/user/settings.js +++ b/web/source/settings/user/settings.js @@ -48,7 +48,7 @@ module.exports = function UserSettings() { ); }; -function UserSettingsForm({data: {source}}) { +function UserSettingsForm({ data: { source } }) { /* form keys - string source[privacy] - bool source[sensitive] @@ -57,20 +57,20 @@ function UserSettingsForm({data: {source}}) { */ const form = { - defaultPrivacy: useTextInput("source[privacy]", {defaultValue: source.privacy ?? "unlisted"}), - isSensitive: useBoolInput("source[sensitive]", {defaultValue: source.sensitive}), - language: useTextInput("source[language]", {defaultValue: source.language ?? "EN"}), - format: useTextInput("source[status_format]", {defaultValue: source.status_format ?? "plain"}), + defaultPrivacy: useTextInput("source[privacy]", { defaultValue: source.privacy ?? "unlisted" }), + isSensitive: useBoolInput("source[sensitive]", { defaultValue: source.sensitive }), + language: useTextInput("source[language]", { defaultValue: source.language ?? "EN" }), + format: useTextInput("source[status_format]", { defaultValue: source.status_format ?? "plain" }), }; - const [result, submitForm] = useFormSubmit(form, query.useUpdateCredentialsMutation()); + const [submitForm, result] = useFormSubmit(form, query.useUpdateCredentialsMutation()); return ( <> Post settings + }> - + - + > ); @@ -107,12 +107,14 @@ function UserSettingsForm({data: {source}}) { function PasswordChange() { const form = { oldPassword: useTextInput("old_password"), - newPassword: useTextInput("old_password", {validator(val) { - if (val != "" && val == form.oldPassword.value) { - return "New password same as old password"; + newPassword: useTextInput("old_password", { + validator(val) { + if (val != "" && val == form.oldPassword.value) { + return "New password same as old password"; + } + return ""; } - return ""; - }}) + }) }; const verifyNewPassword = useTextInput("verifyNewPassword", { @@ -124,15 +126,15 @@ function PasswordChange() { } }); - const [result, submitForm] = useFormSubmit(form, query.usePasswordChangeMutation()); + const [submitForm, result] = useFormSubmit(form, query.usePasswordChangeMutation()); return ( Change password - - - - + + + + ); } \ No newline at end of file