diff --git a/web/source/css/_colors.css b/web/source/css/_colors.css index 03632dc8c..3be552d08 100644 --- a/web/source/css/_colors.css +++ b/web/source/css/_colors.css @@ -66,6 +66,10 @@ $button-bg: $blue2; $button-fg: $gray1; $button-hover-bg: $blue3; +$button-danger-bg: $orange1; +$button-danger-fg: $gray1; +$button-danger-hover-bg: $orange2; + $toot-focus-bg: $gray5; $toot-unfocus-bg: $gray3; @@ -82,6 +86,7 @@ $boxshadow-border: 0.08rem solid $gray1; $avatar-border: $orange2; $input-bg: $gray4; +$input-disabled-bg: $gray2; $input-border: $blue1; $input-focus-border: $blue3; @@ -96,4 +101,7 @@ $settings-nav-bg-active: $orange1; $settings-nav-fg-active: $gray1; $error-fg: $error1; -$error-bg: $error2; \ No newline at end of file +$error-bg: $error2; + +$settings-entry-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 ddca1efa8..d50195465 100644 --- a/web/source/css/base.css +++ b/web/source/css/base.css @@ -162,6 +162,15 @@ main { text-align: center; font-family: 'Noto Sans', sans-serif; + &.danger { + color: $button-danger-fg; + background: $button-danger-bg; + + &:hover { + background: $button-danger-hover-bg; + } + } + &:hover { background: $button-hover-bg; } @@ -283,6 +292,10 @@ input, select, textarea { &:focus { border-color: $input-focus-border; } + + &:disabled { + background: $input-disabled-bg; + } } ::placeholder { @@ -290,11 +303,6 @@ input, select, textarea { color: $fg-reduced } -input, textarea { - padding-top: 0.1rem; - padding-bottom: 0.1rem; -} - hr { color: transparent; width: 100%; diff --git a/web/source/package.json b/web/source/package.json index 8b0bde878..1fa592883 100644 --- a/web/source/package.json +++ b/web/source/package.json @@ -18,6 +18,7 @@ "browserlist": "^1.0.1", "create-error": "^0.3.1", "css-extract": "^2.0.0", + "default-value": "^1.0.0", "dotty": "^0.1.2", "eslint-plugin-react": "^7.24.0", "express": "^4.18.1", @@ -25,6 +26,7 @@ "from2-string": "^1.1.0", "icssify": "^2.0.0", "is-plain-object": "^5.0.0", + "is-valid-domain": "^0.1.6", "js-file-download": "^0.4.12", "modern-normalize": "^1.1.0", "photoswipe": "^5.3.0", diff --git a/web/source/settings-panel/admin/federation.js b/web/source/settings-panel/admin/federation.js index 23117be9f..1363938ff 100644 --- a/web/source/settings-panel/admin/federation.js +++ b/web/source/settings-panel/admin/federation.js @@ -21,12 +21,13 @@ const Promise = require("bluebird"); const React = require("react"); const Redux = require("react-redux"); -const {Switch, Route, Link, useRoute} = require("wouter"); +const {Switch, Route, Link, Redirect, useRoute, useLocation} = require("wouter"); +const fileDownload = require("js-file-download"); -const Submit = require("../components/submit"); +const { formFields } = require("../components/form-fields"); const api = require("../lib/api"); -const adminActions = require("../redux/reducers/instances").actions; +const adminActions = require("../redux/reducers/admin").actions; const base = "/settings/admin/federation"; @@ -39,26 +40,17 @@ const base = "/settings/admin/federation"; module.exports = function AdminSettings() { const dispatch = Redux.useDispatch(); // const instance = Redux.useSelector(state => state.instances.adminSettings); - const { blockedInstances } = Redux.useSelector(state => state.admin); - - const [errorMsg, setError] = React.useState(""); - const [statusMsg, setStatus] = React.useState(""); - - const [loaded, setLoaded] = React.useState(false); + const loadedBlockedInstances = Redux.useSelector(state => state.admin.loadedBlockedInstances); React.useEffect(() => { - if (blockedInstances != undefined) { - setLoaded(true); - } else { + if (!loadedBlockedInstances ) { return Promise.try(() => { return dispatch(api.admin.fetchDomainBlocks()); - }).then(() => { - setLoaded(true); }); } }, []); - if (!loaded) { + if (!loadedBlockedInstances) { return ( <div> <h1>Federation</h1> @@ -68,28 +60,221 @@ module.exports = function AdminSettings() { } return ( - <div> - <Switch> - <Route path={`${base}/:domain`}> - <InstancePage blockedInstances={blockedInstances}/> - </Route> - <InstanceOverview blockedInstances={blockedInstances} /> - </Switch> - </div> + <Switch> + <Route path={`${base}/:domain`}> + <InstancePageWrapped /> + </Route> + <InstanceOverview /> + </Switch> ); }; -function InstanceOverview({blockedInstances}) { +function InstanceOverview() { + const [filter, setFilter] = React.useState(""); + const blockedInstances = Redux.useSelector(state => state.admin.blockedInstances); + const [_location, setLocation] = useLocation(); + + function filterFormSubmit(e) { + e.preventDefault(); + setLocation(`${base}/${filter}`); + } + return ( - <div> + <> <h1>Federation</h1> - {blockedInstances.map((entry) => { - return ( - <Link key={entry.domain} to={`${base}/${entry.domain}`}> - <a>{entry.domain}</a> - </Link> - ); - })} + Here you can see an overview of blocked instances. + + <div className="instance-list"> + <h2>Blocked instances</h2> + <form action={`${base}/view`} className="filter" role="search" onSubmit={filterFormSubmit}> + <input name="domain" value={filter} onChange={(e) => setFilter(e.target.value)}/> + <Link to={`${base}/${filter}`}><a className="button">Add block</a></Link> + </form> + <div className="list"> + {Object.values(blockedInstances).filter((a) => a.domain.startsWith(filter)).map((entry) => { + return ( + <Link key={entry.domain} to={`${base}/${entry.domain}`}> + <a className="entry nounderline"> + <span id="domain"> + {entry.domain} + </span> + <span id="date"> + {new Date(entry.created_at).toLocaleString()} + </span> + </a> + </Link> + ); + })} + </div> + </div> + + <BulkBlocking/> + </> + ); +} + +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) => { + console.error(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 ( + <div className="bulk"> + <h2>Import / Export <a onClick={resetBulk}>reset</a></h2> + <Bulk.TextArea + id="list" + name="Domains, one per line" + placeHolder={`google.com\nfacebook.com`} + /> + + <Bulk.TextArea + id="public_comment" + name="Public comment" + inputProps={disableInfoFields({rows: 3})} + /> + + <Bulk.TextArea + id="private_comment" + name="Private comment" + inputProps={disableInfoFields({rows: 3})} + /> + + <Bulk.Checkbox + id="obfuscate" + name="Obfuscate domains? " + inputProps={disableInfoFields()} + /> + + <div className="hidden"> + <Bulk.File + id="json" + fileType="application/json" + withPreview={false} + /> + </div> + + <div className="messagebutton"> + <div> + <button type="submit" onClick={importBlocks}>Import</button> + </div> + + <div> + <button type="submit" onClick={exportBlocks}>Export</button> + + <Bulk.Select id="exportType" name="Export type" options={ + <> + <option value="plain">One per line in text field</option> + <option value="json">JSON in text field</option> + <option value="json-download">JSON file download</option> + <option disabled value="csv">CSV in text field (glitch-soc)</option> + <option disabled value="csv-download">CSV file download (glitch-soc)</option> + </> + }/> + </div> + <br/> + <div> + {errorMsg.length > 0 && + <div className="error accent">{errorMsg}</div> + } + {statusMsg.length > 0 && + <div className="accent">{statusMsg}</div> + } + </div> + </div> </div> ); } @@ -102,27 +287,114 @@ function BackButton() { ); } -function InstancePage({blockedInstances}) { + +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`); - let [status, setStatus] = React.useState(""); - let [entry, setEntry] = React.useState(() => { - let entry = blockedInstances.find((a) => a.domain == domain); - - if (entry == undefined) { - setStatus(`No block entry found for ${domain}, but you can create one below:`); - return { - private_comment: "" - }; + + if (domain == "view") { // from form field submission + let realDomain = (new URL(document.location)).searchParams.get("domain"); + if (realDomain == undefined) { + return <Redirect to={base}/>; } else { - return entry; + domain = realDomain; } - }); + } + + function alterDomain([key, val]) { + return adminActions.updateDomainBlockVal([domain, key, val]); + } + + const fields = formFields(alterDomain, (state) => state.admin.blockedInstances[domain]); + + return <InstancePage domain={domain} Form={fields} />; +} + +function InstancePage({domain, Form}) { + const dispatch = Redux.useDispatch(); + const { blockedInstances } = Redux.useSelector(state => state.admin); + const entry = blockedInstances[domain]; + + React.useEffect(() => { + if (entry == undefined) { + return dispatch(adminActions.newDomainBlock(domain)); + } + }, []); + + const [errorMsg, setError] = React.useState(""); + const [statusMsg, setStatus] = React.useState(""); + + if (entry == undefined) { + if (statusMsg == "removed") { + return <Redirect to={base}/>; + } else { + return "Loading..."; + } + } + + function submit() { + setStatus("PATCHing"); + setError(""); + return Promise.try(() => { + return dispatch(api.admin.updateDomainBlock(domain)); + }).then(() => { + setStatus("Saved!"); + }).catch((e) => { + setError(e.message); + setStatus(""); + }); + } + + function removeBlock() { + setStatus("removing"); + setError(""); + return Promise.try(() => { + return dispatch(api.admin.removeDomainBlock(domain)); + }).then(() => { + setStatus("removed"); + }).catch((e) => { + setError(e.message); + setStatus(""); + }); + } return ( <div> - {status} <h1><BackButton/> Federation settings for: {domain}</h1> - <div>{entry.private_comment}</div> + {entry.new && "No stored block yet, you can add one below:"} + + <Form.TextArea + id="public_comment" + name="Public comment" + /> + + <Form.TextArea + id="private_comment" + name="Private comment" + /> + + <Form.Checkbox + id="obfuscate" + name="Obfuscate domain? " + /> + + <div className="messagebutton"> + <button type="submit" onClick={submit}>{entry.new ? "Add block" : "Save block"}</button> + + {!entry.new && + <button className="danger" onClick={removeBlock}>Remove block</button> + } + + {errorMsg.length > 0 && + <div className="error accent">{errorMsg}</div> + } + {statusMsg.length > 0 && + <div className="accent">{statusMsg}</div> + } + </div> </div> ); } \ No newline at end of file diff --git a/web/source/settings-panel/components/form-fields.jsx b/web/source/settings-panel/components/form-fields.jsx index 67c6b883a..8c48ba1a9 100644 --- a/web/source/settings-panel/components/form-fields.jsx +++ b/web/source/settings-panel/components/form-fields.jsx @@ -36,28 +36,33 @@ function eventListeners(dispatch, setter, obj) { }; }, - onFileChange: function (key) { + onFileChange: function (key, withPreview) { return function (e) { - let old = d.get(obj, key); - if (old != undefined) { - URL.revokeObjectURL(old); // no error revoking a non-Object URL as provided by instance - } let file = e.target.files[0]; - let objectURL = URL.createObjectURL(file); - dispatch(setter([key, objectURL])); + if (withPreview) { + let old = d.get(obj, key); + if (old != undefined) { + URL.revokeObjectURL(old); // no error revoking a non-Object URL as provided by instance + } + let objectURL = URL.createObjectURL(file); + dispatch(setter([key, objectURL])); + } dispatch(setter([`${key}File`, file])); }; } }; } -function get(state, id) { +function get(state, id, defaultVal) { let value; if (id.includes(".")) { value = d.get(state, id); } else { value = state[id]; } + if (value == undefined) { + value = defaultVal; + } return value; } @@ -71,7 +76,10 @@ function get(state, id) { module.exports = { formFields: function formFields(setter, selector) { - function FormField({type, id, name, className="", placeHolder="", fileType="", children=null, options=null}) { + function FormField({ + type, id, name, className="", placeHolder="", fileType="", children=null, + options=null, inputProps={}, withPreview=true + }) { const dispatch = Redux.useDispatch(); let state = Redux.useSelector(selector); let { @@ -83,14 +91,14 @@ module.exports = { let field; let defaultLabel = true; if (type == "text") { - field = <input type="text" id={id} value={get(state, id)} placeholder={placeHolder} className={className} onChange={onTextChange(id)}/>; + field = <input type="text" id={id} value={get(state, id, "")} placeholder={placeHolder} className={className} onChange={onTextChange(id)} {...inputProps}/>; } else if (type == "textarea") { - field = <textarea type="text" id={id} value={get(state, id)} placeholder={placeHolder} className={className} onChange={onTextChange(id)}/>; + field = <textarea type="text" id={id} value={get(state, id, "")} placeholder={placeHolder} className={className} onChange={onTextChange(id)} rows={8} {...inputProps}/>; } else if (type == "checkbox") { - field = <input type="checkbox" id={id} checked={get(state, id)} className={className} onChange={onCheckChange(id)}/>; + field = <input type="checkbox" id={id} checked={get(state, id, false)} className={className} onChange={onCheckChange(id)} {...inputProps}/>; } else if (type == "select") { field = ( - <select id={id} checked={get(state, id)} className={className} onChange={onTextChange(id)}> + <select id={id} value={get(state, id, "")} className={className} onChange={onTextChange(id)} {...inputProps}> {options} </select> ); @@ -101,8 +109,8 @@ module.exports = { <> <label htmlFor={id} className="file-input button">Browse</label> <span>{file ? file.name : "no file selected"}</span> - {/* <a onClick={removeFile("header")} href="#">remove</a> */} - <input className="hidden" id={id} type="file" accept={fileType} onChange={onFileChange(id)} /> + {/* <a onClick={removeFile("header")}>remove</a> */} + <input className="hidden" id={id} type="file" accept={fileType} onChange={onFileChange(id, withPreview)} {...inputProps}/> </> ); } else { diff --git a/web/source/settings-panel/lib/api/admin.js b/web/source/settings-panel/lib/api/admin.js index cf44fc6ef..4d463433b 100644 --- a/web/source/settings-panel/lib/api/admin.js +++ b/web/source/settings-panel/lib/api/admin.js @@ -19,12 +19,13 @@ "use strict"; const Promise = require("bluebird"); +const isValidDomain = require("is-valid-domain"); const instance = require("../../redux/reducers/instances").actions; const admin = require("../../redux/reducers/admin").actions; module.exports = function ({ apiCall, getChanges }) { - return { + const adminAPI = { updateInstance: function updateInstance() { return function (dispatch, getState) { return Promise.try(() => { @@ -50,6 +51,95 @@ module.exports = function ({ apiCall, getChanges }) { return dispatch(admin.setBlockedInstances(data)); }); }; - } + }, + + updateDomainBlock: function updateDomainBlock(domain) { + return function (dispatch, getState) { + return Promise.try(() => { + const state = getState().admin.blockedInstances[domain]; + const update = getChanges(state, { + formKeys: ["domain", "obfuscate", "public_comment", "private_comment"], + }); + + return dispatch(apiCall("POST", "/api/v1/admin/domain_blocks", update, "form")); + }).then((block) => { + console.log(block); + }); + }; + }, + + bulkDomainBlock: function bulkDomainBlock() { + return function (dispatch, getState) { + let invalidDomains = []; + let success = 0; + + return Promise.try(() => { + const state = getState().admin.bulkBlock; + let list = state.list; + let domains; + + let fields = getChanges(state, { + formKeys: ["obfuscate", "public_comment", "private_comment"] + }); + + let defaultDate = new Date().toUTCString(); + + if (list[0] == "[") { + domains = JSON.parse(state.list); + } else { + domains = list.split("\n").map((line_) => { + let line = line_.trim(); + if (line.length == 0) { + return null; + } + + if (!isValidDomain(line, {wildcard: true, allowUnicode: true})) { + invalidDomains.push(line); + return null; + } + + return { + domain: line, + created_at: defaultDate, + ...fields + }; + }).filter((a) => a != null); + } + + if (domains.length == 0) { + return; + } + console.log(domains); + + const update = { + domains: new Blob([JSON.stringify(domains)], {type: "application/json"}) + }; + + return dispatch(apiCall("POST", "/api/v1/admin/domain_blocks?import=true", update, "form")); + }).then((blocks) => { + if (blocks != undefined) { + return Promise.each(blocks, (block) => { + success += 1; + return dispatch(admin.setDomainBlock([block.domain, block])); + }); + } + }).then(() => { + return { + success, + invalidDomains + }; + }); + }; + }, + + removeDomainBlock: function removeDomainBlock(domain) { + return function (dispatch, getState) { + return Promise.try(() => { + const id = getState().admin.blockedInstances[domain].id; + return dispatch(apiCall("DELETE", `/api/v1/admin/domain_blocks/${id}`)); + }); + }; + }, }; + return adminAPI; }; \ No newline at end of file diff --git a/web/source/settings-panel/lib/api/index.js b/web/source/settings-panel/lib/api/index.js index 9b2e6e5fe..25b54d252 100644 --- a/web/source/settings-panel/lib/api/index.js +++ b/web/source/settings-panel/lib/api/index.js @@ -35,7 +35,9 @@ function apiCall(method, route, payload, type = "json") { return Promise.try(() => { let url = new URL(base); - url.pathname = route; + let [path, query] = route.split("?"); + url.pathname = path; + url.search = query; let body; let headers = { diff --git a/web/source/settings-panel/redux/reducers/admin.js b/web/source/settings-panel/redux/reducers/admin.js index f8f4e994e..95206885b 100644 --- a/web/source/settings-panel/redux/reducers/admin.js +++ b/web/source/settings-panel/redux/reducers/admin.js @@ -18,8 +18,8 @@ "use strict"; -const {createSlice} = require("@reduxjs/toolkit"); -// const d = require("dotty"); +const { createSlice } = require("@reduxjs/toolkit"); +const defaultValue = require("default-value"); function sortBlocks(blocks) { return blocks.sort((a, b) => { // alphabetical sort @@ -27,23 +27,66 @@ function sortBlocks(blocks) { }); } -// function deduplicateBlocks(blocks) { -// let a = new Map(); -// blocks.forEach((block) => { -// a.set(block.id, block); -// }); -// return Array.from(a.values()); -// } +function emptyBlock() { + return { + public_comment: "", + private_comment: "", + obfuscate: false + }; +} module.exports = createSlice({ name: "admin", initialState: { + loadedBlockedInstances: false, blockedInstances: undefined, - blockedInstancesMap: {} + bulkBlock: { + list: "", + exportType: "plain", + ...emptyBlock() + } }, reducers: { - setBlockedInstances: (state, {payload}) => { - state.blockedInstances = sortBlocks(payload); + setBlockedInstances: (state, { payload }) => { + state.blockedInstances = {}; + sortBlocks(payload).forEach((entry) => { + state.blockedInstances[entry.domain] = entry; + }); + state.loadedBlockedInstances = true; }, + + newDomainBlock: (state, { payload: domain }) => { + state.blockedInstances[domain] = { + domain, + new: true, + ...emptyBlock() + }; + }, + + setDomainBlock: (state, { payload: [domain, data = {}] }) => { + state.blockedInstances[domain] = data; + }, + + updateDomainBlockVal: (state, { payload: [domain, key, val] }) => { + state.blockedInstances[domain][key] = val; + }, + + updateBulkBlockVal: (state, { payload: [key, val] }) => { + state.bulkBlock[key] = val; + }, + + resetBulkBlockVal: (state, { _payload }) => { + state.bulkBlock = { + list: "", + exportType: "plain", + ...emptyBlock() + }; + }, + + exportToField: (state, { _payload }) => { + state.bulkBlock.list = Object.values(state.blockedInstances).map((entry) => { + return entry.domain; + }).join("\n"); + } } }); \ No newline at end of file diff --git a/web/source/settings-panel/style.css b/web/source/settings-panel/style.css index 507f09d04..cd40930c0 100644 --- a/web/source/settings-panel/style.css +++ b/web/source/settings-panel/style.css @@ -38,6 +38,32 @@ section { border-left: none; border-top-left-radius: 0; border-bottom-left-radius: 0; + + & > div { + border-left: 0.2rem solid $border-accent; + padding-left: 0.4rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + margin: 2rem 0; + + h2 { + margin: 0; + margin-bottom: 0.5rem; + } + + &:only-child { + border-left: none; + } + + &:first-child { + margin-top: 0; + } + + &:last-child { + margin-bottom: 0; + } + } } .sidebar { @@ -156,13 +182,18 @@ input, select, textarea { display: none; } -.messagebutton { - margin-top: 1rem; +.messagebutton, .messagebutton > div { display: flex; - gap: 1rem; align-items: center; + flex-wrap: wrap; - button { + div.padded { + margin-left: 1rem; + } + + button, .button { + margin-top: 1rem; + margin-right: 1rem; white-space: nowrap; } } @@ -203,7 +234,6 @@ section.with-sidebar > div { textarea { width: 100%; - height: 8rem; } h1 { @@ -324,3 +354,51 @@ section.with-sidebar > div { } } } + +.form-field label { + font-weight: bold; +} + +.instance-list { + .filter { + display: flex; + gap: 0.5rem; + + input { + width: auto; + flex: 1 1 auto; + } + } + + .list { + display: flex; + flex-direction: column; + margin-top: 0.5rem; + max-height: 40rem; + overflow: auto; + } + + .entry { + padding: 0.3rem; + margin: 0.2rem 0; + background: $settings-entry-bg; + + display: flex; + + #domain { + flex: 1 1 auto; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + &:hover { + background: $settings-entry-hover-bg; + } + } +} + +.bulk h2 { + display: flex; + justify-content: space-between; +} diff --git a/web/source/settings-panel/user/settings.js b/web/source/settings-panel/user/settings.js index 86b1435e7..29f382cb9 100644 --- a/web/source/settings-panel/user/settings.js +++ b/web/source/settings-panel/user/settings.js @@ -53,38 +53,41 @@ module.exports = function UserSettings() { } return ( - <div className="user-settings"> - <h1>Post settings</h1> - <Select id="language" name="Default post language" options={ - <Languages/> - }> - </Select> - <Select id="privacy" name="Default post privacy" options={ - <> - <option value="private">Private / followers-only)</option> - <option value="unlisted">Unlisted</option> - <option value="public">Public</option> - </> - }> - <a href="https://docs.gotosocial.org/en/latest/user_guide/posts/#privacy-settings" target="_blank" className="moreinfolink" rel="noreferrer">Learn more about post privacy settings (opens in a new tab)</a> - </Select> - <Select id="format" name="Default post format" options={ - <> - <option value="plain">Plain (default)</option> - <option value="markdown">Markdown</option> - </> - }> - <a href="https://docs.gotosocial.org/en/latest/user_guide/posts/#input-types" target="_blank" className="moreinfolink" rel="noreferrer">Learn more about post format settings (opens in a new tab)</a> - </Select> - <Checkbox - id="sensitive" - name="Mark my posts as sensitive by default" - /> + <> + <div className="user-settings"> + <h1>Post settings</h1> + <Select id="language" name="Default post language" options={ + <Languages/> + }> + </Select> + <Select id="privacy" name="Default post privacy" options={ + <> + <option value="private">Private / followers-only</option> + <option value="unlisted">Unlisted</option> + <option value="public">Public</option> + </> + }> + <a href="https://docs.gotosocial.org/en/latest/user_guide/posts/#privacy-settings" target="_blank" className="moreinfolink" rel="noreferrer">Learn more about post privacy settings (opens in a new tab)</a> + </Select> + <Select id="format" name="Default post format" options={ + <> + <option value="plain">Plain (default)</option> + <option value="markdown">Markdown</option> + </> + }> + <a href="https://docs.gotosocial.org/en/latest/user_guide/posts/#input-types" target="_blank" className="moreinfolink" rel="noreferrer">Learn more about post format settings (opens in a new tab)</a> + </Select> + <Checkbox + id="sensitive" + name="Mark my posts as sensitive by default" + /> - <Submit onClick={submit} label="Save post settings" errorMsg={errorMsg} statusMsg={statusMsg}/> - <hr/> - <PasswordChange/> - </div> + <Submit onClick={submit} label="Save post settings" errorMsg={errorMsg} statusMsg={statusMsg}/> + </div> + <div> + <PasswordChange/> + </div> + </> ); }; diff --git a/web/source/yarn.lock b/web/source/yarn.lock index e5e1122d0..70f582e23 100644 --- a/web/source/yarn.lock +++ b/web/source/yarn.lock @@ -3730,6 +3730,13 @@ is-typed-array@^1.1.3, is-typed-array@^1.1.9: for-each "^0.3.3" has-tostringtag "^1.0.0" +is-valid-domain@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-valid-domain/-/is-valid-domain-0.1.6.tgz#3c85469d2938f170c8f82ce6e52df8ad9fca8105" + integrity sha512-ZKtq737eFkZr71At8NxOFcP9O1K89gW3DkdrGMpp1upr/ueWjj+Weh4l9AI4rN0Gt8W2M1w7jrG2b/Yv83Ljpg== + dependencies: + punycode "^2.1.1" + is-weakref@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2" @@ -4657,7 +4664,7 @@ punycode@^1.3.2: resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" integrity sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ== -punycode@^2.1.0: +punycode@^2.1.0, punycode@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==