summaryrefslogtreecommitdiff
path: root/web/source/settings/views/admin
diff options
context:
space:
mode:
authorLibravatar tobi <31960611+tsmethurst@users.noreply.github.com>2024-04-24 12:12:47 +0200
committerLibravatar GitHub <noreply@github.com>2024-04-24 11:12:47 +0100
commit7a1e6394831fb07e303c5ed0900dfe1ea4820de5 (patch)
treebcd526463b19a85fbe821dcad2276da401daec18 /web/source/settings/views/admin
parent[chore]: Bump codeberg.org/gruf/go-mutexes from 1.4.0 to 1.4.1 (#2860) (diff)
downloadgotosocial-7a1e6394831fb07e303c5ed0900dfe1ea4820de5.tar.xz
[chore] Refactor settings panel routing (and other fixes) (#2864)
Diffstat (limited to 'web/source/settings/views/admin')
-rw-r--r--web/source/settings/views/admin/actions/keys/expireremote.tsx60
-rw-r--r--web/source/settings/views/admin/actions/keys/index.tsx30
-rw-r--r--web/source/settings/views/admin/actions/media/cleanup.tsx59
-rw-r--r--web/source/settings/views/admin/actions/media/index.tsx30
-rw-r--r--web/source/settings/views/admin/emoji/category-select.tsx134
-rw-r--r--web/source/settings/views/admin/emoji/local/detail.tsx142
-rw-r--r--web/source/settings/views/admin/emoji/local/new-emoji.tsx112
-rw-r--r--web/source/settings/views/admin/emoji/local/overview.tsx173
-rw-r--r--web/source/settings/views/admin/emoji/local/use-shortcode.ts56
-rw-r--r--web/source/settings/views/admin/emoji/remote/index.tsx46
-rw-r--r--web/source/settings/views/admin/emoji/remote/steal-this-look.tsx235
-rw-r--r--web/source/settings/views/admin/routes.tsx177
-rw-r--r--web/source/settings/views/admin/settings/index.tsx190
-rw-r--r--web/source/settings/views/admin/settings/rules.tsx151
14 files changed, 1595 insertions, 0 deletions
diff --git a/web/source/settings/views/admin/actions/keys/expireremote.tsx b/web/source/settings/views/admin/actions/keys/expireremote.tsx
new file mode 100644
index 000000000..c7a410267
--- /dev/null
+++ b/web/source/settings/views/admin/actions/keys/expireremote.tsx
@@ -0,0 +1,60 @@
+/*
+ GoToSocial
+ Copyright (C) GoToSocial Authors admin@gotosocial.org
+ SPDX-License-Identifier: AGPL-3.0-or-later
+
+ 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/>.
+*/
+
+import React from "react";
+import { useInstanceKeysExpireMutation } from "../../../../lib/query";
+import { TextInput } from "../../../../components/form/inputs";
+import MutationButton from "../../../../components/form/mutation-button";
+import { useTextInput } from "../../../../lib/form";
+
+export default function ExpireRemote({}) {
+ const domainField = useTextInput("domain");
+
+ const [expire, expireResult] = useInstanceKeysExpireMutation();
+
+ function submitExpire(e) {
+ e.preventDefault();
+ expire(domainField.value);
+ }
+
+ return (
+ <form onSubmit={submitExpire}>
+ <h2>Expire remote instance keys</h2>
+ <p>
+ Mark all public keys from the given remote instance as expired.<br/><br/>
+ This is useful in cases where the remote domain has had to rotate their keys for whatever
+ reason (security issue, data leak, routine safety procedure, etc), and your instance can no
+ longer communicate with theirs properly using cached keys. A key marked as expired in this way
+ will be lazily refetched next time a request is made to your instance signed by the owner of that
+ key.
+ </p>
+ <TextInput
+ field={domainField}
+ label="Domain"
+ type="string"
+ placeholder="example.org"
+ />
+ <MutationButton
+ disabled={!domainField.value}
+ label="Expire keys"
+ result={expireResult}
+ />
+ </form>
+ );
+}
diff --git a/web/source/settings/views/admin/actions/keys/index.tsx b/web/source/settings/views/admin/actions/keys/index.tsx
new file mode 100644
index 000000000..74bfd36ee
--- /dev/null
+++ b/web/source/settings/views/admin/actions/keys/index.tsx
@@ -0,0 +1,30 @@
+/*
+ GoToSocial
+ Copyright (C) GoToSocial Authors admin@gotosocial.org
+ SPDX-License-Identifier: AGPL-3.0-or-later
+
+ 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/>.
+*/
+
+import React from "react";
+import ExpireRemote from "./expireremote";
+
+export default function Keys() {
+ return (
+ <>
+ <h1>Key Actions</h1>
+ <ExpireRemote />
+ </>
+ );
+}
diff --git a/web/source/settings/views/admin/actions/media/cleanup.tsx b/web/source/settings/views/admin/actions/media/cleanup.tsx
new file mode 100644
index 000000000..d4bae24a6
--- /dev/null
+++ b/web/source/settings/views/admin/actions/media/cleanup.tsx
@@ -0,0 +1,59 @@
+/*
+ GoToSocial
+ Copyright (C) GoToSocial Authors admin@gotosocial.org
+ SPDX-License-Identifier: AGPL-3.0-or-later
+
+ 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/>.
+*/
+
+import React from "react";
+
+import { useMediaCleanupMutation } from "../../../../lib/query";
+import { useTextInput } from "../../../../lib/form";
+import { TextInput } from "../../../../components/form/inputs";
+import MutationButton from "../../../../components/form/mutation-button";
+
+export default function Cleanup({}) {
+ const daysField = useTextInput("days", { defaultValue: "30" });
+
+ const [mediaCleanup, mediaCleanupResult] = useMediaCleanupMutation();
+
+ function submitCleanup(e) {
+ e.preventDefault();
+ mediaCleanup(daysField.value);
+ }
+
+ return (
+ <form onSubmit={submitCleanup}>
+ <h2>Cleanup</h2>
+ <p>
+ Clean up remote media older than the specified number of days.
+ If the remote instance is still online they will be refetched when needed.
+ Also cleans up unused headers and avatars from the media cache.
+ </p>
+ <TextInput
+ field={daysField}
+ label="Days"
+ type="number"
+ min="0"
+ placeholder="30"
+ />
+ <MutationButton
+ disabled={!daysField.value}
+ label="Remove old media"
+ result={mediaCleanupResult}
+ />
+ </form>
+ );
+}
diff --git a/web/source/settings/views/admin/actions/media/index.tsx b/web/source/settings/views/admin/actions/media/index.tsx
new file mode 100644
index 000000000..b3b805986
--- /dev/null
+++ b/web/source/settings/views/admin/actions/media/index.tsx
@@ -0,0 +1,30 @@
+/*
+ GoToSocial
+ Copyright (C) GoToSocial Authors admin@gotosocial.org
+ SPDX-License-Identifier: AGPL-3.0-or-later
+
+ 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/>.
+*/
+
+import React from "react";
+import Cleanup from "./cleanup";
+
+export default function Media() {
+ return (
+ <>
+ <h1>Media Actions</h1>
+ <Cleanup />
+ </>
+ );
+}
diff --git a/web/source/settings/views/admin/emoji/category-select.tsx b/web/source/settings/views/admin/emoji/category-select.tsx
new file mode 100644
index 000000000..683e146d8
--- /dev/null
+++ b/web/source/settings/views/admin/emoji/category-select.tsx
@@ -0,0 +1,134 @@
+/*
+ GoToSocial
+ Copyright (C) GoToSocial Authors admin@gotosocial.org
+ SPDX-License-Identifier: AGPL-3.0-or-later
+
+ 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/>.
+*/
+
+import React, { useMemo, useEffect, PropsWithChildren, ReactElement } from "react";
+import { matchSorter } from "match-sorter";
+import ComboBox from "../../../components/combo-box";
+import { useListEmojiQuery } from "../../../lib/query/admin/custom-emoji";
+import { CustomEmoji } from "../../../lib/types/custom-emoji";
+import { ComboboxFormInputHook } from "../../../lib/form/types";
+import Loading from "../../../components/loading";
+import { Error } from "../../../components/error";
+
+/**
+ * Sort all emoji into a map keyed by
+ * the category names (or "Unsorted").
+ */
+export function useEmojiByCategory(emojis: CustomEmoji[]) {
+ return useMemo(() => {
+ const byCategory = new Map<string, CustomEmoji[]>();
+
+ emojis.forEach((emoji) => {
+ const key = emoji.category ?? "Unsorted";
+ const value = byCategory.get(key) ?? [];
+ value.push(emoji);
+ byCategory.set(key, value);
+ });
+
+ return byCategory;
+ }, [emojis]);
+}
+
+interface CategorySelectProps {
+ field: ComboboxFormInputHook;
+}
+
+/**
+ *
+ * Renders a cute lil searchable "category select" dropdown.
+ */
+export function CategorySelect({ field, children }: PropsWithChildren<CategorySelectProps>) {
+ // Get all local emojis.
+ const {
+ data: emoji = [],
+ isLoading,
+ isSuccess,
+ isError,
+ error,
+ } = useListEmojiQuery({ filter: "domain:local" });
+
+ const emojiByCategory = useEmojiByCategory(emoji);
+ const categories = useMemo(() => new Set(emojiByCategory.keys()), [emojiByCategory]);
+ const { value, setIsNew } = field;
+
+ // Data used by the ComboBox element
+ // to select an emoji category.
+ const categoryItems = useMemo(() => {
+ const categoriesArr = Array.from(categories);
+
+ // Sorted by complex algorithm.
+ const categoryNames = matchSorter(
+ categoriesArr,
+ value ?? "",
+ { threshold: matchSorter.rankings.NO_MATCH },
+ );
+
+ // Map each category to the static image
+ // of the first emoji it contains.
+ const categoryItems: [string, ReactElement][] = [];
+ categoryNames.forEach((categoryName) => {
+ let src: string | undefined;
+ const items = emojiByCategory.get(categoryName);
+ if (items && items.length > 0) {
+ src = items[0].static_url;
+ }
+
+ categoryItems.push([
+ categoryName,
+ <>
+ <img
+ src={src}
+ aria-hidden="true"
+ />
+ {categoryName}
+ </>
+ ]);
+ });
+
+ return categoryItems;
+ }, [emojiByCategory, categories, value]);
+
+ // New category if something has been entered
+ // and we don't have it in categories yet.
+ useEffect(() => {
+ if (value !== undefined) {
+ const trimmed = value.trim();
+ if (trimmed.length > 0) {
+ setIsNew(!categories.has(trimmed));
+ }
+ }
+ }, [categories, value, isSuccess, setIsNew]);
+
+ if (isLoading) {
+ return <Loading />;
+ } else if (isError) {
+ return <Error error={error} />;
+ } else {
+ return (
+ <ComboBox
+ field={field}
+ items={categoryItems}
+ label="Category"
+ placeholder="e.g., reactions"
+ >
+ {children}
+ </ComboBox>
+ );
+ }
+}
diff --git a/web/source/settings/views/admin/emoji/local/detail.tsx b/web/source/settings/views/admin/emoji/local/detail.tsx
new file mode 100644
index 000000000..2913b6c17
--- /dev/null
+++ b/web/source/settings/views/admin/emoji/local/detail.tsx
@@ -0,0 +1,142 @@
+/*
+ GoToSocial
+ Copyright (C) GoToSocial Authors admin@gotosocial.org
+ SPDX-License-Identifier: AGPL-3.0-or-later
+
+ 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/>.
+*/
+
+import React, { useEffect } from "react";
+import { Redirect, useParams } from "wouter";
+import { useComboBoxInput, useFileInput, useValue } from "../../../../lib/form";
+import useFormSubmit from "../../../../lib/form/submit";
+import { useBaseUrl } from "../../../../lib/navigation/util";
+import FakeToot from "../../../../components/fake-toot";
+import FormWithData from "../../../../lib/form/form-with-data";
+import Loading from "../../../../components/loading";
+import { FileInput } from "../../../../components/form/inputs";
+import MutationButton from "../../../../components/form/mutation-button";
+import { Error } from "../../../../components/error";
+import { useGetEmojiQuery, useEditEmojiMutation, useDeleteEmojiMutation } from "../../../../lib/query/admin/custom-emoji";
+import { CategorySelect } from "../category-select";
+import BackButton from "../../../../components/back-button";
+
+export default function EmojiDetail() {
+ const baseUrl = useBaseUrl();
+ const params = useParams();
+ return (
+ <div className="emoji-detail">
+ <BackButton to={`~${baseUrl}/local`} />
+ <FormWithData dataQuery={useGetEmojiQuery} queryArg={params.emojiId} DataForm={EmojiDetailForm} />
+ </div>
+ );
+}
+
+function EmojiDetailForm({ data: emoji }) {
+ const baseUrl = useBaseUrl();
+ const form = {
+ id: useValue("id", emoji.id),
+ category: useComboBoxInput("category", { source: emoji }),
+ image: useFileInput("image", {
+ withPreview: true,
+ maxSize: 50 * 1024 // TODO: get from instance api
+ })
+ };
+
+ const [modifyEmoji, result] = useFormSubmit(form, useEditEmojiMutation());
+
+ // Automatic submitting of category change
+ useEffect(() => {
+ if (
+ form.category.hasChanged() &&
+ !form.category.state.open &&
+ !form.category.isNew) {
+ modifyEmoji();
+ }
+ /* eslint-disable-next-line react-hooks/exhaustive-deps */
+ }, [form.category.hasChanged(), form.category.isNew, form.category.state.open]);
+
+ const [deleteEmoji, deleteResult] = useDeleteEmojiMutation();
+
+ if (deleteResult.isSuccess) {
+ return <Redirect to={`~${baseUrl}/local`} />;
+ }
+
+ return (
+ <>
+ <div className="emoji-header">
+ <img src={emoji.url} alt={emoji.shortcode} title={emoji.shortcode} />
+ <div>
+ <h2>{emoji.shortcode}</h2>
+ <MutationButton
+ label="Delete"
+ type="button"
+ onClick={() => deleteEmoji(emoji.id)}
+ className="danger"
+ showError={false}
+ result={deleteResult}
+ disabled={false}
+ />
+ </div>
+ </div>
+
+ <form onSubmit={modifyEmoji} className="left-border">
+ <h2>Modify this emoji {result.isLoading && <Loading />}</h2>
+
+ <div className="update-category">
+ <CategorySelect
+ field={form.category}
+ >
+ <MutationButton
+ name="create-category"
+ label="Create"
+ result={result}
+ showError={false}
+ style={{ visibility: (form.category.isNew ? "initial" : "hidden") }}
+ disabled={!form.category.value}
+ />
+ </CategorySelect>
+ </div>
+
+ <div className="update-image">
+ <FileInput
+ field={form.image}
+ label="Image"
+ accept="image/png,image/gif"
+ />
+
+ <MutationButton
+ name="image"
+ label="Replace image"
+ showError={false}
+ result={result}
+ disabled={!form.image.value}
+ />
+
+ <FakeToot>
+ Look at this new custom emoji <img
+ className="emoji"
+ src={form.image.previewValue ?? emoji.url}
+ title={`:${emoji.shortcode}:`}
+ alt={emoji.shortcode}
+ /> isn&apos;t it cool?
+ </FakeToot>
+
+ {result.error && <Error error={result.error} />}
+ {deleteResult.error && <Error error={deleteResult.error} />}
+ </div>
+ </form>
+ </>
+ );
+} \ No newline at end of file
diff --git a/web/source/settings/views/admin/emoji/local/new-emoji.tsx b/web/source/settings/views/admin/emoji/local/new-emoji.tsx
new file mode 100644
index 000000000..73e846f16
--- /dev/null
+++ b/web/source/settings/views/admin/emoji/local/new-emoji.tsx
@@ -0,0 +1,112 @@
+/*
+ GoToSocial
+ Copyright (C) GoToSocial Authors admin@gotosocial.org
+ SPDX-License-Identifier: AGPL-3.0-or-later
+
+ 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/>.
+*/
+
+import React, { useMemo, useEffect } from "react";
+import { useFileInput, useComboBoxInput } from "../../../../lib/form";
+import useShortcode from "./use-shortcode";
+import useFormSubmit from "../../../../lib/form/submit";
+import { TextInput, FileInput } from "../../../../components/form/inputs";
+import { CategorySelect } from '../category-select';
+import FakeToot from "../../../../components/fake-toot";
+import MutationButton from "../../../../components/form/mutation-button";
+import { useAddEmojiMutation } from "../../../../lib/query/admin/custom-emoji";
+import { useInstanceV1Query } from "../../../../lib/query";
+
+export default function NewEmojiForm() {
+ const shortcode = useShortcode();
+
+ const { data: instance } = useInstanceV1Query();
+ const emojiMaxSize = useMemo(() => {
+ return instance?.configuration?.emojis?.emoji_size_limit ?? 50 * 1024;
+ }, [instance]);
+
+ const image = useFileInput("image", {
+ withPreview: true,
+ maxSize: emojiMaxSize
+ });
+
+ const category = useComboBoxInput("category");
+
+ const [submitForm, result] = useFormSubmit({
+ shortcode, image, category
+ }, useAddEmojiMutation());
+
+ useEffect(() => {
+ if (shortcode.value === undefined || shortcode.value.length == 0) {
+ if (image.value != undefined) {
+ let [name, _ext] = image.value.name.split(".");
+ shortcode.setter(name);
+ }
+ }
+
+ /* We explicitly don't want to have 'shortcode' as a dependency here
+ because we only want to change the shortcode to the filename if the field is empty
+ at the moment the file is selected, not some time after when the field is emptied
+ */
+ /* eslint-disable-next-line react-hooks/exhaustive-deps */
+ }, [image.value]);
+
+ let emojiOrShortcode;
+
+ if (image.previewValue != undefined) {
+ emojiOrShortcode = <img
+ className="emoji"
+ src={image.previewValue}
+ title={`:${shortcode.value}:`}
+ alt={shortcode.value}
+ />;
+ } else if (shortcode.value !== undefined && shortcode.value.length > 0) {
+ emojiOrShortcode = `:${shortcode.value}:`;
+ } else {
+ emojiOrShortcode = `:your_emoji_here:`;
+ }
+
+ return (
+ <div>
+ <h2>Add new custom emoji</h2>
+
+ <FakeToot>
+ Look at this new custom emoji {emojiOrShortcode} isn&apos;t it cool?
+ </FakeToot>
+
+ <form onSubmit={submitForm} className="form-flex">
+ <FileInput
+ field={image}
+ accept="image/png,image/gif,image/webp"
+ />
+
+ <TextInput
+ field={shortcode}
+ label="Shortcode, must be unique among the instance's local emoji"
+ />
+
+ <CategorySelect
+ field={category}
+ children={[]}
+ />
+
+ <MutationButton
+ disabled={image.previewValue === undefined}
+ label="Upload emoji"
+ result={result}
+ />
+ </form>
+ </div>
+ );
+}
diff --git a/web/source/settings/views/admin/emoji/local/overview.tsx b/web/source/settings/views/admin/emoji/local/overview.tsx
new file mode 100644
index 000000000..b28af59f3
--- /dev/null
+++ b/web/source/settings/views/admin/emoji/local/overview.tsx
@@ -0,0 +1,173 @@
+/*
+ GoToSocial
+ Copyright (C) GoToSocial Authors admin@gotosocial.org
+ SPDX-License-Identifier: AGPL-3.0-or-later
+
+ 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/>.
+*/
+
+import React, { useMemo, useState } from "react";
+import { Link } from "wouter";
+import { matchSorter } from "match-sorter";
+import NewEmojiForm from "./new-emoji";
+import { useTextInput } from "../../../../lib/form";
+import { useEmojiByCategory } from "../category-select";
+import Loading from "../../../../components/loading";
+import { Error } from "../../../../components/error";
+import { TextInput } from "../../../../components/form/inputs";
+import { useListEmojiQuery } from "../../../../lib/query/admin/custom-emoji";
+import { CustomEmoji } from "../../../../lib/types/custom-emoji";
+
+export function EmojiOverview() {
+ const { data: emoji = [], isLoading, isError, error } = useListEmojiQuery({ filter: "domain:local" });
+
+ let content: React.JSX.Element;
+ if (isLoading) {
+ content = <Loading />;
+ } else if (isError) {
+ content = <Error error={error} />;
+ } else {
+ content = (
+ <>
+ <EmojiList emoji={emoji} />
+ <NewEmojiForm />
+ </>
+ );
+ }
+
+ return (
+ <>
+ <h1>Local Custom Emoji</h1>
+ <p>
+ To use custom emoji in your toots they have to be 'local' to the instance.
+ You can either upload them here directly, or copy from those already
+ present on other (known) instances through the <Link to={`/remote`}>Remote Emoji</Link> page.
+ </p>
+ <p>
+ <strong>Be warned!</strong> If you upload more than about 300-400 custom emojis in
+ total on your instance, this may lead to rate-limiting issues for users and clients
+ if they try to load all the emoji images at once (which is what many clients do).
+ </p>
+ {content}
+ </>
+ );
+}
+
+interface EmojiListParams {
+ emoji: CustomEmoji[];
+}
+
+function EmojiList({ emoji }: EmojiListParams) {
+ const filterField = useTextInput("filter");
+ const filter = filterField.value ?? "";
+ const emojiByCategory = useEmojiByCategory(emoji);
+
+ // Filter emoji based on shortcode match
+ // with user input, hiding empty categories.
+ const { filteredEmojis, filteredCount } = useMemo(() => {
+ // Amount of emojis removed by the filter.
+ // Start with the length of the array since
+ // that's the max that can be filtered out.
+ let filteredCount = emoji.length;
+
+ // Results of the filtering.
+ const filteredEmojis: [string, CustomEmoji[]][] = [];
+
+ // Filter from emojis in this category.
+ emojiByCategory.forEach((entries, category) => {
+ const filteredEntries = matchSorter(entries, filter, {
+ keys: ["shortcode"]
+ });
+
+ if (filteredEntries.length == 0) {
+ // Nothing left in this category, don't
+ // bother adding it to filteredEmojis.
+ return;
+ }
+
+ filteredCount -= filteredEntries.length;
+ filteredEmojis.push([category, filteredEntries]);
+ });
+
+ return { filteredEmojis, filteredCount };
+ }, [filter, emojiByCategory, emoji.length]);
+
+ return (
+ <>
+ <h2>Overview</h2>
+ {emoji.length > 0
+ ? <span>{emoji.length} custom emoji {filteredCount > 0 && `(${filteredCount} filtered)`}</span>
+ : <span>No custom emoji yet, you can add one below.</span>
+ }
+ <div className="list emoji-list">
+ <div className="header">
+ <TextInput
+ field={filterField}
+ name="emoji-shortcode"
+ placeholder="Search"
+ />
+ </div>
+ <div className="entries scrolling">
+ {filteredEmojis.length > 0
+ ? (
+ <div className="entries scrolling">
+ {filteredEmojis.map(([category, emojis]) => {
+ return <EmojiCategory key={category} category={category} emojis={emojis} />;
+ })}
+ </div>
+ )
+ : <div className="entry">No local emoji matched your filter.</div>
+ }
+ </div>
+ </div>
+ </>
+ );
+}
+
+interface EmojiCategoryProps {
+ category: string;
+ emojis: CustomEmoji[];
+}
+
+function EmojiCategory({ category, emojis }: EmojiCategoryProps) {
+ return (
+ <div className="entry">
+ <b>{category}</b>
+ <div className="emoji-group">
+ {emojis.map((emoji) => {
+ return (
+ <Link key={emoji.id} to={`/local/${emoji.id}`} >
+ <EmojiPreview emoji={emoji} />
+ </Link>
+ );
+ })}
+ </div>
+ </div>
+ );
+}
+
+function EmojiPreview({ emoji }) {
+ const [ animate, setAnimate ] = useState(false);
+
+ return (
+ <img
+ onMouseEnter={() => { setAnimate(true); }}
+ onMouseLeave={() => { setAnimate(false); }}
+ src={animate ? emoji.url : emoji.static_url}
+ alt={emoji.shortcode}
+ title={emoji.shortcode}
+ loading="lazy"
+ />
+ );
+}
diff --git a/web/source/settings/views/admin/emoji/local/use-shortcode.ts b/web/source/settings/views/admin/emoji/local/use-shortcode.ts
new file mode 100644
index 000000000..358e711b0
--- /dev/null
+++ b/web/source/settings/views/admin/emoji/local/use-shortcode.ts
@@ -0,0 +1,56 @@
+/*
+ GoToSocial
+ Copyright (C) GoToSocial Authors admin@gotosocial.org
+ SPDX-License-Identifier: AGPL-3.0-or-later
+
+ 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/>.
+*/
+
+import { useMemo } from "react";
+
+import { useTextInput } from "../../../../lib/form";
+import { useListEmojiQuery } from "../../../../lib/query/admin/custom-emoji";
+
+const shortcodeRegex = /^\w{2,30}$/;
+
+export default function useShortcode() {
+ const { data: emoji = [] } = useListEmojiQuery({
+ filter: "domain:local"
+ });
+
+ const emojiCodes = useMemo(() => {
+ return new Set(emoji.map((e) => e.shortcode));
+ }, [emoji]);
+
+ return useTextInput("shortcode", {
+ validator: function validateShortcode(code) {
+ // technically invalid, but hacky fix to prevent validation error on page load
+ if (code == "") { 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 (!shortcodeRegex.test(code)) {
+ return "Shortcode must only contain letters, numbers, and underscores";
+ }
+
+ return "";
+ }
+ });
+}
diff --git a/web/source/settings/views/admin/emoji/remote/index.tsx b/web/source/settings/views/admin/emoji/remote/index.tsx
new file mode 100644
index 000000000..5521d1115
--- /dev/null
+++ b/web/source/settings/views/admin/emoji/remote/index.tsx
@@ -0,0 +1,46 @@
+/*
+ GoToSocial
+ Copyright (C) GoToSocial Authors admin@gotosocial.org
+ SPDX-License-Identifier: AGPL-3.0-or-later
+
+ 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/>.
+*/
+
+import React, { useMemo } from "react";
+
+import StealThisLook from "./steal-this-look";
+
+import Loading from "../../../../components/loading";
+import { Error } from "../../../../components/error";
+import { useListEmojiQuery } from "../../../../lib/query/admin/custom-emoji";
+
+export default function RemoteEmoji() {
+ // Local emoji are queried for
+ // shortcode collision detection
+ const {
+ data: emoji = [],
+ isLoading,
+ error
+ } = useListEmojiQuery({ filter: "domain:local" });
+
+ const emojiCodes = useMemo(() => new Set(emoji.map((e) => e.shortcode)), [emoji]);
+
+ return (
+ <>
+ <h1>Custom Emoji (remote)</h1>
+ {error && <Error error={error} />}
+ {isLoading ? <Loading /> : <StealThisLook emojiCodes={emojiCodes} />}
+ </>
+ );
+}
diff --git a/web/source/settings/views/admin/emoji/remote/steal-this-look.tsx b/web/source/settings/views/admin/emoji/remote/steal-this-look.tsx
new file mode 100644
index 000000000..43d0b83e1
--- /dev/null
+++ b/web/source/settings/views/admin/emoji/remote/steal-this-look.tsx
@@ -0,0 +1,235 @@
+/*
+ GoToSocial
+ Copyright (C) GoToSocial Authors admin@gotosocial.org
+ SPDX-License-Identifier: AGPL-3.0-or-later
+
+ 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/>.
+*/
+
+import React, { useCallback, useEffect } from "react";
+
+import { useTextInput, useComboBoxInput, useCheckListInput } from "../../../../lib/form";
+
+import useFormSubmit from "../../../../lib/form/submit";
+
+import CheckList from "../../../../components/check-list";
+import { CategorySelect } from '../category-select';
+
+import { TextInput } from "../../../../components/form/inputs";
+import MutationButton from "../../../../components/form/mutation-button";
+import { Error } from "../../../../components/error";
+import { useSearchItemForEmojiMutation, usePatchRemoteEmojisMutation } from "../../../../lib/query/admin/custom-emoji";
+
+export default function StealThisLook({ emojiCodes }) {
+ const [searchStatus, result] = useSearchItemForEmojiMutation();
+ const urlField = useTextInput("url");
+
+ function submitSearch(e) {
+ e.preventDefault();
+ if (urlField.value !== undefined && urlField.value.trim().length != 0) {
+ searchStatus(urlField.value);
+ }
+ }
+
+ return (
+ <div className="parse-emoji">
+ <h2>Steal this look</h2>
+ <form onSubmit={submitSearch}>
+ <div className="form-field text">
+ <label htmlFor="url">
+ Link to a status:
+ </label>
+ <div className="row">
+ <input
+ type="text"
+ id="url"
+ name="url"
+ onChange={urlField.onChange}
+ value={urlField.value}
+ />
+ <button disabled={result.isLoading}>
+ <i className={[
+ "fa fa-fw",
+ (result.isLoading
+ ? "fa-refresh fa-spin"
+ : "fa-search")
+ ].join(" ")} aria-hidden="true" title="Search" />
+ <span className="sr-only">Search</span>
+ </button>
+ </div>
+ </div>
+ </form>
+ <SearchResult result={result} localEmojiCodes={emojiCodes} />
+ </div>
+ );
+}
+
+function SearchResult({ result, localEmojiCodes }) {
+ const { error, data, isSuccess, isError } = result;
+
+ if (!(isSuccess || isError)) {
+ return null;
+ }
+
+ if (error == "NONE_FOUND") {
+ return "No results found";
+ } else if (error == "LOCAL_INSTANCE") {
+ return <b>This is a local user/status, all referenced emoji are already on your instance</b>;
+ } else if (error != undefined) {
+ return <Error error={result.error} />;
+ }
+
+ if (data.list.length == 0) {
+ return <b>This {data.type == "statuses" ? "status" : "account"} doesn't use any custom emoji</b>;
+ }
+
+ return (
+ <CopyEmojiForm
+ localEmojiCodes={localEmojiCodes}
+ type={data.type}
+ emojiList={data.list}
+ />
+ );
+}
+
+function CopyEmojiForm({ localEmojiCodes, type, emojiList }) {
+ const form = {
+ selectedEmoji: useCheckListInput("selectedEmoji", {
+ entries: emojiList,
+ uniqueKey: "id"
+ }),
+ category: useComboBoxInput("category")
+ };
+
+ const [formSubmit, result] = useFormSubmit(
+ form,
+ usePatchRemoteEmojisMutation(),
+ {
+ changedOnly: false,
+ onFinish: ({ data }) => {
+ if (data) {
+ // uncheck all successfully processed emoji
+ const processed = data.map((emoji) => {
+ return [emoji.id, { checked: false }];
+ });
+ form.selectedEmoji.updateMultiple(processed);
+ }
+ }
+ }
+ );
+
+ const buttonsInactive = form.selectedEmoji.someSelected
+ ? {
+ disabled: false,
+ title: ""
+ }
+ : {
+ disabled: true,
+ title: "No emoji selected, cannot perform any actions"
+ };
+
+ const checkListExtraProps = useCallback(() => ({ localEmojiCodes }), [localEmojiCodes]);
+
+ return (
+ <div className="parsed">
+ <span>This {type == "statuses" ? "status" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span>
+ <form onSubmit={formSubmit}>
+ <CheckList
+ field={form.selectedEmoji}
+ header={<></>}
+ EntryComponent={EmojiEntry}
+ getExtraProps={checkListExtraProps}
+ />
+
+ <CategorySelect
+ field={form.category}
+ children={[]}
+ />
+
+ <div className="action-buttons row">
+ <MutationButton
+ name="copy"
+ label="Copy to local emoji"
+ result={result}
+ showError={false}
+ {...buttonsInactive}
+ />
+ <MutationButton
+ name="disable"
+ label="Disable"
+ result={result}
+ className="button danger"
+ showError={false}
+ {...buttonsInactive}
+ />
+ </div>
+ {result.error && (
+ Array.isArray(result.error)
+ ? <ErrorList errors={result.error} />
+ : <Error error={result.error} />
+ )}
+ </form>
+ </div>
+ );
+}
+
+function ErrorList({ errors }) {
+ return (
+ <div className="error">
+ One or multiple emoji failed to process:
+ {errors.map(([shortcode, err]) => (
+ <div key={shortcode}>
+ <b>{shortcode}:</b> {err}
+ </div>
+ ))}
+ </div>
+ );
+}
+
+function EmojiEntry({ entry: emoji, onChange, extraProps: { localEmojiCodes } }) {
+ const shortcodeField = useTextInput("shortcode", {
+ defaultValue: emoji.shortcode,
+ validator: function validateShortcode(code) {
+ return (emoji.checked && localEmojiCodes.has(code))
+ ? "Shortcode already in use"
+ : "";
+ }
+ });
+
+ useEffect(() => {
+ if (emoji.valid != shortcodeField.valid) {
+ onChange({ valid: shortcodeField.valid });
+ }
+ }, [onChange, emoji.valid, shortcodeField.valid]);
+
+ useEffect(() => {
+ shortcodeField.validate();
+ // only need this update if it's the emoji.checked that updated, not shortcodeField
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [emoji.checked]);
+
+ return (
+ <>
+ <img className="emoji" src={emoji.url} title={emoji.shortcode} />
+
+ <TextInput
+ field={shortcodeField}
+ onChange={(e) => {
+ shortcodeField.onChange(e);
+ onChange({ shortcode: e.target.value, checked: true });
+ }}
+ />
+ </>
+ );
+}
diff --git a/web/source/settings/views/admin/routes.tsx b/web/source/settings/views/admin/routes.tsx
new file mode 100644
index 000000000..29889046c
--- /dev/null
+++ b/web/source/settings/views/admin/routes.tsx
@@ -0,0 +1,177 @@
+/*
+ GoToSocial
+ Copyright (C) GoToSocial Authors admin@gotosocial.org
+ SPDX-License-Identifier: AGPL-3.0-or-later
+
+ 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/>.
+*/
+
+import { MenuItem } from "../../lib/navigation/menu";
+import React from "react";
+import { BaseUrlContext, useBaseUrl } from "../../lib/navigation/util";
+import { Route, Router, Switch } from "wouter";
+import EmojiDetail from "./emoji/local/detail";
+import { EmojiOverview } from "./emoji/local/overview";
+import RemoteEmoji from "./emoji/remote";
+import InstanceSettings from "./settings";
+import { InstanceRuleDetail, InstanceRules } from "./settings/rules";
+import Media from "./actions/media";
+import Keys from "./actions/keys";
+
+/*
+ EXPORTED COMPONENTS
+*/
+
+/**
+ * Admininistration menu. Admin actions,
+ * emoji import, instance settings.
+ */
+export function AdminMenu() {
+ return (
+ <MenuItem
+ name="Administration"
+ itemUrl="admin"
+ defaultChild="actions"
+ permissions={["admin"]}
+ >
+ <MenuItem
+ name="Instance Settings"
+ itemUrl="instance-settings"
+ icon="fa-sliders"
+ />
+ <MenuItem
+ name="Instance Rules"
+ itemUrl="instance-rules"
+ icon="fa-dot-circle-o"
+ />
+ <AdminEmojisMenu />
+ <AdminActionsMenu />
+ </MenuItem>
+ );
+}
+
+/**
+ * Admininistration router. Admin actions,
+ * emoji import, instance settings.
+ */
+export function AdminRouter() {
+ const parentUrl = useBaseUrl();
+ const thisBase = "/admin";
+ const absBase = parentUrl + thisBase;
+
+ return (
+ <BaseUrlContext.Provider value={absBase}>
+ <Router base={thisBase}>
+ <Route path="/instance-settings" component={InstanceSettings}/>
+ <Route path="/instance-rules" component={InstanceRules} />
+ <Route path="/instance-rules/:ruleId" component={InstanceRuleDetail} />
+ <AdminEmojisRouter />
+ <AdminActionsRouter />
+ </Router>
+ </BaseUrlContext.Provider>
+ );
+}
+
+/*
+ INTERNAL COMPONENTS
+*/
+
+/*
+ MENUS
+*/
+
+function AdminActionsMenu() {
+ return (
+ <MenuItem
+ name="Actions"
+ itemUrl="actions"
+ defaultChild="media"
+ icon="fa-bolt"
+ >
+ <MenuItem
+ name="Media"
+ itemUrl="media"
+ icon="fa-photo"
+ />
+ <MenuItem
+ name="Keys"
+ itemUrl="keys"
+ icon="fa-key-modern"
+ />
+ </MenuItem>
+ );
+}
+
+function AdminEmojisMenu() {
+ return (
+ <MenuItem
+ name="Custom Emoji"
+ itemUrl="emojis"
+ defaultChild="local"
+ icon="fa-smile-o"
+ >
+ <MenuItem
+ name="Local"
+ itemUrl="local"
+ icon="fa-home"
+ />
+ <MenuItem
+ name="Remote"
+ itemUrl="remote"
+ icon="fa-cloud"
+ />
+ </MenuItem>
+ );
+}
+
+/*
+ ROUTERS
+*/
+
+function AdminEmojisRouter() {
+ const parentUrl = useBaseUrl();
+ const thisBase = "/emojis";
+ const absBase = parentUrl + thisBase;
+
+ return (
+ <BaseUrlContext.Provider value={absBase}>
+ <Router base={thisBase}>
+ <Switch>
+ <Route path="/local/:emojiId" component={EmojiDetail} />
+ <Route path="/local" component={EmojiOverview} />
+ <Route path="/remote" component={RemoteEmoji} />
+ <Route component={EmojiOverview}/>
+ </Switch>
+ </Router>
+ </BaseUrlContext.Provider>
+ );
+}
+
+function AdminActionsRouter() {
+ const parentUrl = useBaseUrl();
+ const thisBase = "/actions";
+ const absBase = parentUrl + thisBase;
+
+ return (
+ <BaseUrlContext.Provider value={absBase}>
+ <Router base={thisBase}>
+ <Switch>
+ <Route path="/media" component={Media} />
+ <Route path="/keys" component={Keys} />
+ <Route component={Media}/>
+ </Switch>
+ </Router>
+ </BaseUrlContext.Provider>
+ );
+}
diff --git a/web/source/settings/views/admin/settings/index.tsx b/web/source/settings/views/admin/settings/index.tsx
new file mode 100644
index 000000000..abb34cf66
--- /dev/null
+++ b/web/source/settings/views/admin/settings/index.tsx
@@ -0,0 +1,190 @@
+/*
+ GoToSocial
+ Copyright (C) GoToSocial Authors admin@gotosocial.org
+ SPDX-License-Identifier: AGPL-3.0-or-later
+
+ 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/>.
+*/
+
+import React from "react";
+
+import { useTextInput, useFileInput } from "../../../lib/form";
+
+const useFormSubmit = require("../../../lib/form/submit").default;
+
+import { TextInput, TextArea, FileInput } from "../../../components/form/inputs";
+
+const FormWithData = require("../../../lib/form/form-with-data").default;
+import MutationButton from "../../../components/form/mutation-button";
+
+import { useInstanceV1Query } from "../../../lib/query";
+import { useUpdateInstanceMutation } from "../../../lib/query/admin";
+import { InstanceV1 } from "../../../lib/types/instance";
+
+export default function InstanceSettings() {
+ return (
+ <FormWithData
+ dataQuery={useInstanceV1Query}
+ DataForm={InstanceSettingsForm}
+ />
+ );
+}
+
+interface InstanceSettingsFormProps{
+ data: InstanceV1;
+}
+
+function InstanceSettingsForm({ data: instance }: InstanceSettingsFormProps) {
+ const titleLimit = 40;
+ const shortDescLimit = 500;
+ const descLimit = 5000;
+ const termsLimit = 5000;
+
+ const form = {
+ title: useTextInput("title", {
+ source: instance,
+ validator: (val: string) => val.length <= titleLimit ? "" : `Instance title is ${val.length} characters; must be ${titleLimit} characters or less`
+ }),
+ thumbnail: useFileInput("thumbnail", { withPreview: true }),
+ thumbnailDesc: useTextInput("thumbnail_description", { source: instance }),
+ shortDesc: useTextInput("short_description", {
+ source: instance,
+ // Select "raw" text version of parsed field for editing.
+ valueSelector: (s: InstanceV1) => s.short_description_text,
+ validator: (val: string) => val.length <= shortDescLimit ? "" : `Instance short description is ${val.length} characters; must be ${shortDescLimit} characters or less`
+ }),
+ description: useTextInput("description", {
+ source: instance,
+ // Select "raw" text version of parsed field for editing.
+ valueSelector: (s: InstanceV1) => s.description_text,
+ validator: (val: string) => val.length <= descLimit ? "" : `Instance description is ${val.length} characters; must be ${descLimit} characters or less`
+ }),
+ terms: useTextInput("terms", {
+ source: instance,
+ // Select "raw" text version of parsed field for editing.
+ valueSelector: (s: InstanceV1) => s.terms_text,
+ validator: (val: string) => val.length <= termsLimit ? "" : `Instance terms and conditions is ${val.length} characters; must be ${termsLimit} characters or less`
+ }),
+ contactUser: useTextInput("contact_username", { source: instance, valueSelector: (s) => s.contact_account?.username }),
+ contactEmail: useTextInput("contact_email", { source: instance, valueSelector: (s) => s.email })
+ };
+
+ const [submitForm, result] = useFormSubmit(form, useUpdateInstanceMutation());
+
+ return (
+ <form onSubmit={submitForm}>
+ <h1>Instance Settings</h1>
+
+ <div className="form-section-docs">
+ <h3>Appearance</h3>
+ <a
+ href="https://docs.gotosocial.org/en/latest/admin/settings/#instance-appearance"
+ target="_blank"
+ className="docslink"
+ rel="noreferrer"
+ >
+ Learn more about these settings (opens in a new tab)
+ </a>
+ </div>
+
+ <TextInput
+ field={form.title}
+ label={`Instance title (max ${titleLimit} characters)`}
+ placeholder="My GoToSocial instance"
+ />
+
+ <div className="file-upload" aria-labelledby="avatar">
+ <strong id="avatar">Instance avatar (1:1 images look best)</strong>
+ <div className="file-upload-with-preview">
+ <img
+ className="preview avatar"
+ src={form.thumbnail.previewValue ?? instance?.thumbnail}
+ alt={form.thumbnailDesc.value ?? (instance?.thumbnail ? `Thumbnail image for the instance` : "No instance thumbnail image set")}
+ />
+ <div className="file-input-with-image-description">
+ <FileInput
+ field={form.thumbnail}
+ accept="image/png, image/jpeg, image/webp, image/gif"
+ />
+ <TextInput
+ field={form.thumbnailDesc}
+ label="Avatar image description"
+ placeholder="A cute drawing of a smiling sloth."
+ />
+ </div>
+ </div>
+
+ </div>
+
+ <div className="form-section-docs">
+ <h3>Descriptors</h3>
+ <a
+ href="https://docs.gotosocial.org/en/latest/admin/settings/#instance-descriptors"
+ target="_blank"
+ className="docslink"
+ rel="noreferrer"
+ >
+ Learn more about these settings (opens in a new tab)
+ </a>
+ </div>
+
+ <TextArea
+ field={form.shortDesc}
+ label={`Short description (markdown accepted, max ${shortDescLimit} characters)`}
+ placeholder="A small testing instance for the GoToSocial alpha software."
+ rows={6}
+ />
+
+ <TextArea
+ field={form.description}
+ label={`Full description (markdown accepted, max ${descLimit} characters)`}
+ placeholder="A small testing instance for the GoToSocial alpha software. Just trying it out, my main instance is https://example.com"
+ rows={6}
+ />
+
+ <TextArea
+ field={form.terms}
+ label={`Terms & Conditions (markdown accepted, max ${termsLimit} characters)`}
+ placeholder="Terms and conditions of using this instance, data policy, imprint, GDPR stuff, yadda yadda."
+ rows={6}
+ />
+
+ <div className="form-section-docs">
+ <h3>Contact info</h3>
+ <a
+ href="https://docs.gotosocial.org/en/latest/admin/settings/#instance-contact-info"
+ target="_blank"
+ className="docslink"
+ rel="noreferrer"
+ >
+ Learn more about these settings (opens in a new tab)
+ </a>
+ </div>
+
+ <TextInput
+ field={form.contactUser}
+ label="Contact user (local account username)"
+ placeholder="admin"
+ />
+
+ <TextInput
+ field={form.contactEmail}
+ label="Contact email"
+ placeholder="admin@example.com"
+ />
+
+ <MutationButton label="Save" result={result} disabled={false} />
+ </form>
+ );
+} \ No newline at end of file
diff --git a/web/source/settings/views/admin/settings/rules.tsx b/web/source/settings/views/admin/settings/rules.tsx
new file mode 100644
index 000000000..2b8a51c22
--- /dev/null
+++ b/web/source/settings/views/admin/settings/rules.tsx
@@ -0,0 +1,151 @@
+/*
+ GoToSocial
+ Copyright (C) GoToSocial Authors admin@gotosocial.org
+ SPDX-License-Identifier: AGPL-3.0-or-later
+
+ 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/>.
+*/
+
+import React from "react";
+import { Link, Redirect, useParams } from "wouter";
+import { useInstanceRulesQuery, useAddInstanceRuleMutation, useUpdateInstanceRuleMutation, useDeleteInstanceRuleMutation } from "../../../lib/query";
+import { useBaseUrl } from "../../../lib/navigation/util";
+import { useValue, useTextInput } from "../../../lib/form";
+import useFormSubmit from "../../../lib/form/submit";
+import { TextArea } from "../../../components/form/inputs";
+import MutationButton from "../../../components/form/mutation-button";
+import { Error } from "../../../components/error";
+import BackButton from "../../../components/back-button";
+import { InstanceRule, MappedRules } from "../../../lib/types/rules";
+import Loading from "../../../components/loading";
+import FormWithData from "../../../lib/form/form-with-data";
+
+export function InstanceRules() {
+ return (
+ <>
+ <h1>Instance Rules</h1>
+ <FormWithData
+ dataQuery={useInstanceRulesQuery}
+ DataForm={InstanceRulesForm}
+ />
+ </>
+ );
+}
+
+function InstanceRulesForm({ data: rules }: { data: MappedRules }) {
+ const baseUrl = useBaseUrl();
+ const newRule = useTextInput("text");
+
+ const [submitForm, result] = useFormSubmit({ newRule }, useAddInstanceRuleMutation(), {
+ changedOnly: true,
+ onFinish: () => newRule.reset()
+ });
+
+ return (
+ <form onSubmit={submitForm} className="new-rule">
+ <ol className="instance-rules">
+ {Object.values(rules).map((rule: InstanceRule) => (
+ <Link className="rule" to={`~${baseUrl}/instance-rules/${rule.id}`}>
+ <li>
+ <h2>{rule.text} <i className="fa fa-pencil edit-icon" /></h2>
+ </li>
+ <span>{new Date(rule.created_at).toLocaleString()}</span>
+ </Link>
+ ))}
+ </ol>
+ <TextArea
+ field={newRule}
+ label="New instance rule"
+ />
+ <MutationButton
+ disabled={newRule.value === undefined || newRule.value.length === 0}
+ label="Add rule"
+ result={result}
+ />
+ </form>
+ );
+}
+
+export function InstanceRuleDetail() {
+ const baseUrl = useBaseUrl();
+ const params: { ruleId: string } = useParams();
+
+ const { data: rules, isLoading, isError, error } = useInstanceRulesQuery();
+ if (isLoading) {
+ return <Loading />;
+ } else if (isError) {
+ return <Error error={error} />;
+ }
+
+ if (rules === undefined) {
+ throw "undefined rules";
+ }
+
+ return (
+ <>
+ <BackButton to={`~${baseUrl}/instance-rules`} />
+ <EditInstanceRuleForm rule={rules[params.ruleId]} />
+ </>
+ );
+}
+
+function EditInstanceRuleForm({ rule }) {
+ const baseUrl = useBaseUrl();
+ const form = {
+ id: useValue("id", rule.id),
+ rule: useTextInput("text", { defaultValue: rule.text })
+ };
+
+ const [submitForm, result] = useFormSubmit(form, useUpdateInstanceRuleMutation());
+
+ const [deleteRule, deleteResult] = useDeleteInstanceRuleMutation({ fixedCacheKey: rule.id });
+
+ if (result.isSuccess || deleteResult.isSuccess) {
+ return (
+ <Redirect to={`~${baseUrl}/instance-rules`} />
+ );
+ }
+
+ return (
+ <div className="rule-detail">
+ <form onSubmit={submitForm}>
+ <TextArea
+ field={form.rule}
+ />
+
+ <div className="action-buttons row">
+ <MutationButton
+ label="Save"
+ showError={false}
+ result={result}
+ disabled={!form.rule.hasChanged()}
+ />
+
+ <MutationButton
+ disabled={false}
+ type="button"
+ onClick={() => deleteRule(rule.id)}
+ label="Delete"
+ className="button danger"
+ showError={false}
+ result={deleteResult}
+ />
+ </div>
+
+ {result.error && <Error error={result.error} />}
+ {deleteResult.error && <Error error={deleteResult.error} />}
+ </form>
+ </div>
+ );
+}