diff options
Diffstat (limited to 'web/source/panels')
| -rw-r--r-- | web/source/panels/admin/README.md | 21 | ||||
| -rw-r--r-- | web/source/panels/admin/auth.js | 96 | ||||
| -rw-r--r-- | web/source/panels/admin/blocks.js | 318 | ||||
| -rw-r--r-- | web/source/panels/admin/index.js | 95 | ||||
| -rw-r--r-- | web/source/panels/admin/settings.js | 175 | ||||
| -rw-r--r-- | web/source/panels/admin/style.css | 141 | ||||
| -rw-r--r-- | web/source/panels/user/index.js | 31 | 
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  | 
