diff --git a/internal/web/settings-panel.go b/internal/web/settings-panel.go
index d290e5441..3ba396998 100644
--- a/internal/web/settings-panel.go
+++ b/internal/web/settings-panel.go
@@ -42,6 +42,7 @@ func (m *Module) SettingsPanelHandler(c *gin.Context) {
 			assetsPathPrefix + "/dist/_colors.css",
 			assetsPathPrefix + "/dist/base.css",
 			assetsPathPrefix + "/dist/profile.css",
+			assetsPathPrefix + "/dist/status.css",
 			assetsPathPrefix + "/dist/settings-panel-style.css",
 		},
 		"javascript": []string{
diff --git a/web/source/css/base.css b/web/source/css/base.css
index d50195465..d6ed44df0 100644
--- a/web/source/css/base.css
+++ b/web/source/css/base.css
@@ -278,6 +278,13 @@ section.error {
 	}
 }
 
+.error-text {
+	color: $error1;
+	background: $error2;
+	border-radius: 0.1rem;
+	font-weight: bold;
+}
+
 input, select, textarea {
 	box-sizing: border-box;
 	border: 0.15rem solid $input-border;
diff --git a/web/source/package.json b/web/source/package.json
index 01e661fb2..6e8deba09 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",
@@ -35,6 +36,8 @@
     "postcss-nested": "^5.0.6",
     "postcss-scss": "^4.0.4",
     "postcss-strip-inline-comments": "^0.1.5",
+    "prettier-bytes": "^1.0.4",
+    "pretty-bytes": "4",
     "react": "18",
     "react-dom": "18",
     "react-error-boundary": "^3.1.4",
diff --git a/web/source/settings-panel/admin/emoji.js b/web/source/settings-panel/admin/emoji.js
index e4fff2104..6cf78ad39 100644
--- a/web/source/settings-panel/admin/emoji.js
+++ b/web/source/settings-panel/admin/emoji.js
@@ -21,36 +21,192 @@
 const Promise = require("bluebird");
 const React = require("react");
 const Redux = require("react-redux");
+const {Switch, Route, Link, Redirect, useRoute, useLocation} = require("wouter");
+
+const Submit = require("../components/submit");
+const FakeToot = require("../components/fake-toot");
+const { formFields } = require("../components/form-fields");
 
 const api = require("../lib/api");
 const adminActions = require("../redux/reducers/admin").actions;
+const submit = require("../lib/submit");
+
+const base = "/settings/admin/custom-emoji";
 
 module.exports = function CustomEmoji() {
 	return (
-		<>
-			<h1>Custom Emoji</h1>
-			<div>
-				<EmojiOverview/>
-			</div>
-			<div>
-				<h2>Upload</h2>
-			</div>
-		</>
+		<Switch>
+			<Route path={`${base}/:emojiId`}>
+				<EmojiDetailWrapped />
+			</Route>
+			<EmojiOverview />
+		</Switch>
 	);
 };
 
 function EmojiOverview() {
 	const dispatch = Redux.useDispatch();
-	const emoji = Redux.useSelector((state) => state.admin.emoji);
-	console.log(emoji);
+	const [loaded, setLoaded] = React.useState(false);
+
+	const [errorMsg, setError] = React.useState("");
 
 	React.useEffect(() => {
-		dispatch(api.admin.fetchCustomEmoji());
+		if (!loaded) {
+			Promise.try(() => {
+				return dispatch(api.admin.fetchCustomEmoji());
+			}).then(() => {
+				setLoaded(true);
+			}).catch((e) => {
+				setLoaded(true);
+				setError(e.message);
+			});
+		}
 	}, []);
 
+	if (!loaded) {
+		return (
+			<>
+				<h1>Custom Emoji</h1>
+				Loading...
+			</>
+		);
+	}
+
 	return (
 		<>
-
+			<h1>Custom Emoji</h1>
+			<EmojiList/>
+			<NewEmoji/>
+			{errorMsg.length > 0 && 
+				<div className="error accent">{errorMsg}</div>
+			}
 		</>
 	);
+}
+
+const NewEmojiForm = formFields(adminActions.updateNewEmojiVal, (state) => state.admin.newEmoji);
+function NewEmoji() {
+	const dispatch = Redux.useDispatch();
+	const newEmojiForm = Redux.useSelector((state) => state.admin.newEmoji);
+
+	const [errorMsg, setError] = React.useState("");
+	const [statusMsg, setStatus] = React.useState("");
+
+	const uploadEmoji = submit(
+		() => dispatch(api.admin.newEmoji()),
+		{
+			setStatus, setError,
+			onSuccess: function() {
+				URL.revokeObjectURL(newEmojiForm.image);
+				return Promise.all([
+					dispatch(adminActions.updateNewEmojiVal(["image", undefined])),
+					dispatch(adminActions.updateNewEmojiVal(["imageFile", undefined])),
+					dispatch(adminActions.updateNewEmojiVal(["shortcode", ""])),
+				]);
+			}
+		}
+	);
+
+	React.useEffect(() => {
+		if (newEmojiForm.shortcode.length == 0) {
+			if (newEmojiForm.imageFile != undefined) {
+				let [name, ext] = newEmojiForm.imageFile.name.split(".");
+				dispatch(adminActions.updateNewEmojiVal(["shortcode", name]));
+			}
+		}
+	});
+
+	let emojiOrShortcode = `:${newEmojiForm.shortcode}:`;
+
+	if (newEmojiForm.image != undefined) {
+		emojiOrShortcode = <img
+			className="emoji"
+			src={newEmojiForm.image}
+			title={`:${newEmojiForm.shortcode}:`}
+			alt={newEmojiForm.shortcode}
+		/>;
+	}
+
+	return (
+		<div>
+			<h2>Add new custom emoji</h2>
+
+			<FakeToot content="bazinga">
+				Look at this new custom emoji {emojiOrShortcode} isn&apos;t it cool?
+			</FakeToot>
+
+			<NewEmojiForm.File
+				id="image"
+				name="Image"
+				fileType="image/png,image/gif"
+				showSize={true}
+				maxSize={50 * 1000}
+			/>
+
+			<NewEmojiForm.TextInput
+				id="shortcode"
+				name="Shortcode (without : :), must be unique on the instance"
+				placeHolder="blobcat"
+			/>
+
+			<Submit onClick={uploadEmoji} label="Upload" errorMsg={errorMsg} statusMsg={statusMsg} />
+		</div>
+	);
+}
+
+function EmojiList() {
+	const emoji = Redux.useSelector((state) => state.admin.emoji);
+
+	return (
+		<div>
+			<h2>Overview</h2>
+			<div className="list emoji-list">
+				{Object.entries(emoji).map(([category, entries]) => {
+					return <EmojiCategory key={category} category={category} entries={entries}/>;
+				})}
+			</div>
+		</div>
+	);
+}
+
+function EmojiCategory({category, entries}) {
+	return (
+		<div className="entry">
+			<b>{category}</b>
+			<div className="emoji-group">
+				{entries.map((e) => {
+					return (
+						// <Link key={e.static_url} to={`${base}/${e.shortcode}`}>
+						<Link key={e.static_url} to={`${base}`}>
+							<a>
+								<img src={e.static_url} alt={e.shortcode} title={`:${e.shortcode}:`}/>
+							</a>
+						</Link>
+					);
+				})}
+			</div>
+		</div>
+	);
+}
+
+function EmojiDetailWrapped() {
+	/* 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, {emojiId}] = useRoute(`${base}/:emojiId`);
+
+	function alterEmoji([key, val]) {
+		return adminActions.updateDomainBlockVal([emojiId, key, val]);
+	}
+
+	const fields = formFields(alterEmoji, (state) => state.admin.blockedInstances[emojiId]);
+
+	return <EmojiDetail id={emojiId} Form={fields} />;
+}
+
+function EmojiDetail({id, Form}) {
+	return (
+		"Not implemented yet"
+	);
 }
\ No newline at end of file
diff --git a/web/source/settings-panel/admin/federation.js b/web/source/settings-panel/admin/federation.js
index 3911099d7..1c5070efc 100644
--- a/web/source/settings-panel/admin/federation.js
+++ b/web/source/settings-panel/admin/federation.js
@@ -287,7 +287,6 @@ function BackButton() {
 	);
 }
 
-
 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,
diff --git a/web/source/settings-panel/components/fake-toot.jsx b/web/source/settings-panel/components/fake-toot.jsx
new file mode 100644
index 000000000..d66da66be
--- /dev/null
+++ b/web/source/settings-panel/components/fake-toot.jsx
@@ -0,0 +1,43 @@
+/*
+	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");
+
+module.exports = function FakeToot({children}) {
+	const account = Redux.useSelector((state) => state.user.profile);
+
+	return (
+		<div className="toot expanded">
+			<div className="contentgrid">
+				<span className="avatar">
+					<img src="http://localhost:8080/assets/default_avatars/GoToSocial_icon6.png" alt=""/>
+				</span>
+				<span className="displayname">{account.display_name.trim().length > 0 ? account.display_name : account.username}</span>
+				<span className="username">@{account.username}</span>
+				<div className="text">
+					<div className="content">
+						{children}
+					</div>
+				</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 8c48ba1a9..0ecaa2dc4 100644
--- a/web/source/settings-panel/components/form-fields.jsx
+++ b/web/source/settings-panel/components/form-fields.jsx
@@ -21,6 +21,7 @@
 const React = require("react");
 const Redux = require("react-redux");
 const d = require("dotty");
+const prettierBytes = require("prettier-bytes");
 
 function eventListeners(dispatch, setter, obj) {
 	return {
@@ -78,7 +79,7 @@ module.exports = {
 	formFields: function formFields(setter, selector) {
 		function FormField({
 			type, id, name, className="", placeHolder="", fileType="", children=null,
-			options=null, inputProps={}, withPreview=true
+			options=null, inputProps={}, withPreview=true, showSize=false, maxSize=Infinity
 		}) {
 			const dispatch = Redux.useDispatch();
 			let state = Redux.useSelector(selector);
@@ -105,10 +106,22 @@ module.exports = {
 			} else if (type == "file") {
 				defaultLabel = false;
 				let file = get(state, `${id}File`);
+
+				let size = null;
+				if (showSize && file) {
+					size = `(${prettierBytes(file.size)})`;
+
+					if (file.size > maxSize) {
+						size = <span className="error-text">{size}</span>;
+					}
+				}
+
 				field = (
 					<>
 						<label htmlFor={id} className="file-input button">Browse</label>
-						<span>{file ? file.name : "no file selected"}</span>
+						<span>
+							{file ? file.name : "no file selected"} {size}
+						</span>
 						{/* <a onClick={removeFile("header")}>remove</a> */}
 						<input className="hidden" id={id} type="file" accept={fileType} onChange={onFileChange(id, withPreview)}  {...inputProps}/>
 					</>
diff --git a/web/source/settings-panel/index.js b/web/source/settings-panel/index.js
index c2f3b0f8a..91b889626 100644
--- a/web/source/settings-panel/index.js
+++ b/web/source/settings-panel/index.js
@@ -92,7 +92,7 @@ function App() {
 					e.message = "Stored OAUTH token no longer valid, please log in again.";
 				}
 				setErrorMsg(e);
-				console.error(e.message);
+				console.error(e);
 			});
 		}
 	}, []);
diff --git a/web/source/settings-panel/lib/api/admin.js b/web/source/settings-panel/lib/api/admin.js
index 67b170f12..7873975f4 100644
--- a/web/source/settings-panel/lib/api/admin.js
+++ b/web/source/settings-panel/lib/api/admin.js
@@ -157,6 +157,23 @@ module.exports = function ({ apiCall, getChanges }) {
 					return dispatch(admin.setEmoji(emoji));
 				});
 			};
+		},
+
+		newEmoji: function newEmoji() {
+			return function (dispatch, getState) {
+				return Promise.try(() => {
+					const state = getState().admin.newEmoji;
+
+					const update = getChanges(state, {
+						formKeys: ["shortcode"],
+						fileKeys: ["image"]
+					});
+
+					return dispatch(apiCall("POST", "/api/v1/admin/custom_emojis", update, "form"));
+				}).then((emoji) => {
+					return dispatch(admin.addEmoji(emoji));
+				});
+			};
 		}
 	};
 	return adminAPI;
diff --git a/web/source/settings-panel/lib/api/index.js b/web/source/settings-panel/lib/api/index.js
index 25b54d252..e699011bd 100644
--- a/web/source/settings-panel/lib/api/index.js
+++ b/web/source/settings-panel/lib/api/index.js
@@ -37,7 +37,9 @@ function apiCall(method, route, payload, type = "json") {
 			let url = new URL(base);
 			let [path, query] = route.split("?");
 			url.pathname = path;
-			url.search = query;
+			if (query != undefined) {
+				url.search = query;
+			}
 			let body;
 
 			let headers = {
diff --git a/web/source/settings-panel/lib/submit.js b/web/source/settings-panel/lib/submit.js
new file mode 100644
index 000000000..4092b292b
--- /dev/null
+++ b/web/source/settings-panel/lib/submit.js
@@ -0,0 +1,49 @@
+/*
+	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");
+
+module.exports = function submit(func, {
+	setStatus, setError,
+	startStatus="PATCHing", successStatus="Saved!",
+	onSuccess,
+	onError
+}) {
+	return function() {
+		setStatus(startStatus);
+		setError("");
+		return Promise.try(() => {
+			return func();
+		}).then(() => {
+			setStatus(successStatus);
+			if (onSuccess != undefined) {
+				console.log("running", onSuccess);
+				return onSuccess();
+			}
+		}).catch((e) => {
+			setError(e.message);
+			setStatus("");
+			console.error(e);
+			if (onError != undefined) {
+				onError(e);
+			}
+		});
+	};
+};
\ No newline at end of file
diff --git a/web/source/settings-panel/redux/reducers/admin.js b/web/source/settings-panel/redux/reducers/admin.js
index 07863cf11..e534b1a3d 100644
--- a/web/source/settings-panel/redux/reducers/admin.js
+++ b/web/source/settings-panel/redux/reducers/admin.js
@@ -19,6 +19,7 @@
 "use strict";
 
 const { createSlice } = require("@reduxjs/toolkit");
+const defaultValue = require("default-value");
 
 function sortBlocks(blocks) {
 	return blocks.sort((a, b) => { // alphabetical sort
@@ -34,6 +35,12 @@ function emptyBlock() {
 	};
 }
 
+function emptyEmojiForm() {
+	return {
+		shortcode: ""
+	};
+}
+
 module.exports = createSlice({
 	name: "admin",
 	initialState: {
@@ -44,7 +51,8 @@ module.exports = createSlice({
 			exportType: "plain",
 			...emptyBlock()
 		},
-		emoji: []
+		emoji: {},
+		newEmoji: emptyEmojiForm()
 	},
 	reducers: {
 		setBlockedInstances: (state, { payload }) => {
@@ -90,7 +98,26 @@ module.exports = createSlice({
 		},
 
 		setEmoji: (state, {payload}) => {
-			state.emoji = payload;
-		}
+			state.emoji = {};
+			payload.forEach((emoji) => {
+				if (emoji.category == undefined) {
+					emoji.category = "Unsorted";
+				}
+				state.emoji[emoji.category] = defaultValue(state.emoji[emoji.category], []);
+				state.emoji[emoji.category].push(emoji);
+			});
+		},
+
+		updateNewEmojiVal: (state, { payload: [key, val] }) => {
+			state.newEmoji[key] = val;
+		},
+
+		addEmoji: (state, {payload: emoji}) => {
+			if (emoji.category == undefined) {
+				emoji.category = "Unsorted";
+			}
+			state.emoji[emoji.category] = defaultValue(state.emoji[emoji.category], []);
+			state.emoji[emoji.category].push(emoji);
+		},
 	}
 });
\ No newline at end of file
diff --git a/web/source/settings-panel/style.css b/web/source/settings-panel/style.css
index cd40930c0..09f532c02 100644
--- a/web/source/settings-panel/style.css
+++ b/web/source/settings-panel/style.css
@@ -192,9 +192,14 @@ input, select, textarea {
 	}
 
 	button, .button {
-		margin-top: 1rem;
-		margin-right: 1rem;
 		white-space: nowrap;
+		margin-right: 1rem;
+	}
+}
+
+.messagebutton > div {
+	button, .button {
+		margin-top: 1rem;
 	}
 }
 
@@ -359,6 +364,23 @@ section.with-sidebar > div {
 	font-weight: bold;
 }
 
+.list {
+	display: flex;
+	flex-direction: column;
+	margin-top: 0.5rem;
+	max-height: 40rem;
+	overflow: auto;
+
+	.entry {
+		display: flex;
+		background: $settings-entry-bg;
+
+		&:hover {
+			background: $settings-entry-hover-bg;
+		}
+	}
+}
+
 .instance-list {
 	.filter {
 		display: flex;
@@ -370,20 +392,9 @@ section.with-sidebar > div {
 		}
 	}
 
-	.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;
@@ -391,10 +402,6 @@ section.with-sidebar > div {
 			white-space: nowrap;
 			text-overflow: ellipsis;
 		}
-
-		&:hover {
-			background: $settings-entry-hover-bg;
-		}
 	}
 }
 
@@ -402,3 +409,42 @@ section.with-sidebar > div {
 	display: flex;
 	justify-content: space-between;
 }
+
+.emoji-list {
+	background: $settings-entry-bg;
+
+	.entry {
+		padding: 0.5rem;
+		flex-direction: column;
+
+		.emoji-group {
+			display: flex;
+	
+			a {
+				border-radius: $br;
+				padding: 0.4rem;
+				line-height: 0;
+	
+				img {
+					height: 2rem;
+					width: 2rem;
+				}
+
+				&:hover {
+					background: $settings-entry-hover-bg;
+				}
+			}
+		}
+
+		&:hover {
+			background: inherit;
+		}
+	}
+}
+
+.toot {
+	padding-top: 0.5rem;
+	.contentgrid {
+		padding: 0 0.5rem;
+	}
+}
diff --git a/web/source/yarn.lock b/web/source/yarn.lock
index 85a375ff0..830ca5055 100644
--- a/web/source/yarn.lock
+++ b/web/source/yarn.lock
@@ -4492,11 +4492,16 @@ prelude-ls@~1.1.2:
   resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
   integrity sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==
 
-prettier-bytes@^1.0.3:
+prettier-bytes@^1.0.3, prettier-bytes@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/prettier-bytes/-/prettier-bytes-1.0.4.tgz#994b02aa46f699c50b6257b5faaa7fe2557e62d6"
   integrity sha512-dLbWOa4xBn+qeWeIF60qRoB6Pk2jX5P3DIVgOQyMyvBpu931Q+8dXz8X0snJiFkQdohDDLnZQECjzsAj75hgZQ==
 
+pretty-bytes@4:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-4.0.2.tgz#b2bf82e7350d65c6c33aa95aaa5a4f6327f61cd9"
+  integrity sha512-yJAF+AjbHKlxQ8eezMd/34Mnj/YTQ3i6kLzvVsH4l/BfIFtp444n0wVbnsn66JimZ9uBofv815aRp1zCppxlWw==
+
 pretty-ms@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/pretty-ms/-/pretty-ms-2.1.0.tgz#4257c256df3fb0b451d6affaab021884126981dc"