diff options
-rw-r--r-- | web/source/css/base.css | 10 | ||||
-rw-r--r-- | web/source/settings/admin/emoji/local/detail.js (renamed from web/source/settings/admin/emoji/detail.js) | 17 | ||||
-rw-r--r-- | web/source/settings/admin/emoji/local/index.js (renamed from web/source/settings/admin/emoji/index.js) | 2 | ||||
-rw-r--r-- | web/source/settings/admin/emoji/local/new-emoji.js (renamed from web/source/settings/admin/emoji/new-emoji.js) | 39 | ||||
-rw-r--r-- | web/source/settings/admin/emoji/local/overview.js (renamed from web/source/settings/admin/emoji/overview.js) | 11 | ||||
-rw-r--r-- | web/source/settings/admin/emoji/remote/index.js | 54 | ||||
-rw-r--r-- | web/source/settings/admin/emoji/remote/parse-from-toot.js | 319 | ||||
-rw-r--r-- | web/source/settings/admin/federation.js | 7 | ||||
-rw-r--r-- | web/source/settings/components/form/text.jsx | 16 | ||||
-rw-r--r-- | web/source/settings/components/loading.jsx | 27 | ||||
-rw-r--r-- | web/source/settings/index.js | 9 | ||||
-rw-r--r-- | web/source/settings/lib/query/custom-emoji.js | 97 | ||||
-rw-r--r-- | web/source/settings/style.css | 48 |
13 files changed, 623 insertions, 33 deletions
diff --git a/web/source/css/base.css b/web/source/css/base.css index d2fa95a3b..73b533733 100644 --- a/web/source/css/base.css +++ b/web/source/css/base.css @@ -394,3 +394,13 @@ footer { color: $gray1; } } + +label { + cursor: pointer; +} + +@media (prefers-reduced-motion) { + .fa-spin { + animation: none; + } +}
\ No newline at end of file diff --git a/web/source/settings/admin/emoji/detail.js b/web/source/settings/admin/emoji/local/detail.js index 51e291448..179ee7c7c 100644 --- a/web/source/settings/admin/emoji/detail.js +++ b/web/source/settings/admin/emoji/local/detail.js @@ -22,13 +22,14 @@ const React = require("react"); const { useRoute, Link, Redirect } = require("wouter"); -const { CategorySelect } = require("./category-select"); -const { useComboBoxInput, useFileInput } = require("../../components/form"); +const { CategorySelect } = require("../category-select"); +const { useComboBoxInput, useFileInput } = require("../../../components/form"); -const query = require("../../lib/query"); -const FakeToot = require("../../components/fake-toot"); +const query = require("../../../lib/query"); +const FakeToot = require("../../../components/fake-toot"); +const Loading = require("../../../components/loading"); -const base = "/settings/admin/custom-emoji"; +const base = "/settings/custom-emoji/local"; module.exports = function EmojiDetailRoute() { let [_match, params] = useRoute(`${base}/:emojiId`); @@ -54,7 +55,11 @@ function EmojiDetailData({emojiId}) { </div> ); } else if (isLoading) { - return "Loading..."; + return ( + <div> + <Loading/> + </div> + ); } else { return <EmojiDetail emoji={emoji}/>; } diff --git a/web/source/settings/admin/emoji/index.js b/web/source/settings/admin/emoji/local/index.js index 0fcda8264..1ccdece72 100644 --- a/web/source/settings/admin/emoji/index.js +++ b/web/source/settings/admin/emoji/local/index.js @@ -24,7 +24,7 @@ const {Switch, Route} = require("wouter"); const EmojiOverview = require("./overview"); const EmojiDetail = require("./detail"); -const base = "/settings/admin/custom-emoji"; +const base = "/settings/custom-emoji/local"; module.exports = function CustomEmoji() { return ( diff --git a/web/source/settings/admin/emoji/new-emoji.js b/web/source/settings/admin/emoji/local/new-emoji.js index 8cd604c02..985be2d32 100644 --- a/web/source/settings/admin/emoji/new-emoji.js +++ b/web/source/settings/admin/emoji/local/new-emoji.js @@ -21,17 +21,19 @@ const Promise = require('bluebird'); const React = require("react"); -const FakeToot = require("../../components/fake-toot"); -const MutateButton = require("../../components/mutation-button"); +const FakeToot = require("../../../components/fake-toot"); +const MutateButton = require("../../../components/mutation-button"); const { useTextInput, useFileInput, useComboBoxInput -} = require("../../components/form"); +} = require("../../../components/form"); -const query = require("../../lib/query"); -const { CategorySelect } = require('./category-select'); +const query = require("../../../lib/query"); +const { CategorySelect } = require('../category-select'); + +const shortcodeRegex = /^[a-z0-9_]+$/; module.exports = function NewEmojiForm({ emoji }) { const emojiCodes = React.useMemo(() => { @@ -47,9 +49,26 @@ module.exports = function NewEmojiForm({ emoji }) { const [onShortcodeChange, resetShortcode, { shortcode, setShortcode, shortcodeRef }] = useTextInput("shortcode", { validator: function validateShortcode(code) { - return emojiCodes.has(code) - ? "Shortcode already in use" - : ""; + // technically invalid, but hacky fix to prevent validation error on page load + if (shortcode == "") {return "";} + + if (emojiCodes.has(code)) { + return "Shortcode already in use"; + } + + if (code.length < 2 || code.length > 30) { + return "Shortcode must be between 2 and 30 characters"; + } + + if (code.toLowerCase() != code) { + return "Shortcode must be lowercase"; + } + + if (!shortcodeRegex.test(code)) { + return "Shortcode must only contain lowercase letters, numbers, and underscores"; + } + + return ""; } }); @@ -78,11 +97,13 @@ module.exports = function NewEmojiForm({ emoji }) { image, shortcode, category - }); + }).unwrap(); }).then(() => { resetFile(); resetShortcode(); resetCategory(); + }).catch((e) => { + console.error("Emoji upload error:", e); }); } diff --git a/web/source/settings/admin/emoji/overview.js b/web/source/settings/admin/emoji/local/overview.js index b8ac87a0f..7a5cfaad6 100644 --- a/web/source/settings/admin/emoji/overview.js +++ b/web/source/settings/admin/emoji/local/overview.js @@ -23,10 +23,11 @@ const {Link} = require("wouter"); const NewEmojiForm = require("./new-emoji"); -const query = require("../../lib/query"); -const { useEmojiByCategory } = require("./category-select"); +const query = require("../../../lib/query"); +const { useEmojiByCategory } = require("../category-select"); +const Loading = require("../../../components/loading"); -const base = "/settings/admin/custom-emoji"; +const base = "/settings/custom-emoji/local"; module.exports = function EmojiOverview() { const { @@ -37,12 +38,12 @@ module.exports = function EmojiOverview() { return ( <> - <h1>Custom Emoji</h1> + <h1>Custom Emoji (local)</h1> {error && <div className="error accent">{error}</div> } {isLoading - ? "Loading..." + ? <Loading/> : <> <EmojiList emoji={emoji}/> <NewEmojiForm emoji={emoji}/> diff --git a/web/source/settings/admin/emoji/remote/index.js b/web/source/settings/admin/emoji/remote/index.js new file mode 100644 index 000000000..ae59673a5 --- /dev/null +++ b/web/source/settings/admin/emoji/remote/index.js @@ -0,0 +1,54 @@ +/* + 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 ParseFromToot = require("./parse-from-toot"); + +const query = require("../../../lib/query"); +const Loading = require("../../../components/loading"); + +module.exports = function RemoteEmoji() { + // local emoji are queried for shortcode collision detection + const { + data: emoji = [], + isLoading, + error + } = query.useGetAllEmojiQuery({filter: "domain:local"}); + + const emojiCodes = React.useMemo(() => { + return new Set(emoji.map((e) => e.shortcode)); + }, [emoji]); + + return ( + <> + <h1>Custom Emoji (remote)</h1> + {error && + <div className="error accent">{error}</div> + } + {isLoading + ? <Loading/> + : <> + <ParseFromToot emoji={emoji} emojiCodes={emojiCodes} /> + </> + } + </> + ); +};
\ No newline at end of file diff --git a/web/source/settings/admin/emoji/remote/parse-from-toot.js b/web/source/settings/admin/emoji/remote/parse-from-toot.js new file mode 100644 index 000000000..75ff8bf7a --- /dev/null +++ b/web/source/settings/admin/emoji/remote/parse-from-toot.js @@ -0,0 +1,319 @@ +/* + 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 Redux = require("react-redux"); +const syncpipe = require("syncpipe"); + +const { + useTextInput, + useComboBoxInput +} = require("../../../components/form"); + +const { CategorySelect } = require('../category-select'); + +const query = require("../../../lib/query"); +const Loading = require("../../../components/loading"); + +module.exports = function ParseFromToot({ emojiCodes }) { + const [searchStatus, { data, isLoading, isSuccess, error }] = query.useSearchStatusForEmojiMutation(); + const instanceDomain = Redux.useSelector((state) => (new URL(state.oauth.instance).host)); + + const [onURLChange, _resetURL, { url }] = useTextInput("url"); + + const searchResult = React.useMemo(() => { + if (!isSuccess) { + return null; + } + + if (data.type == "none") { + return "No results found"; + } + + if (data.domain == instanceDomain) { + return <b>This is a local user/toot, all referenced emoji are already on your instance</b>; + } + + if (data.list.length == 0) { + return <b>This {data.type == "statuses" ? "toot" : "account"} doesn't use any custom emoji</b>; + } + + return ( + <CopyEmojiForm + localEmojiCodes={emojiCodes} + type={data.type} + domain={data.domain} + emojiList={data.list} + /> + ); + }, [isSuccess, data, instanceDomain, emojiCodes]); + + function submitSearch(e) { + e.preventDefault(); + searchStatus(url); + } + + return ( + <div className="parse-emoji"> + <h2>Steal this look</h2> + <form onSubmit={submitSearch}> + <div className="form-field text"> + <label htmlFor="url"> + Link to a toot: + </label> + <div className="row"> + <input + type="text" + id="url" + name="url" + onChange={onURLChange} + value={url} + /> + <button disabled={isLoading}> + <i className={[ + "fa", + (isLoading + ? "fa-refresh fa-spin" + : "fa-search") + ].join(" ")} aria-hidden="true" title="Search"/> + <span className="sr-only">Search</span> + </button> + </div> + {isLoading && <Loading/>} + {error && <div className="error">{error.data.error}</div>} + </div> + </form> + {searchResult} + </div> + ); +}; + +function makeEmojiState(emojiList, checked) { + /* Return a new object, with a key for every emoji's shortcode, + And a value for it's checkbox `checked` state. + */ + return syncpipe(emojiList, [ + (_) => _.map((emoji) => [emoji.shortcode, { + checked, + valid: true + }]), + (_) => Object.fromEntries(_) + ]); +} + +function updateEmojiState(emojiState, checked) { + /* Create a new object with all emoji entries' checked state updated */ + return syncpipe(emojiState, [ + (_) => Object.entries(emojiState), + (_) => _.map(([key, val]) => [key, { + ...val, + checked + }]), + (_) => Object.fromEntries(_) + ]); +} + +function CopyEmojiForm({ localEmojiCodes, type, domain, emojiList }) { + const [patchRemoteEmojis, patchResult] = query.usePatchRemoteEmojisMutation(); + const [err, setError] = React.useState(); + + const toggleAllRef = React.useRef(null); + const [toggleAllState, setToggleAllState] = React.useState(0); + const [emojiState, setEmojiState] = React.useState(makeEmojiState(emojiList, false)); + const [someSelected, setSomeSelected] = React.useState(false); + + const [categoryState, resetCategory, { category }] = useComboBoxInput("category"); + + React.useEffect(() => { + if (emojiList != undefined) { + setEmojiState(makeEmojiState(emojiList, false)); + } + }, [emojiList]); + + React.useEffect(() => { + /* Updates (un)check all checkbox, based on shortcode checkboxes + Can be 0 (not checked), 1 (checked) or 2 (indeterminate) + */ + if (toggleAllRef.current == null) { + return; + } + + let values = Object.values(emojiState); + /* one or more boxes are checked */ + let some = values.some((v) => v.checked); + + let all = false; + if (some) { + /* there's not at least one unchecked box */ + all = !values.some((v) => v.checked == false); + } + + setSomeSelected(some); + + if (some && !all) { + setToggleAllState(2); + toggleAllRef.current.indeterminate = true; + } else { + setToggleAllState(all ? 1 : 0); + toggleAllRef.current.indeterminate = false; + } + }, [emojiState, toggleAllRef]); + + function updateEmoji(shortcode, value) { + setEmojiState({ + ...emojiState, + [shortcode]: { + ...emojiState[shortcode], + ...value + } + }); + } + + function toggleAll(e) { + let selectAll = e.target.checked; + + if (toggleAllState == 2) { // indeterminate + selectAll = false; + } + + setEmojiState(updateEmojiState(emojiState, selectAll)); + setToggleAllState(selectAll); + } + + function submit(action) { + Promise.try(() => { + setError(null); + const selectedShortcodes = syncpipe(emojiState, [ + (_) => Object.entries(_), + (_) => _.filter(([_shortcode, entry]) => entry.checked), + (_) => _.map(([shortcode, entry]) => { + if (action == "copy" && !entry.valid) { + throw `One or more selected emoji have non-unique shortcodes (${shortcode}), unselect them or pick a different local shortcode`; + } + return { + shortcode, + localShortcode: entry.shortcode + }; + }) + ]); + + return patchRemoteEmojis({ + action, + domain, + list: selectedShortcodes, + category + }).unwrap(); + }).then(() => { + setEmojiState(makeEmojiState(emojiList, false)); + resetCategory(); + }).catch((e) => { + if (Array.isArray(e)) { + setError(e.map(([shortcode, msg]) => ( + <div key={shortcode}> + {shortcode}: <span style={{ fontWeight: "initial" }}>{msg}</span> + </div> + ))); + } else { + setError(e); + } + }); + } + + return ( + <div className="parsed"> + <span>This {type == "statuses" ? "toot" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span> + <div className="emoji-list"> + <label className="header"> + <input + ref={toggleAllRef} + type="checkbox" + onChange={toggleAll} + checked={toggleAllState === 1} + /> All + </label> + {emojiList.map((emoji) => ( + <EmojiEntry + key={emoji.shortcode} + emoji={emoji} + localEmojiCodes={localEmojiCodes} + updateEmoji={(value) => updateEmoji(emoji.shortcode, value)} + checked={emojiState[emoji.shortcode].checked} + /> + ))} + </div> + + <CategorySelect + value={category} + categoryState={categoryState} + /> + + <div className="action-buttons row"> + <button disabled={!someSelected} onClick={() => submit("copy")}>{patchResult.isLoading ? "Processing..." : "Copy to local emoji"}</button> + <button disabled={!someSelected} onClick={() => submit("disable")} className="danger">{patchResult.isLoading ? "Processing..." : "Disable"}</button> + </div> + {err && <div className="error"> + {err} + </div>} + {patchResult.isSuccess && <div> + Action applied to {patchResult.data.length} emoji + </div>} + </div> + ); +} + +function EmojiEntry({ emoji, localEmojiCodes, updateEmoji, checked }) { + const [onShortcodeChange, _resetShortcode, { shortcode, shortcodeRef, shortcodeValid }] = useTextInput("shortcode", { + defaultValue: emoji.shortcode, + validator: function validateShortcode(code) { + return (checked && localEmojiCodes.has(code)) + ? "Shortcode already in use" + : ""; + } + }); + + React.useEffect(() => { + updateEmoji({ valid: shortcodeValid }); + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [shortcodeValid]); + + return ( + <label key={emoji.shortcode} className="row"> + <input + type="checkbox" + onChange={(e) => updateEmoji({ checked: e.target.checked })} + checked={checked} + /> + <img className="emoji" src={emoji.url} title={emoji.shortcode} /> + + <input + type="text" + id="shortcode" + name="Shortcode" + ref={shortcodeRef} + onChange={(e) => { + onShortcodeChange(e); + updateEmoji({ shortcode: e.target.value, checked: true }); + }} + value={shortcode} + /> + </label> + ); +}
\ No newline at end of file diff --git a/web/source/settings/admin/federation.js b/web/source/settings/admin/federation.js index 753024e58..f1023d365 100644 --- a/web/source/settings/admin/federation.js +++ b/web/source/settings/admin/federation.js @@ -30,6 +30,7 @@ const api = require("../lib/api"); const adminActions = require("../redux/reducers/admin").actions; const submit = require("../lib/submit"); const BackButton = require("../components/back-button"); +const Loading = require("../components/loading"); const base = "/settings/admin/federation"; @@ -56,7 +57,9 @@ module.exports = function AdminSettings() { return ( <div> <h1>Federation</h1> - Loading... + <div> + <Loading/> + </div> </div> ); } @@ -321,7 +324,7 @@ function InstancePage({domain, Form}) { const [statusMsg, setStatus] = React.useState(""); if (entry == undefined) { - return "Loading..."; + return <Loading/>; } const updateBlock = submit( diff --git a/web/source/settings/components/form/text.jsx b/web/source/settings/components/form/text.jsx index a392239dc..c24628dd7 100644 --- a/web/source/settings/components/form/text.jsx +++ b/web/source/settings/components/form/text.jsx @@ -20,17 +20,14 @@ const React = require("react"); -module.exports = function useTextInput({name, Name}, {validator} = {}) { - const [text, setText] = React.useState(""); +module.exports = function useTextInput({name, Name}, {validator, defaultValue=""} = {}) { + const [text, setText] = React.useState(defaultValue); + const [valid, setValid] = React.useState(true); const textRef = React.useRef(null); function onChange(e) { let input = e.target.value; setText(input); - - if (validator) { - validator(input); - } } function reset() { @@ -39,7 +36,9 @@ module.exports = function useTextInput({name, Name}, {validator} = {}) { React.useEffect(() => { if (validator) { - textRef.current.setCustomValidity(validator(text)); + let res = validator(text); + setValid(res == ""); + textRef.current.setCustomValidity(res); textRef.current.reportValidity(); } }, [text, textRef, validator]); @@ -50,7 +49,8 @@ module.exports = function useTextInput({name, Name}, {validator} = {}) { { [name]: text, [`${name}Ref`]: textRef, - [`set${Name}`]: setText + [`set${Name}`]: setText, + [`${name}Valid`]: valid } ]; };
\ No newline at end of file diff --git a/web/source/settings/components/loading.jsx b/web/source/settings/components/loading.jsx new file mode 100644 index 000000000..51ce3a18b --- /dev/null +++ b/web/source/settings/components/loading.jsx @@ -0,0 +1,27 @@ +/* + 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"); + +module.exports = function Loading() { + return ( + <i className="fa fa-spin fa-refresh" aria-label="Loading" title="Loading"/> + ); +};
\ No newline at end of file diff --git a/web/source/settings/index.js b/web/source/settings/index.js index 58afe5d28..b087f945c 100644 --- a/web/source/settings/index.js +++ b/web/source/settings/index.js @@ -32,6 +32,7 @@ const oauth = require("./redux/reducers/oauth").actions; const { AuthenticationError } = require("./lib/errors"); const Login = require("./components/login"); +const Loading = require("./components/loading"); require("./style.css"); @@ -46,7 +47,11 @@ const nav = { "Instance Settings": require("./admin/settings.js"), "Actions": require("./admin/actions"), "Federation": require("./admin/federation.js"), - "Custom Emoji": require("./admin/emoji"), + }, + "Custom Emoji": { + adminOnly: true, + "Local": require("./admin/emoji/local"), + "Remote": require("./admin/emoji/remote"), } }; @@ -167,7 +172,7 @@ function App() { function Main() { return ( <Provider store={store}> - <PersistGate loading={"loading..."} persistor={persistor}> + <PersistGate loading={<section><Loading/></section>} persistor={persistor}> <App /> </PersistGate> </Provider> diff --git a/web/source/settings/lib/query/custom-emoji.js b/web/source/settings/lib/query/custom-emoji.js index fa2a08db6..593556620 100644 --- a/web/source/settings/lib/query/custom-emoji.js +++ b/web/source/settings/lib/query/custom-emoji.js @@ -18,8 +18,18 @@ "use strict"; +const Promise = require("bluebird"); + const base = require("./base"); +function unwrap(res) { + if (res.error != undefined) { + throw res.error; + } else { + return res.data; + } +} + const endpoints = (build) => ({ getAllEmoji: build.query({ query: (params = {}) => ({ @@ -77,6 +87,93 @@ const endpoints = (build) => ({ url: `/api/v1/admin/custom_emojis/${id}` }), invalidatesTags: (res, error, id) => [{type: "Emojis", id}] + }), + searchStatusForEmoji: build.mutation({ + query: (url) => ({ + method: "GET", + url: `/api/v2/search?q=${encodeURIComponent(url)}&resolve=true&limit=1` + }), + transformResponse: (res) => { + /* Parses search response, prioritizing a toot result, + and returns referenced custom emoji + */ + let type; + + if (res.statuses.length > 0) { + type = "statuses"; + } else if (res.accounts.length > 0) { + type = "accounts"; + } else { + return { + type: "none" + }; + } + + let data = res[type][0]; + + return { + type, + domain: (new URL(data.url)).host, // to get WEB_DOMAIN, see https://github.com/superseriousbusiness/gotosocial/issues/1225 + list: data.emojis + }; + } + }), + patchRemoteEmojis: build.mutation({ + queryFn: ({action, domain, list, category}, api, _extraOpts, baseQuery) => { + const data = []; + const errors = []; + + return Promise.each(list, (emoji) => { + return Promise.try(() => { + return baseQuery({ + method: "GET", + url: `/api/v1/admin/custom_emojis`, + params: { + filter: `domain:${domain},shortcode:${emoji.shortcode}`, + limit: 1 + } + }).then(unwrap); + }).then(([lookup]) => { + if (lookup == undefined) { throw "not found"; } + + let body = { + type: action + }; + + if (action == "copy") { + body.shortcode = emoji.localShortcode ?? emoji.shortcode; + if (category.trim().length != 0) { + body.category = category; + } + } + + return baseQuery({ + method: "PATCH", + url: `/api/v1/admin/custom_emojis/${lookup.id}`, + asForm: true, + body: body + }).then(unwrap); + }).then((res) => { + data.push([emoji.shortcode, res]); + }).catch((e) => { + console.error("emoji lookup for", emoji.shortcode, "failed:", e); + let msg = e.message ?? e; + if (e.data.error) { + msg = e.data.error; + } + errors.push([emoji.shortcode, msg]); + }); + }).then(() => { + if (errors.length == 0) { + return { data }; + } else { + return { + error: errors + }; + } + }); + }, + invalidatesTags: () => [{type: "Emojis", id: "LIST"}] }) }); diff --git a/web/source/settings/style.css b/web/source/settings/style.css index 7a71c3278..7ed2fb725 100644 --- a/web/source/settings/style.css +++ b/web/source/settings/style.css @@ -598,4 +598,52 @@ span.form-info { .left-border { border-left: 0.2rem solid $border-accent; padding-left: 0.4rem; +} + +.parse-emoji { + .parsed { + margin-top: 0.5rem; + display: flex; + flex-direction: column; + gap: 1rem; + + & > span { + margin-bottom: -1rem; + } + + .action-buttons { + gap: 1rem; + } + + .emoji-list { + display: flex; + flex-direction: column; + + & > * { + gap: 1rem; + align-items: center; + padding: 0.5rem 1rem; + } + + .header { + background: $gray2; + display: flex; + } + + .row { + display: grid; + grid-template-columns: auto auto 1fr; + + &:hover { + background: $settings-entry-hover-bg; + } + } + + .emoji { + height: 2rem; + width: 2rem; + margin: 0; + } + } + } }
\ No newline at end of file |