diff --git a/web/source/css/base.css b/web/source/css/base.css index 5539c4fc9..98c16acdd 100644 --- a/web/source/css/base.css +++ b/web/source/css/base.css @@ -340,4 +340,8 @@ footer { margin: -0.5ex 0 0; object-fit: contain; vertical-align: middle; +} + +.monospace { + font-family: monospace; } \ 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 new file mode 100644 index 000000000..a3b19bc17 --- /dev/null +++ b/web/source/settings-panel/components/form-fields.jsx @@ -0,0 +1,138 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +"use strict"; + +const React = require("react"); +const Redux = require("react-redux"); +const d = require("dotty"); + +function eventListeners(dispatch, setter, obj) { + return { + onTextChange: function (key) { + return function (e) { + dispatch(setter([key, e.target.value])); + }; + }, + + onCheckChange: function (key) { + return function (e) { + dispatch(setter([key, e.target.checked])); + }; + }, + + onFileChange: function (key) { + 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])); + dispatch(setter([`${key}File`, file])); + }; + } + }; +} + +function get(state, id) { + let value; + if (id.includes(".")) { + value = d.get(state, id); + } else { + value = state[id]; + } + return value; +} + +// function removeFile(name) { +// return function(e) { +// e.preventDefault(); +// dispatch(user.setProfileVal([name, ""])); +// dispatch(user.setProfileVal([`${name}File`, ""])); +// }; +// } + +module.exports = { + formFields: function formFields(setter, selector) { + function FormField({type, id, name, className="", placeHolder="", fileType="", children=null}) { + const dispatch = Redux.useDispatch(); + let state = Redux.useSelector(selector); + let { + onTextChange, + onCheckChange, + onFileChange + } = eventListeners(dispatch, setter, state); + + 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)}/>; + } else if (type == "textarea") { + field = <textarea type="text" id={id} value={get(state, id)} placeholder={placeHolder} className={className} onChange={onTextChange(id)}/>; + } else if (type == "checkbox") { + field = <input type="checkbox" id={id} checked={get(state, id)} className={className} onChange={onCheckChange(id)}/>; + } else if (type == "file") { + defaultLabel = false; + let file = get(state, `${id}File`); + field = ( + <> + <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)} /> + </> + ); + } else { + defaultLabel = false; + field = `unsupported FormField ${type}, this is a developer error`; + } + + let label = <label htmlFor={id}>{name}</label>; + + return ( + <div className={`form-field ${type}`}> + {defaultLabel ? label : null} + {field} + {children} + </div> + ); + } + + return { + TextInput: function(props) { + return <FormField type="text" {...props} />; + }, + + TextArea: function(props) { + return <FormField type="textarea" {...props} />; + }, + + Checkbox: function(props) { + return <FormField type="checkbox" {...props} />; + }, + + File: function(props) { + return <FormField type="file" {...props} />; + }, + }; + }, + + eventListeners +}; \ 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 6a99ed1e9..3a68a8a26 100644 --- a/web/source/settings-panel/lib/api/index.js +++ b/web/source/settings-panel/lib/api/index.js @@ -20,23 +20,24 @@ const Promise = require("bluebird"); const { isPlainObject } = require("is-plain-object"); +const d = require("dotty"); const { APIError } = require("../errors"); const { setInstanceInfo, setNamedInstanceInfo } = require("../../redux/reducers/instances").actions; const oauth = require("../../redux/reducers/oauth").actions; -function apiCall(method, route, payload, type="json") { +function apiCall(method, route, payload, type = "json") { return function (dispatch, getState) { const state = getState(); let base = state.oauth.instance; let auth = state.oauth.token; console.log(method, base, route, "auth:", auth != undefined); - + return Promise.try(() => { let url = new URL(base); url.pathname = route; let body; - + let headers = { "Accept": "application/json", }; @@ -50,20 +51,24 @@ function apiCall(method, route, payload, type="json") { Object.entries(payload).forEach(([key, val]) => { if (isPlainObject(val)) { Object.entries(val).forEach(([key2, val2]) => { - formData.set(`${key}[${key2}]`, val2); + if (val2 != undefined) { + formData.set(`${key}[${key2}]`, val2); + } }); } else { - formData.set(key, val); + if (val != undefined) { + formData.set(key, val); + } } }); body = formData; } } - + if (auth != undefined) { headers["Authorization"] = auth; } - + return fetch(url.toString(), { method, headers, @@ -74,7 +79,7 @@ function apiCall(method, route, payload, type="json") { let json = res.json().catch((e) => { throw new APIError(`JSON parsing error: ${e.message}`); }); - + return Promise.all([res, json]); }).then(([res, json]) => { if (!res.ok) { @@ -83,7 +88,7 @@ function apiCall(method, route, payload, type="json") { dispatch(oauth.remove()); throw new APIError("Stored OAUTH login was no longer valid, please log in again."); } - throw new APIError(json.error, {json}); + throw new APIError(json.error, { json }); } else { return json; } @@ -91,12 +96,40 @@ function apiCall(method, route, payload, type="json") { }; } +function getChanges(state, keys) { + const { formKeys = [], fileKeys = [], renamedKeys = {} } = keys; + const update = {}; + + formKeys.forEach((key) => { + let value = d.get(state, key); + if (value == undefined) { + return; + } + if (renamedKeys[key]) { + key = renamedKeys[key]; + } + d.put(update, key, value); + }); + + fileKeys.forEach((key) => { + let file = d.get(state, `${key}File`); + if (file != undefined) { + if (renamedKeys[key]) { + key = renamedKeys[key]; + } + d.put(update, key, file); + } + }); + + return update; +} + function getCurrentUrl() { return `${window.location.origin}${window.location.pathname}`; } function fetchInstanceWithoutStore(domain) { - return function(dispatch, getState) { + return function (dispatch, getState) { return Promise.try(() => { let lookup = getState().instances.info[domain]; if (lookup != undefined) { @@ -107,7 +140,7 @@ function fetchInstanceWithoutStore(domain) { // but we don't want to store it there yet // so we mock the API here with our function argument let fakeState = { - oauth: {instance: domain} + oauth: { instance: domain } }; return apiCall("GET", "/api/v1/instance")(dispatch, () => fakeState); @@ -121,7 +154,7 @@ function fetchInstanceWithoutStore(domain) { } function fetchInstance() { - return function(dispatch, _getState) { + return function (dispatch, _getState) { return Promise.try(() => { return dispatch(apiCall("GET", "/api/v1/instance")); }).then((json) => { @@ -133,12 +166,15 @@ function fetchInstance() { }; } +let submoduleArgs = { apiCall, getCurrentUrl, getChanges }; + module.exports = { instance: { fetchWithoutStore: fetchInstanceWithoutStore, fetch: fetchInstance }, - oauth: require("./oauth")({apiCall, getCurrentUrl}), - user: require("./user")({apiCall}), - apiCall + oauth: require("./oauth")(submoduleArgs), + user: require("./user")(submoduleArgs), + apiCall, + getChanges }; \ No newline at end of file diff --git a/web/source/settings-panel/lib/api/user.js b/web/source/settings-panel/lib/api/user.js index a24bccaff..a2967c449 100644 --- a/web/source/settings-panel/lib/api/user.js +++ b/web/source/settings-panel/lib/api/user.js @@ -23,28 +23,13 @@ const d = require("dotty"); const user = require("../../redux/reducers/user").actions; -module.exports = function ({ apiCall }) { - function updateCredentials(selector, {formKeys=[], renamedKeys=[], fileKeys=[]}) { +module.exports = function ({ apiCall, getChanges }) { + function updateCredentials(selector, keys) { return function (dispatch, getState) { return Promise.try(() => { const state = selector(getState()); - const update = {}; - - formKeys.forEach((key) => { - d.put(update, key, d.get(state, key)); - }); - - renamedKeys.forEach(([sendKey, intKey]) => { - d.put(update, sendKey, d.get(state, intKey)); - }); - - fileKeys.forEach((key) => { - let file = d.get(state, `${key}File`); - if (file != undefined) { - d.put(update, key, file); - } - }); + const update = getChanges(state, keys); return dispatch(apiCall("PATCH", "/api/v1/accounts/update_credentials", update, "form")); }).then((account) => { @@ -63,13 +48,17 @@ module.exports = function ({ apiCall }) { }); }; }, + updateProfile: function updateProfile() { - const formKeys = ["display_name", "locked", "source", "custom_css"]; - const renamedKeys = [["note", "source.note"]]; + const formKeys = ["display_name", "locked", "source", "custom_css", "source.note"]; + const renamedKeys = { + "source.note": "note" + }; const fileKeys = ["header", "avatar"]; return updateCredentials((state) => state.user.profile, {formKeys, renamedKeys, fileKeys}); }, + updateSettings: function updateProfile() { const formKeys = ["source"]; diff --git a/web/source/settings-panel/style.css b/web/source/settings-panel/style.css index 68350239c..4d1d88da2 100644 --- a/web/source/settings-panel/style.css +++ b/web/source/settings-panel/style.css @@ -277,7 +277,7 @@ section.with-sidebar > div { flex-direction: column; justify-content: center; - div.picker { + div.form-field { width: 100%; display: flex; diff --git a/web/source/settings-panel/user/profile.js b/web/source/settings-panel/user/profile.js index f06f0e667..05a1249c5 100644 --- a/web/source/settings-panel/user/profile.js +++ b/web/source/settings-panel/user/profile.js @@ -25,9 +25,17 @@ const Redux = require("react-redux"); const Submit = require("../components/submit"); const api = require("../lib/api"); -const formFields = require("../lib/form-fields"); const user = require("../redux/reducers/user").actions; +const { formFields } = require("../components/form-fields"); + +const { + TextInput, + TextArea, + Checkbox, + File +} = formFields(user.setProfileVal, (state) => state.user.profile); + module.exports = function UserProfile() { const dispatch = Redux.useDispatch(); const account = Redux.useSelector(state => state.user.profile); @@ -53,14 +61,6 @@ module.exports = function UserProfile() { }); } - // function removeFile(name) { - // return function(e) { - // e.preventDefault(); - // dispatch(user.setProfileVal([name, ""])); - // dispatch(user.setProfileVal([`${name}File`, ""])); - // }; - // } - return ( <div className="user-profile"> <h1>Profile</h1> @@ -79,42 +79,42 @@ module.exports = function UserProfile() { <div className="files"> <div> <h3>Header</h3> - <div className="picker"> - <label htmlFor="header" className="file-input button">Browse</label> - <span>{account.headerFile ? account.headerFile.name : "no file selected"}</span> - </div> - {/* <a onClick={removeFile("header")} href="#">remove</a> */} - <input className="hidden" id="header" type="file" accept="image/*" onChange={onFileChange("header")} /> + <File + id="header" + fileType="image/*" + /> </div> <div> <h3>Avatar</h3> - <div className="picker"> - <label htmlFor="avatar" className="file-input button">Browse</label> - <span>{account.avatarFile ? account.avatarFile.name : "no file selected"}</span> - </div> - {/* <a onClick={removeFile("avatar")} href="#">remove</a> */} - <input className="hidden" id="avatar" type="file" accept="image/*" onChange={onFileChange("avatar")} /> + <File + id="avatar" + fileType="image/*" + /> </div> </div> </div> - <div className="labelinput"> - <label htmlFor="displayname">Name</label> - <input id="displayname" type="text" value={account.display_name} onChange={onTextChange("display_name")} placeholder="A GoToSocial user" /> - </div> - <div className="labelinput"> - <label htmlFor="bio">Bio</label> - <textarea id="bio" value={account.source.note} onChange={onTextChange("source.note")} placeholder="Just trying out GoToSocial, my pronouns are they/them and I like sloths." /> - </div> - <div className="labelcheckbox"> - <label htmlFor="locked">Manually approve follow requests?</label> - <input id="locked" type="checkbox" checked={account.locked} onChange={onCheckChange("locked")} /> - </div> + <TextInput + id="display_name" + name="Name" + placeHolder="A GoToSocial user" + /> + <TextArea + id="source.note" + name="Bio" + placeHolder="Just trying out GoToSocial, my pronouns are they/them and I like sloths." + /> + <Checkbox + id="locked" + name="Manually approve follow requests? " + /> { !allowCustomCSS ? null : - <div className="labelinput"> - <label htmlFor="customcss">Custom CSS</label> - <textarea className="mono" id="customcss" value={account.custom_css} onChange={onTextChange("custom_css")}/> - <a href="https://docs.gotosocial.org/en/latest/user_guide/custom_css" target="_blank" className="moreinfolink" rel="noreferrer">Learn more about custom CSS (opens in a new tab)</a> - </div> + <TextArea + id="custom_css" + name="Custom CSS" + className="monospace" + > + <a href="https://docs.gotosocial.org/en/latest/user_guide/custom_css" target="_blank" className="moreinfolink" rel="noreferrer">Learn more about custom profile CSS (opens in a new tab)</a> + </TextArea> } <Submit onClick={submit} label="Save profile info" errorMsg={errorMsg} statusMsg={statusMsg} /> </div>