summaryrefslogtreecommitdiff
path: root/web/source/panels
diff options
context:
space:
mode:
Diffstat (limited to 'web/source/panels')
-rw-r--r--web/source/panels/admin/README.md21
-rw-r--r--web/source/panels/admin/auth.js96
-rw-r--r--web/source/panels/admin/blocks.js318
-rw-r--r--web/source/panels/admin/index.js95
-rw-r--r--web/source/panels/admin/settings.js175
-rw-r--r--web/source/panels/admin/style.css141
-rw-r--r--web/source/panels/user/index.js31
7 files changed, 877 insertions, 0 deletions
diff --git a/web/source/panels/admin/README.md b/web/source/panels/admin/README.md
new file mode 100644
index 000000000..9a4572270
--- /dev/null
+++ b/web/source/panels/admin/README.md
@@ -0,0 +1,21 @@
+# GoToSocial Admin Panel
+
+Standalone web admin panel for [GoToSocial](https://github.com/superseriousbusiness/gotosocial).
+
+A public hosted instance is also available at https://gts.superseriousbusiness.org/admin/, so you can fill your own instance URL in there.
+
+## Installation
+Build requirements: some version of Node.js with npm,
+```
+git clone https://github.com/superseriousbusiness/gotosocial-admin.git && cd gotosocial-admin
+npm install
+node index.js
+```
+All processed build output will now be in `public/`, which you can copy over to a folder in your GoToSocial installation like `web/assets/admin`, or serve elsewhere.
+No further configuration is required, authentication happens through normal OAUTH flow.
+
+## Development
+Follow the installation steps, but run `NODE_ENV=development node index.js` to start the livereloading dev server instead.
+
+## License, donations
+[AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.html). If you want to support my work, you can: <a href="https://liberapay.com/f0x/donate"><img alt="Donate using Liberapay" src="https://liberapay.com/assets/widgets/donate.svg"></a> \ No newline at end of file
diff --git a/web/source/panels/admin/auth.js b/web/source/panels/admin/auth.js
new file mode 100644
index 000000000..e26ff06b9
--- /dev/null
+++ b/web/source/panels/admin/auth.js
@@ -0,0 +1,96 @@
+/*
+ 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 Promise = require("bluebird");
+const React = require("react");
+const oauthLib = require("../../lib/oauth");
+
+module.exports = function Auth({setOauth}) {
+ const [ instance, setInstance ] = React.useState("");
+
+ React.useEffect(() => {
+ let isStillMounted = true;
+ // check if current domain runs an instance
+ let thisUrl = new URL(window.location.origin);
+ thisUrl.pathname = "/api/v1/instance";
+ Promise.try(() => {
+ return fetch(thisUrl.href);
+ }).then((res) => {
+ if (res.status == 200) {
+ return res.json();
+ }
+ }).then((json) => {
+ if (json && json.uri && isStillMounted) {
+ setInstance(json.uri);
+ }
+ }).catch((e) => {
+ console.log("error checking instance response:", e);
+ });
+
+ return () => {
+ // cleanup function
+ isStillMounted = false;
+ };
+ }, []);
+
+ function doAuth() {
+ return Promise.try(() => {
+ return new URL(instance);
+ }).catch(TypeError, () => {
+ return new URL(`https://${instance}`);
+ }).then((parsedURL) => {
+ let url = parsedURL.toString();
+ let oauth = oauthLib({
+ instance: url,
+ client_name: "GoToSocial Admin Panel",
+ scope: ["admin"],
+ website: window.location.href
+ });
+ setOauth(oauth);
+ setInstance(url);
+ return oauth.register().then(() => {
+ return oauth;
+ });
+ }).then((oauth) => {
+ return oauth.authorize();
+ }).catch((e) => {
+ console.log("error authenticating:", e);
+ });
+ }
+
+ function updateInstance(e) {
+ if (e.key == "Enter") {
+ doAuth();
+ } else {
+ setInstance(e.target.value);
+ }
+ }
+
+ return (
+ <section className="login">
+ <h1>OAUTH Login:</h1>
+ <form onSubmit={(e) => e.preventDefault()}>
+ <label htmlFor="instance">Instance: </label>
+ <input value={instance} onChange={updateInstance} id="instance"/>
+ <button onClick={doAuth}>Authenticate</button>
+ </form>
+ </section>
+ );
+}; \ No newline at end of file
diff --git a/web/source/panels/admin/blocks.js b/web/source/panels/admin/blocks.js
new file mode 100644
index 000000000..b12eb50a9
--- /dev/null
+++ b/web/source/panels/admin/blocks.js
@@ -0,0 +1,318 @@
+/*
+ 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 Promise = require("bluebird");
+const React = require("react");
+const fileDownload = require("js-file-download");
+
+function sortBlocks(blocks) {
+ return blocks.sort((a, b) => { // alphabetical sort
+ return a.domain.localeCompare(b.domain);
+ });
+}
+
+function deduplicateBlocks(blocks) {
+ let a = new Map();
+ blocks.forEach((block) => {
+ a.set(block.id, block);
+ });
+ return Array.from(a.values());
+}
+
+module.exports = function Blocks({oauth}) {
+ const [blocks, setBlocks] = React.useState([]);
+ const [info, setInfo] = React.useState("Fetching blocks");
+ const [errorMsg, setError] = React.useState("");
+ const [checked, setChecked] = React.useState(new Set());
+
+ React.useEffect(() => {
+ Promise.try(() => {
+ return oauth.apiRequest("/api/v1/admin/domain_blocks", undefined, undefined, "GET");
+ }).then((json) => {
+ setInfo("");
+ setError("");
+ setBlocks(sortBlocks(json));
+ }).catch((e) => {
+ setError(e.message);
+ setInfo("");
+ });
+ }, []);
+
+ let blockList = blocks.map((block) => {
+ function update(e) {
+ let newChecked = new Set(checked.values());
+ if (e.target.checked) {
+ newChecked.add(block.id);
+ } else {
+ newChecked.delete(block.id);
+ }
+ setChecked(newChecked);
+ }
+
+ return (
+ <React.Fragment key={block.id}>
+ <div><input type="checkbox" onChange={update} checked={checked.has(block.id)}></input></div>
+ <div>{block.domain}</div>
+ <div>{(new Date(block.created_at)).toLocaleString()}</div>
+ </React.Fragment>
+ );
+ });
+
+ function clearChecked() {
+ setChecked(new Set());
+ }
+
+ function undoChecked() {
+ let amount = checked.size;
+ if(confirm(`Are you sure you want to remove ${amount} block(s)?`)) {
+ setInfo("");
+ Promise.map(Array.from(checked.values()), (block) => {
+ console.log("deleting", block);
+ return oauth.apiRequest(`/api/v1/admin/domain_blocks/${block}`, "DELETE");
+ }).then((res) => {
+ console.log(res);
+ setInfo(`Deleted ${amount} blocks: ${res.map((a) => a.domain).join(", ")}`);
+ }).catch((e) => {
+ setError(e);
+ });
+
+ let newBlocks = blocks.filter((block) => {
+ if (checked.size > 0 && checked.has(block.id)) {
+ checked.delete(block.id);
+ return false;
+ } else {
+ return true;
+ }
+ });
+ setBlocks(newBlocks);
+ clearChecked();
+ }
+ }
+
+ return (
+ <section className="blocks">
+ <h1>Blocks</h1>
+ <div className="error accent">{errorMsg}</div>
+ <div>{info}</div>
+ <AddBlock oauth={oauth} blocks={blocks} setBlocks={setBlocks} />
+ <h3>Blocks:</h3>
+ <div style={{display: "grid", gridTemplateColumns: "1fr auto"}}>
+ <span onClick={clearChecked} className="accent" style={{alignSelf: "end"}}>uncheck all</span>
+ <button onClick={undoChecked}>Unblock selected</button>
+ </div>
+ <div className="blocklist overflow">
+ {blockList}
+ </div>
+ <BulkBlocking oauth={oauth} blocks={blocks} setBlocks={setBlocks}/>
+ </section>
+ );
+};
+
+function BulkBlocking({oauth, blocks, setBlocks}) {
+ const [bulk, setBulk] = React.useState("");
+ const [blockMap, setBlockMap] = React.useState(new Map());
+ const [output, setOutput] = React.useState();
+
+ React.useEffect(() => {
+ let newBlockMap = new Map();
+ blocks.forEach((block) => {
+ newBlockMap.set(block.domain, block);
+ });
+ setBlockMap(newBlockMap);
+ }, [blocks]);
+
+ const fileRef = React.useRef();
+
+ function error(e) {
+ setOutput(<div className="error accent">{e}</div>);
+ throw e;
+ }
+
+ function fileUpload() {
+ let reader = new FileReader();
+ reader.addEventListener("load", (e) => {
+ try {
+ // TODO: use validatem?
+ let json = JSON.parse(e.target.result);
+ json.forEach((block) => {
+ console.log("block:", block);
+ });
+ } catch(e) {
+ error(e.message);
+ }
+ });
+ reader.readAsText(fileRef.current.files[0]);
+ }
+
+ React.useEffect(() => {
+ if (fileRef && fileRef.current) {
+ fileRef.current.addEventListener("change", fileUpload);
+ }
+ return function cleanup() {
+ fileRef.current.removeEventListener("change", fileUpload);
+ };
+ });
+
+ function textImport() {
+ Promise.try(() => {
+ if (bulk[0] == "[") {
+ // assume it's json
+ return JSON.parse(bulk);
+ } else {
+ return bulk.split("\n").map((val) => {
+ return {
+ domain: val.trim()
+ };
+ });
+ }
+ }).then((domains) => {
+ console.log(domains);
+ let before = domains.length;
+ setOutput(`Importing ${before} domain(s)`);
+ domains = domains.filter(({domain}) => {
+ return (domain != "" && !blockMap.has(domain));
+ });
+ setOutput(<span>{output}<br/>{`Deduplicated ${before - domains.length}/${before} with existing blocks, adding ${domains.length} block(s)`}</span>);
+ if (domains.length > 0) {
+ let data = new FormData();
+ data.append("domains", new Blob([JSON.stringify(domains)], {type: "application/json"}), "import.json");
+ return oauth.apiRequest("/api/v1/admin/domain_blocks?import=true", "POST", data, "form");
+ }
+ }).then((json) => {
+ console.log("bulk import result:", json);
+ setBlocks(sortBlocks(deduplicateBlocks([...json, ...blocks])));
+ }).catch((e) => {
+ error(e.message);
+ });
+ }
+
+ function textExport() {
+ setBulk(blocks.reduce((str, val) => {
+ if (typeof str == "object") {
+ return str.domain;
+ } else {
+ return str + "\n" + val.domain;
+ }
+ }));
+ }
+
+ function jsonExport() {
+ Promise.try(() => {
+ return oauth.apiRequest("/api/v1/admin/domain_blocks?export=true", "GET");
+ }).then((json) => {
+ fileDownload(JSON.stringify(json), "block-export.json");
+ }).catch((e) => {
+ error(e);
+ });
+ }
+
+ function textAreaUpdate(e) {
+ setBulk(e.target.value);
+ }
+
+ return (
+ <React.Fragment>
+ <h3>Bulk import/export</h3>
+ <label htmlFor="bulk">Domains, one per line:</label>
+ <textarea value={bulk} rows={20} onChange={textAreaUpdate}></textarea>
+ <div className="controls">
+ <button onClick={textImport}>Import All From Field</button>
+ <button onClick={textExport}>Export To Field</button>
+ <label className="button" htmlFor="upload">Upload .json</label>
+ <button onClick={jsonExport}>Download .json</button>
+ </div>
+ {output}
+ <input type="file" id="upload" className="hidden" ref={fileRef}></input>
+ </React.Fragment>
+ );
+}
+
+function AddBlock({oauth, blocks, setBlocks}) {
+ const [domain, setDomain] = React.useState("");
+ const [type, setType] = React.useState("suspend");
+ const [obfuscated, setObfuscated] = React.useState(false);
+ const [privateDescription, setPrivateDescription] = React.useState("");
+ const [publicDescription, setPublicDescription] = React.useState("");
+
+ function addBlock() {
+ console.log(`${type}ing`, domain);
+ Promise.try(() => {
+ return oauth.apiRequest("/api/v1/admin/domain_blocks", "POST", {
+ domain: domain,
+ obfuscate: obfuscated,
+ private_comment: privateDescription,
+ public_comment: publicDescription
+ }, "json");
+ }).then((json) => {
+ setDomain("");
+ setPrivateDescription("");
+ setPublicDescription("");
+ setBlocks([json, ...blocks]);
+ });
+ }
+
+ function onDomainChange(e) {
+ setDomain(e.target.value);
+ }
+
+ function onTypeChange(e) {
+ setType(e.target.value);
+ }
+
+ function onKeyDown(e) {
+ if (e.key == "Enter") {
+ addBlock();
+ }
+ }
+
+ return (
+ <React.Fragment>
+ <h3>Add Block:</h3>
+ <div className="addblock">
+ <input id="domain" placeholder="instance" onChange={onDomainChange} value={domain} onKeyDown={onKeyDown} />
+ <select value={type} onChange={onTypeChange}>
+ <option id="suspend">Suspend</option>
+ <option id="silence">Silence</option>
+ </select>
+ <button onClick={addBlock}>Add</button>
+ <div>
+ <label htmlFor="private">Private description:</label><br/>
+ <textarea id="private" value={privateDescription} onChange={(e) => setPrivateDescription(e.target.value)}></textarea>
+ </div>
+ <div>
+ <label htmlFor="public">Public description:</label><br/>
+ <textarea id="public" value={publicDescription} onChange={(e) => setPublicDescription(e.target.value)}></textarea>
+ </div>
+ <div className="single">
+ <label htmlFor="obfuscate">Obfuscate:</label>
+ <input id="obfuscate" type="checkbox" value={obfuscated} onChange={(e) => setObfuscated(e.target.checked)}/>
+ </div>
+ </div>
+ </React.Fragment>
+ );
+}
+
+// function Blocklist() {
+// return (
+// <section className="blocklists">
+// <h1>Blocklists</h1>
+// </section>
+// );
+// } \ No newline at end of file
diff --git a/web/source/panels/admin/index.js b/web/source/panels/admin/index.js
new file mode 100644
index 000000000..05ab8e583
--- /dev/null
+++ b/web/source/panels/admin/index.js
@@ -0,0 +1,95 @@
+/*
+ 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 Promise = require("bluebird");
+const React = require("react");
+const ReactDom = require("react-dom");
+
+const oauthLib = require("../../lib/oauth.js");
+const Auth = require("./auth");
+const Settings = require("./settings");
+const Blocks = require("./blocks");
+
+require("./style.css");
+
+function App() {
+ const [oauth, setOauth] = React.useState();
+ const [hasAuth, setAuth] = React.useState(false);
+ const [oauthState, setOauthState] = React.useState(localStorage.getItem("oauth"));
+
+ React.useEffect(() => {
+ let state = localStorage.getItem("oauth");
+ if (state != undefined) {
+ state = JSON.parse(state);
+ let restoredOauth = oauthLib(state.config, state);
+ Promise.try(() => {
+ return restoredOauth.callback();
+ }).then(() => {
+ setAuth(true);
+ });
+ setOauth(restoredOauth);
+ }
+ }, []);
+
+ if (!hasAuth && oauth && oauth.isAuthorized()) {
+ setAuth(true);
+ }
+
+ if (oauth && oauth.isAuthorized()) {
+ return <AdminPanel oauth={oauth} />;
+ } else if (oauthState != undefined) {
+ return "processing oauth...";
+ } else {
+ return <Auth setOauth={setOauth} />;
+ }
+}
+
+function AdminPanel({oauth}) {
+ /*
+ Features: (issue #78)
+ - [ ] Instance information updating
+ GET /api/v1/instance PATCH /api/v1/instance
+ - [ ] Domain block creation, viewing, and deletion
+ GET /api/v1/admin/domain_blocks
+ POST /api/v1/admin/domain_blocks
+ GET /api/v1/admin/domain_blocks/DOMAIN_BLOCK_ID, DELETE /api/v1/admin/domain_blocks/DOMAIN_BLOCK_ID
+ - [ ] Blocklist import/export
+ GET /api/v1/admin/domain_blocks?export=true
+ POST json file as form field domains to /api/v1/admin/domain_blocks
+ */
+
+ return (
+ <React.Fragment>
+ <Logout oauth={oauth}/>
+ <Settings oauth={oauth} />
+ <Blocks oauth={oauth}/>
+ </React.Fragment>
+ );
+}
+
+function Logout({oauth}) {
+ return (
+ <div>
+ <button onClick={oauth.logout}>Logout</button>
+ </div>
+ );
+}
+
+ReactDom.render(<App/>, document.getElementById("root")); \ No newline at end of file
diff --git a/web/source/panels/admin/settings.js b/web/source/panels/admin/settings.js
new file mode 100644
index 000000000..2a10951a7
--- /dev/null
+++ b/web/source/panels/admin/settings.js
@@ -0,0 +1,175 @@
+/*
+ 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 Promise = require("bluebird");
+const React = require("react");
+
+module.exports = function Settings({oauth}) {
+ const [info, setInfo] = React.useState({});
+ const [errorMsg, setError] = React.useState("");
+ const [statusMsg, setStatus] = React.useState("Fetching instance info");
+
+ React.useEffect(() => {
+ Promise.try(() => {
+ return oauth.apiRequest("/api/v1/instance", "GET");
+ }).then((json) => {
+ setInfo(json);
+ }).catch((e) => {
+ setError(e.message);
+ setStatus("");
+ });
+ }, []);
+
+ function submit() {
+ setStatus("PATCHing");
+ setError("");
+ return Promise.try(() => {
+ let formDataInfo = new FormData();
+ Object.entries(info).forEach(([key, val]) => {
+ if (key == "contact_account") {
+ key = "contact_username";
+ val = val.username;
+ }
+ if (key == "email") {
+ key = "contact_email";
+ }
+ if (typeof val != "object") {
+ formDataInfo.append(key, val);
+ }
+ });
+ return oauth.apiRequest("/api/v1/instance", "PATCH", formDataInfo, "form");
+ }).then((json) => {
+ setStatus("Config saved");
+ console.log(json);
+ }).catch((e) => {
+ setError(e.message);
+ setStatus("");
+ });
+ }
+
+ return (
+ <section className="info login">
+ <h1>Instance Information <button onClick={submit}>Save</button></h1>
+ <div className="error accent">
+ {errorMsg}
+ </div>
+ <div>
+ {statusMsg}
+ </div>
+ <form onSubmit={(e) => e.preventDefault()}>
+ {editableObject(info)}
+ </form>
+ </section>
+ );
+};
+
+function editableObject(obj, path=[]) {
+ const readOnlyKeys = ["uri", "version", "urls_streaming_api", "stats"];
+ const hiddenKeys = ["contact_account_", "urls"];
+ const explicitShownKeys = ["contact_account_username"];
+ const implementedKeys = "title, contact_account_username, email, short_description, description, terms, avatar, header".split(", ");
+
+ let listing = Object.entries(obj).map(([key, val]) => {
+ let fullkey = [...path, key].join("_");
+
+ if (
+ hiddenKeys.includes(fullkey) ||
+ hiddenKeys.includes(path.join("_")+"_") // also match just parent path
+ ) {
+ if (!explicitShownKeys.includes(fullkey)) {
+ return null;
+ }
+ }
+
+ if (Array.isArray(val)) {
+ // FIXME: handle this
+ } else if (typeof val == "object") {
+ return (<React.Fragment key={fullkey}>
+ {editableObject(val, [...path, key])}
+ </React.Fragment>);
+ }
+
+ let isImplemented = "";
+ if (!implementedKeys.includes(fullkey)) {
+ isImplemented = " notImplemented";
+ }
+
+ let isReadOnly = (
+ readOnlyKeys.includes(fullkey) ||
+ readOnlyKeys.includes(path.join("_")) ||
+ isImplemented != ""
+ );
+
+ let label = key.replace(/_/g, " ");
+ if (path.length > 0) {
+ label = `\u00A0`.repeat(4 * path.length) + label;
+ }
+
+ let inputProps;
+ let changeFunc;
+ if (val === true || val === false) {
+ inputProps = {
+ type: "checkbox",
+ defaultChecked: val,
+ disabled: isReadOnly
+ };
+ changeFunc = (e) => e.target.checked;
+ } else if (val.length != 0 && !isNaN(val)) {
+ inputProps = {
+ type: "number",
+ defaultValue: val,
+ readOnly: isReadOnly
+ };
+ changeFunc = (e) => e.target.value;
+ } else {
+ inputProps = {
+ type: "text",
+ defaultValue: val,
+ readOnly: isReadOnly
+ };
+ changeFunc = (e) => e.target.value;
+ }
+
+ function setRef(element) {
+ if (element != null) {
+ element.addEventListener("change", (e) => {
+ obj[key] = changeFunc(e);
+ });
+ }
+ }
+
+ return (
+ <React.Fragment key={fullkey}>
+ <label htmlFor={key} className="capitalize">{label}</label>
+ <div className={isImplemented}>
+ <input className={isImplemented} ref={setRef} {...inputProps} />
+ </div>
+ </React.Fragment>
+ );
+ });
+ return (
+ <React.Fragment>
+ {path != "" &&
+ <><b>{path}:</b> <span id="filler"></span></>
+ }
+ {listing}
+ </React.Fragment>
+ );
+} \ No newline at end of file
diff --git a/web/source/panels/admin/style.css b/web/source/panels/admin/style.css
new file mode 100644
index 000000000..c9d2f09b4
--- /dev/null
+++ b/web/source/panels/admin/style.css
@@ -0,0 +1,141 @@
+/*
+ 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/>.
+*/
+
+body {
+ grid-template-rows: auto 1fr;
+}
+
+.capitalize {
+ text-transform: capitalize;
+}
+
+section {
+ margin-bottom: 1rem;
+}
+
+input, select, textarea {
+ box-sizing: border-box;
+}
+
+section.info {
+ form {
+ grid-template-columns: auto 1fr;
+ width: calc(100% - 0.35rem);
+
+ input {
+ width: 100%;
+ line-height: 1.5rem;
+ }
+
+ label, input {
+ padding: 0.2rem 0.5rem;
+ }
+
+ input[type=checkbox] {
+ justify-self: start;
+ width: initial;
+ }
+
+ input:read-only {
+ border: none;
+ }
+
+ input:invalid {
+ border-color: red;
+ }
+ }
+
+ textarea {
+ width: 100%;
+ height: 8rem;
+ }
+
+ h1 {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 0.5rem;
+ }
+}
+
+section.blocks {
+ .overflow {
+ max-height: 80vh;
+ overflow-y: auto;
+ }
+
+ .blocklist {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ grid-gap: 0.35rem 0;
+
+ div {
+ background: rgb(70, 79, 88);
+ padding: 0.2rem 0.4rem;
+ }
+ }
+
+ .addblock {
+ display: grid;
+ grid-template-columns: 1fr auto auto;
+ grid-gap: 0.35rem;
+
+ input, select {
+ font-size: 1.2rem;
+ }
+
+ input, select, textarea {
+ padding: 0.5rem;
+ }
+
+ div {
+ grid-column: 1/4;
+ }
+
+ div.single input {
+ width: initial;
+ }
+ }
+
+ h3 {
+ margin-bottom: 0;
+ }
+
+ .controls {
+ display: flex;
+ gap: 0.5rem;
+ }
+}
+
+.error {
+ font-weight: bold;
+}
+
+.hidden {
+ display: none;
+}
+
+.notImplemented {
+ border: 2px solid rgb(70, 79, 88);
+ background: repeating-linear-gradient(
+ -45deg,
+ #525c66,
+ #525c66 10px,
+ rgb(70, 79, 88) 10px,
+ rgb(70, 79, 88) 20px
+ ) !important;
+} \ No newline at end of file
diff --git a/web/source/panels/user/index.js b/web/source/panels/user/index.js
new file mode 100644
index 000000000..7adc320d8
--- /dev/null
+++ b/web/source/panels/user/index.js
@@ -0,0 +1,31 @@
+/*
+ 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 Promise = require("bluebird");
+const React = require("react");
+const ReactDom = require("react-dom");
+
+// require("./style.css");
+
+function App() {
+ return "hello world - user panel";
+}
+
+ReactDom.render(<App/>, document.getElementById("root")); \ No newline at end of file