diff --git a/web/source/css/_colors.css b/web/source/css/_colors.css index 82028dce..70c12486 100644 --- a/web/source/css/_colors.css +++ b/web/source/css/_colors.css @@ -46,6 +46,7 @@ $blue3: #89caff; /* hover/selected accent to $blue2, can be used with $gray1 (7. $error1: #860000; /* Error border/foreground text, can be used with $error2 (5.0), $white1 (10), $white2 (5.1) */ $error2: #ff9796; /* Error background text, can be used with $error1 (5.0), $gray1 (6.6), $gray2 (5.3), $gray3 (4.8) */ +$error3: #dd2c2c; /* Error button background text, can be used with $white1 (4.51) */ $error-link: #185F8C; /* Error link text, can be used with $error2 (5.54) */ $fg: $white1; @@ -69,9 +70,9 @@ $button-bg: $blue2; $button-fg: $gray1; $button-hover-bg: $blue3; -$button-danger-bg: $orange1; -$button-danger-fg: $gray1; -$button-danger-hover-bg: $orange2; +$button-danger-bg: $error3; +$button-danger-fg: $white1; +$button-danger-hover-bg: $error2; $toot-focus-bg: $gray5; $toot-unfocus-bg: $gray2; diff --git a/web/source/css/base.css b/web/source/css/base.css index 760189be..73014de8 100644 --- a/web/source/css/base.css +++ b/web/source/css/base.css @@ -172,6 +172,16 @@ main { } } + &:disabled { + color: $white2; + background: $gray2; + cursor: auto; + + &:hover { + background: $gray3; + } + } + &:hover { background: $button-hover-bg; } diff --git a/web/source/index.js b/web/source/index.js index 90ee5a4e..a96e663c 100644 --- a/web/source/index.js +++ b/web/source/index.js @@ -66,7 +66,6 @@ skulk({ ], }, settings: { - debug: false, entryFile: "settings", outputFile: "settings.js", prodCfg: prodCfg, diff --git a/web/source/settings/admin/emoji/category-select.jsx b/web/source/settings/admin/emoji/category-select.jsx new file mode 100644 index 00000000..3a2ace89 --- /dev/null +++ b/web/source/settings/admin/emoji/category-select.jsx @@ -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 . +*/ + +"use strict"; + +const React = require("react"); +const splitFilterN = require("split-filter-n"); +const syncpipe = require('syncpipe'); +const { matchSorter } = require("match-sorter"); + +const query = require("../../lib/query"); + +const ComboBox = require("../../components/combo-box"); + +function useEmojiByCategory(emoji) { + // split all emoji over an object keyed by the category names (or Unsorted) + return React.useMemo(() => splitFilterN( + emoji, + [], + (entry) => entry.category ?? "Unsorted" + ), [emoji]); +} + +function CategorySelect({value, categoryState, setIsNew=() => {}, children}) { + const { + data: emoji = [], + isLoading, + isSuccess, + error + } = query.useGetAllEmojiQuery({filter: "domain:local"}); + + const emojiByCategory = useEmojiByCategory(emoji); + + const categories = React.useMemo(() => new Set(Object.keys(emojiByCategory)), [emojiByCategory]); + + // data used by the ComboBox element to select an emoji category + const categoryItems = React.useMemo(() => { + return syncpipe(emojiByCategory, [ + (_) => Object.keys(_), // just emoji category names + (_) => matchSorter(_, value, {threshold: matchSorter.rankings.NO_MATCH}), // sorted by complex algorithm + (_) => _.map((categoryName) => [ // map to input value, and selectable element with icon + categoryName, + <> + + {categoryName} + + ]) + ]); + }, [emojiByCategory, value]); + + React.useEffect(() => { + if (value != undefined && isSuccess && value.trim().length > 0) { + setIsNew(!categories.has(value.trim())); + } + }, [categories, value, setIsNew, isSuccess]); + + if (error) { // fall back to plain text input, but this would almost certainly have caused a bigger error message elsewhere + return ( + <> + {categoryState.value = e.target.value;}}/>; + + ); + } else if (isLoading) { + return ; + } + + return ( + + ); +} + +module.exports = { + useEmojiByCategory, + CategorySelect +}; \ No newline at end of file diff --git a/web/source/settings/admin/emoji/detail.js b/web/source/settings/admin/emoji/detail.js index cc0f8e73..26608471 100644 --- a/web/source/settings/admin/emoji/detail.js +++ b/web/source/settings/admin/emoji/detail.js @@ -22,48 +22,130 @@ const React = require("react"); const { useRoute, Link, Redirect } = require("wouter"); -const BackButton = require("../../components/back-button"); +const { CategorySelect } = require("./category-select"); +const { useComboBoxInput, useFileInput } = require("../../components/form"); const query = require("../../lib/query"); +const FakeToot = require("../../components/fake-toot"); const base = "/settings/admin/custom-emoji"; -/* 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 -*/ -module.exports = function EmojiDetailWrapped() { - let [_match, {emojiId}] = useRoute(`${base}/:emojiId`); - const {currentData: emoji, isLoading, error} = query.useGetEmojiQuery(emojiId); - - return (<> - {error &&
{error.status}: {error.data.error}
} - {isLoading - ? "Loading..." - : - } - ); +module.exports = function EmojiDetailRoute() { + let [_match, params] = useRoute(`${base}/:emojiId`); + if (params?.emojiId == undefined) { + return ; + } else { + return ( +
+ < go back + +
+ ); + } }; +function EmojiDetailData({emojiId}) { + const {currentData: emoji, isLoading, error} = query.useGetEmojiQuery(emojiId); + + if (error) { + return ( +
+ {error.status}: {error.data.error} +
+ ); + } else if (isLoading) { + return "Loading..."; + } else { + return ; + } +} + function EmojiDetail({emoji}) { - if (emoji == undefined) { - return (<> - - go back - - ); + const [modifyEmoji, modifyResult] = query.useEditEmojiMutation(); + + const [isNewCategory, setIsNewCategory] = React.useState(false); + + const [categoryState, _resetCategory, { category }] = useComboBoxInput("category", {defaultValue: emoji.category}); + + const [onFileChange, _resetFile, { image, imageURL, imageInfo }] = useFileInput("image", { + withPreview: true, + maxSize: 50 * 1024 + }); + + function modifyCategory() { + modifyEmoji({id: emoji.id, category: category.trim()}); } + function modifyImage() { + modifyEmoji({id: emoji.id, image: image}); + } + + React.useEffect(() => { + if (category != emoji.category && !categoryState.open && !isNewCategory && emoji.category != undefined) { + console.log("updating to", category); + modifyEmoji({id: emoji.id, category: category.trim()}); + } + }, [isNewCategory, category, categoryState.open, emoji.category, emoji.id, modifyEmoji]); + return ( -
-

Custom Emoji: {emoji.shortcode}

- -

- Editing custom emoji isn't implemented yet.
- View implementation progress. -

- {emoji.shortcode} -
+ <> +
+ {emoji.shortcode} +
+

{emoji.shortcode}

+ +
+
+ +
+

Modify this emoji {modifyResult.isLoading && "(processing..)"}

+ + {modifyResult.error &&
+ {modifyResult.error.status}: {modifyResult.error.data.error} +
} + +
+ + + +
+ +
+ Image +
+ + {imageInfo} + +
+ + + + + Look at this new custom emoji {emoji.shortcode} isn't it cool? + +
+
+ ); } @@ -71,9 +153,9 @@ function DeleteButton({id}) { // TODO: confirmation dialog? const [deleteEmoji, deleteResult] = query.useDeleteEmojiMutation(); - let text = "Delete this emoji"; + let text = "Delete"; if (deleteResult.isLoading) { - text = "processing..."; + text = "Deleting..."; } if (deleteResult.isSuccess) { @@ -81,6 +163,6 @@ function DeleteButton({id}) { } return ( - + ); } \ No newline at end of file diff --git a/web/source/settings/admin/emoji/new-emoji.js b/web/source/settings/admin/emoji/new-emoji.js index 65dc5213..8cd604c0 100644 --- a/web/source/settings/admin/emoji/new-emoji.js +++ b/web/source/settings/admin/emoji/new-emoji.js @@ -20,11 +20,9 @@ const Promise = require('bluebird'); const React = require("react"); -const { matchSorter } = require("match-sorter"); const FakeToot = require("../../components/fake-toot"); const MutateButton = require("../../components/mutation-button"); -const ComboBox = require("../../components/combo-box"); const { useTextInput, @@ -33,9 +31,9 @@ const { } = require("../../components/form"); const query = require("../../lib/query"); -const syncpipe = require('syncpipe'); +const { CategorySelect } = require('./category-select'); -module.exports = function NewEmojiForm({ emoji, emojiByCategory }) { +module.exports = function NewEmojiForm({ emoji }) { const emojiCodes = React.useMemo(() => { return new Set(emoji.map((e) => e.shortcode)); }, [emoji]); @@ -57,21 +55,6 @@ module.exports = function NewEmojiForm({ emoji, emojiByCategory }) { const [categoryState, resetCategory, { category }] = useComboBoxInput("category"); - // data used by the ComboBox element to select an emoji category - const categoryItems = React.useMemo(() => { - return syncpipe(emojiByCategory, [ - (_) => Object.keys(_), // just emoji category names - (_) => matchSorter(_, category), // sorted by complex algorithm - (_) => _.map((categoryName) => [ // map to input value, and selectable element with icon - categoryName, - <> - - {categoryName} - - ]) - ]); - }, [emojiByCategory, category]); - React.useEffect(() => { if (shortcode.length == 0) { if (image != undefined) { @@ -152,11 +135,9 @@ module.exports = function NewEmojiForm({ emoji, emojiByCategory }) { /> - diff --git a/web/source/settings/admin/emoji/overview.js b/web/source/settings/admin/emoji/overview.js index 15891a5e..b8ac87a0 100644 --- a/web/source/settings/admin/emoji/overview.js +++ b/web/source/settings/admin/emoji/overview.js @@ -20,11 +20,11 @@ const React = require("react"); const {Link} = require("wouter"); -const splitFilterN = require("split-filter-n"); const NewEmojiForm = require("./new-emoji"); const query = require("../../lib/query"); +const { useEmojiByCategory } = require("./category-select"); const base = "/settings/admin/custom-emoji"; @@ -35,13 +35,6 @@ module.exports = function EmojiOverview() { error } = query.useGetAllEmojiQuery({filter: "domain:local"}); - // split all emoji over an object keyed by the category names (or Unsorted) - const emojiByCategory = React.useMemo(() => splitFilterN( - emoji, - [], - (entry) => entry.category ?? "Unsorted" - ), [emoji]); - return ( <>

Custom Emoji

@@ -51,15 +44,17 @@ module.exports = function EmojiOverview() { {isLoading ? "Loading..." : <> - - + + } ); }; -function EmojiList({emoji, emojiByCategory}) { +function EmojiList({emoji}) { + const emojiByCategory = useEmojiByCategory(emoji); + return (

Overview

diff --git a/web/source/settings/components/combo-box.jsx b/web/source/settings/components/combo-box.jsx index 1e629389..d69df55b 100644 --- a/web/source/settings/components/combo-box.jsx +++ b/web/source/settings/components/combo-box.jsx @@ -26,16 +26,19 @@ const { ComboboxPopover, } = require("ariakit/combobox"); -module.exports = function ComboBox({state, items, label, placeHolder}) { +module.exports = function ComboBox({state, items, label, placeHolder, children}) { return (
{items.map(([key, value]) => ( diff --git a/web/source/settings/components/form/combobox.jsx b/web/source/settings/components/form/combobox.jsx index 6ab235ed..d21a8c3f 100644 --- a/web/source/settings/components/form/combobox.jsx +++ b/web/source/settings/components/form/combobox.jsx @@ -20,11 +20,15 @@ const { useComboboxState } = require("ariakit/combobox"); -module.exports = function useComboBoxInput({name, Name}, {validator} = {}) { - const state = useComboboxState({ gutter: 0, sameWidth: true }); +module.exports = function useComboBoxInput({name, Name}, {validator, defaultValue} = {}) { + const state = useComboboxState({ + defaultValue, + gutter: 0, + sameWidth: true + }); function reset() { - state.value = ""; + state.setValue(""); } return [ diff --git a/web/source/settings/lib/query/custom-emoji.js b/web/source/settings/lib/query/custom-emoji.js index a26da75c..fa2a08db 100644 --- a/web/source/settings/lib/query/custom-emoji.js +++ b/web/source/settings/lib/query/custom-emoji.js @@ -54,6 +54,23 @@ const endpoints = (build) => ({ ? [{type: "Emojis", id: "LIST"}, {type: "Emojis", id: res.id}] : [{type: "Emojis", id: "LIST"}] }), + editEmoji: build.mutation({ + query: ({id, ...patch}) => { + return { + method: "PATCH", + url: `/api/v1/admin/custom_emojis/${id}`, + asForm: true, + body: { + type: "modify", + ...patch + } + }; + }, + invalidatesTags: (res) => + res + ? [{type: "Emojis", id: "LIST"}, {type: "Emojis", id: res.id}] + : [{type: "Emojis", id: "LIST"}] + }), deleteEmoji: build.mutation({ query: (id) => ({ method: "DELETE", diff --git a/web/source/settings/style.css b/web/source/settings/style.css index 2a32fc0a..7922a405 100644 --- a/web/source/settings/style.css +++ b/web/source/settings/style.css @@ -544,4 +544,56 @@ span.form-info { .combobox-item[data-active-item] { background: $button-hover-bg; color: hsl(204 20% 100%); +} + +.row { + display: flex; +} + +.emoji-detail { + display: flex; + flex-direction: column; + gap: 1rem !important; + + & > a { + align-self: flex-start; + } + + .emoji-header { + display: flex; + align-items: center; + gap: 0.5rem; + + img { + height: 8.5rem; + width: 8.5rem; + border: 0.2rem solid $border-accent; + object-fit: contain; + padding: 0.5rem; + } + } + + .update-category { + margin-bottom: 1rem; + .combobox-wrapper button { + font-size: 1rem; + margin: 0.15rem 0; + } + + .row { + margin-top: 0.4rem; + gap: 0.5rem; + } + } + + .update-image { + display: flex; + flex-direction: column; + gap: 0.5rem; + } +} + +.left-border { + border-left: 0.2rem solid $border-accent; + padding-left: 0.4rem; } \ No newline at end of file