emoji uploader

This commit is contained in:
f0x 2022-09-16 18:50:25 +02:00
parent b984641108
commit dd9a46a412
14 changed files with 407 additions and 39 deletions

View file

@ -42,6 +42,7 @@ func (m *Module) SettingsPanelHandler(c *gin.Context) {
assetsPathPrefix + "/dist/_colors.css", assetsPathPrefix + "/dist/_colors.css",
assetsPathPrefix + "/dist/base.css", assetsPathPrefix + "/dist/base.css",
assetsPathPrefix + "/dist/profile.css", assetsPathPrefix + "/dist/profile.css",
assetsPathPrefix + "/dist/status.css",
assetsPathPrefix + "/dist/settings-panel-style.css", assetsPathPrefix + "/dist/settings-panel-style.css",
}, },
"javascript": []string{ "javascript": []string{

View file

@ -278,6 +278,13 @@ section.error {
} }
} }
.error-text {
color: $error1;
background: $error2;
border-radius: 0.1rem;
font-weight: bold;
}
input, select, textarea { input, select, textarea {
box-sizing: border-box; box-sizing: border-box;
border: 0.15rem solid $input-border; border: 0.15rem solid $input-border;

View file

@ -18,6 +18,7 @@
"browserlist": "^1.0.1", "browserlist": "^1.0.1",
"create-error": "^0.3.1", "create-error": "^0.3.1",
"css-extract": "^2.0.0", "css-extract": "^2.0.0",
"default-value": "^1.0.0",
"dotty": "^0.1.2", "dotty": "^0.1.2",
"eslint-plugin-react": "^7.24.0", "eslint-plugin-react": "^7.24.0",
"express": "^4.18.1", "express": "^4.18.1",
@ -35,6 +36,8 @@
"postcss-nested": "^5.0.6", "postcss-nested": "^5.0.6",
"postcss-scss": "^4.0.4", "postcss-scss": "^4.0.4",
"postcss-strip-inline-comments": "^0.1.5", "postcss-strip-inline-comments": "^0.1.5",
"prettier-bytes": "^1.0.4",
"pretty-bytes": "4",
"react": "18", "react": "18",
"react-dom": "18", "react-dom": "18",
"react-error-boundary": "^3.1.4", "react-error-boundary": "^3.1.4",

View file

@ -21,36 +21,192 @@
const Promise = require("bluebird"); const Promise = require("bluebird");
const React = require("react"); const React = require("react");
const Redux = require("react-redux"); 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 api = require("../lib/api");
const adminActions = require("../redux/reducers/admin").actions; const adminActions = require("../redux/reducers/admin").actions;
const submit = require("../lib/submit");
const base = "/settings/admin/custom-emoji";
module.exports = function CustomEmoji() { module.exports = function CustomEmoji() {
return ( return (
<> <Switch>
<h1>Custom Emoji</h1> <Route path={`${base}/:emojiId`}>
<div> <EmojiDetailWrapped />
<EmojiOverview/> </Route>
</div> <EmojiOverview />
<div> </Switch>
<h2>Upload</h2>
</div>
</>
); );
}; };
function EmojiOverview() { function EmojiOverview() {
const dispatch = Redux.useDispatch(); const dispatch = Redux.useDispatch();
const emoji = Redux.useSelector((state) => state.admin.emoji); const [loaded, setLoaded] = React.useState(false);
console.log(emoji);
const [errorMsg, setError] = React.useState("");
React.useEffect(() => { 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 ( 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"
);
}

View file

@ -287,7 +287,6 @@ function BackButton() {
); );
} }
function InstancePageWrapped() { function InstancePageWrapped() {
/* We wrap the component to generate formFields with a setter depending on the domain /* 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, if formFields() is used inside the same component that is re-rendered with their state,

View file

@ -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>
);
};

View file

@ -21,6 +21,7 @@
const React = require("react"); const React = require("react");
const Redux = require("react-redux"); const Redux = require("react-redux");
const d = require("dotty"); const d = require("dotty");
const prettierBytes = require("prettier-bytes");
function eventListeners(dispatch, setter, obj) { function eventListeners(dispatch, setter, obj) {
return { return {
@ -78,7 +79,7 @@ module.exports = {
formFields: function formFields(setter, selector) { formFields: function formFields(setter, selector) {
function FormField({ function FormField({
type, id, name, className="", placeHolder="", fileType="", children=null, 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(); const dispatch = Redux.useDispatch();
let state = Redux.useSelector(selector); let state = Redux.useSelector(selector);
@ -105,10 +106,22 @@ module.exports = {
} else if (type == "file") { } else if (type == "file") {
defaultLabel = false; defaultLabel = false;
let file = get(state, `${id}File`); 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 = ( field = (
<> <>
<label htmlFor={id} className="file-input button">Browse</label> <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> */} {/* <a onClick={removeFile("header")}>remove</a> */}
<input className="hidden" id={id} type="file" accept={fileType} onChange={onFileChange(id, withPreview)} {...inputProps}/> <input className="hidden" id={id} type="file" accept={fileType} onChange={onFileChange(id, withPreview)} {...inputProps}/>
</> </>

View file

@ -92,7 +92,7 @@ function App() {
e.message = "Stored OAUTH token no longer valid, please log in again."; e.message = "Stored OAUTH token no longer valid, please log in again.";
} }
setErrorMsg(e); setErrorMsg(e);
console.error(e.message); console.error(e);
}); });
} }
}, []); }, []);

View file

@ -157,6 +157,23 @@ module.exports = function ({ apiCall, getChanges }) {
return dispatch(admin.setEmoji(emoji)); 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; return adminAPI;

View file

@ -37,7 +37,9 @@ function apiCall(method, route, payload, type = "json") {
let url = new URL(base); let url = new URL(base);
let [path, query] = route.split("?"); let [path, query] = route.split("?");
url.pathname = path; url.pathname = path;
url.search = query; if (query != undefined) {
url.search = query;
}
let body; let body;
let headers = { let headers = {

View file

@ -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);
}
});
};
};

View file

@ -19,6 +19,7 @@
"use strict"; "use strict";
const { createSlice } = require("@reduxjs/toolkit"); const { createSlice } = require("@reduxjs/toolkit");
const defaultValue = require("default-value");
function sortBlocks(blocks) { function sortBlocks(blocks) {
return blocks.sort((a, b) => { // alphabetical sort return blocks.sort((a, b) => { // alphabetical sort
@ -34,6 +35,12 @@ function emptyBlock() {
}; };
} }
function emptyEmojiForm() {
return {
shortcode: ""
};
}
module.exports = createSlice({ module.exports = createSlice({
name: "admin", name: "admin",
initialState: { initialState: {
@ -44,7 +51,8 @@ module.exports = createSlice({
exportType: "plain", exportType: "plain",
...emptyBlock() ...emptyBlock()
}, },
emoji: [] emoji: {},
newEmoji: emptyEmojiForm()
}, },
reducers: { reducers: {
setBlockedInstances: (state, { payload }) => { setBlockedInstances: (state, { payload }) => {
@ -90,7 +98,26 @@ module.exports = createSlice({
}, },
setEmoji: (state, {payload}) => { 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);
},
} }
}); });

View file

@ -192,9 +192,14 @@ input, select, textarea {
} }
button, .button { button, .button {
margin-top: 1rem;
margin-right: 1rem;
white-space: nowrap; white-space: nowrap;
margin-right: 1rem;
}
}
.messagebutton > div {
button, .button {
margin-top: 1rem;
} }
} }
@ -359,6 +364,23 @@ section.with-sidebar > div {
font-weight: bold; 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 { .instance-list {
.filter { .filter {
display: flex; 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 { .entry {
padding: 0.3rem; padding: 0.3rem;
margin: 0.2rem 0; margin: 0.2rem 0;
background: $settings-entry-bg;
display: flex;
#domain { #domain {
flex: 1 1 auto; flex: 1 1 auto;
@ -391,10 +402,6 @@ section.with-sidebar > div {
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
&:hover {
background: $settings-entry-hover-bg;
}
} }
} }
@ -402,3 +409,42 @@ section.with-sidebar > div {
display: flex; display: flex;
justify-content: space-between; 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;
}
}

View file

@ -4492,11 +4492,16 @@ prelude-ls@~1.1.2:
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
integrity sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w== integrity sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==
prettier-bytes@^1.0.3: prettier-bytes@^1.0.3, prettier-bytes@^1.0.4:
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/prettier-bytes/-/prettier-bytes-1.0.4.tgz#994b02aa46f699c50b6257b5faaa7fe2557e62d6" resolved "https://registry.yarnpkg.com/prettier-bytes/-/prettier-bytes-1.0.4.tgz#994b02aa46f699c50b6257b5faaa7fe2557e62d6"
integrity sha512-dLbWOa4xBn+qeWeIF60qRoB6Pk2jX5P3DIVgOQyMyvBpu931Q+8dXz8X0snJiFkQdohDDLnZQECjzsAj75hgZQ== 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: pretty-ms@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/pretty-ms/-/pretty-ms-2.1.0.tgz#4257c256df3fb0b451d6affaab021884126981dc" resolved "https://registry.yarnpkg.com/pretty-ms/-/pretty-ms-2.1.0.tgz#4257c256df3fb0b451d6affaab021884126981dc"