[chore] Refactor settings panel routing (and other fixes) (#2864)

This commit is contained in:
tobi 2024-04-24 12:12:47 +02:00 committed by GitHub
parent 62788aa116
commit 7a1e639483
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 1788 additions and 1445 deletions

11
.vscode/settings.json vendored
View file

@ -10,5 +10,14 @@
}, },
"eslint.workingDirectories": ["web/source"], "eslint.workingDirectories": ["web/source"],
"eslint.lintTask.enable": true, "eslint.lintTask.enable": true,
"eslint.lintTask.options": "${workspaceFolder}/web/source" "eslint.lintTask.options": "${workspaceFolder}/web/source",
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
} }

View file

@ -78,7 +78,7 @@ skulk({
// commonjs here, no need for the typescript preset. // commonjs here, no need for the typescript preset.
["babelify", { ["babelify", {
global: true, global: true,
ignore: [/node_modules\/(?!nanoid)/], ignore: [/node_modules\/(?!(nanoid)|(wouter))/],
}] }]
], ],
presets: [ presets: [

View file

@ -13,7 +13,6 @@
"dependencies": { "dependencies": {
"@reduxjs/toolkit": "^1.8.6", "@reduxjs/toolkit": "^1.8.6",
"ariakit": "^2.0.0-next.41", "ariakit": "^2.0.0-next.41",
"bluebird": "^3.7.2",
"get-by-dot": "^1.0.2", "get-by-dot": "^1.0.2",
"is-valid-domain": "^0.1.6", "is-valid-domain": "^0.1.6",
"js-file-download": "^0.4.12", "js-file-download": "^0.4.12",
@ -33,9 +32,7 @@
"redux": "^4.2.0", "redux": "^4.2.0",
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",
"skulk": "^0.0.8-fix", "skulk": "^0.0.8-fix",
"split-filter-n": "^1.1.3", "wouter": "^3.1.0"
"syncpipe": "^1.0.0",
"wouter": "^2.8.0-alpha.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.23.0", "@babel/core": "^7.23.0",
@ -45,14 +42,13 @@
"@browserify/envify": "^6.0.0", "@browserify/envify": "^6.0.0",
"@browserify/uglifyify": "^6.0.0", "@browserify/uglifyify": "^6.0.0",
"@joepie91/eslint-config": "^1.1.1", "@joepie91/eslint-config": "^1.1.1",
"@types/bluebird": "^3.5.39",
"@types/is-valid-domain": "^0.0.2", "@types/is-valid-domain": "^0.0.2",
"@types/papaparse": "^5.3.9", "@types/papaparse": "^5.3.9",
"@types/psl": "^1.1.1", "@types/psl": "^1.1.1",
"@types/react-dom": "^18.2.8", "@types/react-dom": "^18.2.8",
"@typescript-eslint/eslint-plugin": "^6.7.4", "@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4", "@typescript-eslint/parser": "^6.7.4",
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.19",
"babelify": "^10.0.0", "babelify": "^10.0.0",
"css-extract": "^2.0.0", "css-extract": "^2.0.0",
"eslint": "^8.26.0", "eslint": "^8.26.0",

View file

@ -1,49 +0,0 @@
/*
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 { Switch, Route } from "wouter";
import DomainPermissionsOverview from "./overview";
import { PermType } from "../../lib/types/domain-permission";
import DomainPermDetail from "./detail";
export default function DomainPermissions({ baseUrl }: { baseUrl: string }) {
return (
<Switch>
<Route path="/settings/admin/domain-permissions/:permType/:domain">
{params => (
<DomainPermDetail
permType={params.permType as PermType}
baseUrl={baseUrl}
domain={params.domain}
/>
)}
</Route>
<Route path="/settings/admin/domain-permissions/:permType">
{params => (
<DomainPermissionsOverview
permType={params.permType as PermType}
baseUrl={baseUrl}
/>
)}
</Route>
</Switch>
);
}

View file

@ -1,96 +0,0 @@
/*
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/>.
*/
const React = require("react");
const splitFilterN = require("split-filter-n");
const syncpipe = require('syncpipe');
const { matchSorter } = require("match-sorter");
const ComboBox = require("../../components/combo-box");
const { useListEmojiQuery } = require("../../lib/query/admin/custom-emoji");
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({ field, children }) {
const { value, setIsNew } = field;
const {
data: emoji = [],
isLoading,
isSuccess,
error
} = useListEmojiQuery({ 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,
<>
<img src={emojiByCategory[categoryName][0].static_url} aria-hidden="true"></img>
{categoryName}
</>
])
]);
}, [emojiByCategory, value]);
React.useEffect(() => {
if (value != undefined && isSuccess && value.trim().length > 0) {
setIsNew(!categories.has(value.trim()));
}
}, [categories, value, isSuccess, setIsNew]);
if (error) { // fall back to plain text input, but this would almost certainly have caused a bigger error message elsewhere
return (
<>
<input type="text" placeholder="e.g., reactions" onChange={(e) => { field.value = e.target.value; }} />;
</>
);
} else if (isLoading) {
return <input type="text" value="Loading categories..." disabled={true} />;
}
return (
<ComboBox
field={field}
items={categoryItems}
label="Category"
placeholder="e.g., reactions"
children={children}
/>
);
}
module.exports = {
useEmojiByCategory,
CategorySelect
};

View file

@ -1,153 +0,0 @@
/*
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/>.
*/
const React = require("react");
const { Link } = require("wouter");
const syncpipe = require("syncpipe");
const { matchSorter } = require("match-sorter");
const NewEmojiForm = require("./new-emoji").default;
const { useTextInput } = require("../../../lib/form");
const { useEmojiByCategory } = require("../category-select");
const { useBaseUrl } = require("../../../lib/navigation/util");
const Loading = require("../../../components/loading");
const { Error } = require("../../../components/error");
const { TextInput } = require("../../../components/form/inputs");
const { useListEmojiQuery } = require("../../../lib/query/admin/custom-emoji");
module.exports = function EmojiOverview({ }) {
const {
data: emoji = [],
isLoading,
isError,
error
} = useListEmojiQuery({ filter: "domain:local" });
let content = null;
if (isLoading) {
content = <Loading />;
} else if (isError) {
content = <Error error={error} />;
} else {
content = (
<>
<EmojiList emoji={emoji} />
<NewEmojiForm emoji={emoji} />
</>
);
}
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}
</>
);
};
function EmojiList({ emoji }) {
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 { filteredEmoji, hidden } = React.useMemo(() => {
let hidden = emoji.length;
const filteredEmoji = syncpipe(emojiByCategory, [
(_) => Object.entries(emojiByCategory),
(_) => _.map(([category, entries]) => {
let filteredEntries = matchSorter(entries, filter, { keys: ["shortcode"] });
if (filteredEntries.length == 0) {
return null;
} else {
hidden -= filteredEntries.length;
return [category, filteredEntries];
}
}),
(_) => _.filter((value) => value !== null)
]);
return { filteredEmoji, hidden };
}, [filter, emojiByCategory, emoji.length]);
return (
<div>
<h2>Overview</h2>
{emoji.length > 0
? <span>{emoji.length} custom emoji {hidden > 0 && `(${hidden} 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">
{filteredEmoji.length > 0
? (
<div className="entries scrolling">
{filteredEmoji.map(([category, entries]) => {
return <EmojiCategory key={category} category={category} entries={entries} />;
})}
</div>
)
: <div className="entry">No local emoji matched your filter.</div>
}
</div>
</div>
</div>
);
}
function EmojiCategory({ category, entries }) {
const baseUrl = useBaseUrl();
return (
<div className="entry">
<b>{category}</b>
<div className="emoji-group">
{entries.map((e) => {
return (
<Link key={e.id} to={`${baseUrl}/${e.id}`}>
<a>
<img src={e.url} alt={e.shortcode} title={`:${e.shortcode}:`} />
</a>
</Link>
);
})}
</div>
</div>
);
}

View file

@ -1,174 +0,0 @@
/*
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 { Switch, Route, Link, Redirect, useRoute } from "wouter";
import { useInstanceRulesQuery, useAddInstanceRuleMutation, useUpdateInstanceRuleMutation, useDeleteInstanceRuleMutation } from "../../lib/query";
import FormWithData from "../../lib/form/form-with-data";
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";
export default function InstanceRulesData({ baseUrl }) {
return (
<FormWithData
dataQuery={useInstanceRulesQuery}
DataForm={InstanceRules}
{...{baseUrl}}
/>
);
}
function InstanceRules({ baseUrl, data: rules }) {
return (
<Switch>
<Route path={`${baseUrl}/:ruleId`}>
<InstanceRuleDetail rules={rules} />
</Route>
<Route>
<div>
<h1>Instance Rules</h1>
<div>
<p>
The rules for your instance are listed on the about page, and can be selected when submitting reports.
</p>
</div>
<InstanceRuleList rules={rules} />
</div>
</Route>
</Switch>
);
}
function InstanceRuleList({ rules }) {
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: any) => (
<InstanceRule key={rule.id} rule={rule} />
))}
</ol>
<TextArea
field={newRule}
label="New instance rule"
/>
<MutationButton
disabled={newRule.value === undefined || newRule.value.length === 0}
label="Add rule"
result={result}
/>
</form>
</>
);
}
function InstanceRule({ rule }) {
const baseUrl = useBaseUrl();
return (
<Link to={`${baseUrl}/${rule.id}`}>
<a className="rule">
<li>
<h2>{rule.text} <i className="fa fa-pencil edit-icon" /></h2>
</li>
<span>{new Date(rule.created_at).toLocaleString()}</span>
</a>
</Link>
);
}
function InstanceRuleDetail({ rules }) {
const baseUrl = useBaseUrl();
let [_match, params] = useRoute(`${baseUrl}/:ruleId`);
if (params?.ruleId == undefined || rules[params.ruleId] == undefined) {
return <Redirect to={baseUrl} />;
} else {
return (
<>
<Link to={baseUrl}><a>&lt; go back</a></Link>
<InstanceRuleForm rule={rules[params.ruleId]} />
</>
);
}
}
function InstanceRuleForm({ 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} />
);
}
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>
);
}

View file

@ -68,7 +68,7 @@ export function AccountList({
<Link <Link
key={acc.acct} key={acc.acct}
className="account entry" className="account entry"
href={`/settings/admin/accounts/${acc.id}`} href={`/${acc.id}`}
> >
{acc.display_name?.length > 0 {acc.display_name?.length > 0
? acc.display_name ? acc.display_name

View file

@ -17,13 +17,11 @@
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
const React = require("react"); import React from "react";
const { Link } = require("wouter"); import { Link } from "wouter";
module.exports = function BackButton({ to }) { export default function BackButton({ to }) {
return ( return (
<Link to={to}> <Link className="button" to={to}>&lt; back</Link>
<a className="button">&lt; back</a>
</Link>
); );
}; }

View file

@ -1,124 +0,0 @@
/*
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/>.
*/
const React = require("react");
const ReactDom = require("react-dom/client");
const { Provider } = require("react-redux");
const { PersistGate } = require("redux-persist/integration/react");
const { store, persistor } = require("./redux/store");
const { createNavigation, Menu, Item } = require("./lib/navigation");
const { Authorization } = require("./components/authorization");
const Loading = require("./components/loading");
const UserLogoutCard = require("./components/user-logout-card");
const { RoleContext } = require("./lib/navigation/util");
const UserProfile = require("./user/profile").default;
const UserSettings = require("./user/settings").default;
const UserMigration = require("./user/migration").default;
const Reports = require("./admin/reports").default;
const Accounts = require("./admin/accounts").default;
const AccountsPending = require("./admin/accounts/pending").default;
const DomainPerms = require("./admin/domain-permissions").default;
const DomainPermsImportExport = require("./admin/domain-permissions/import-export").default;
const AdminMedia = require("./admin/actions/media").default;
const AdminKeys = require("./admin/actions/keys").default;
const LocalEmoji = require("./admin/emoji/local").default;
const RemoteEmoji = require("./admin/emoji/remote").default;
const InstanceSettings = require("./admin/settings").default;
const InstanceRules = require("./admin/settings/rules").default;
require("./style.css");
const { Sidebar, ViewRouter } = createNavigation("/settings", [
Menu("User", [
Item("Profile", { icon: "fa-user" }, UserProfile),
Item("Settings", { icon: "fa-cogs" }, UserSettings),
Item("Migration", { icon: "fa-exchange" }, UserMigration),
]),
Menu("Moderation", {
url: "admin",
permissions: ["admin"]
}, [
Item("Reports", { icon: "fa-flag", wildcard: true }, Reports),
Item("Accounts", { icon: "fa-users", wildcard: true }, [
Item("Overview", { icon: "fa-list", url: "", wildcard: true }, Accounts),
Item("Pending", { icon: "fa-question", url: "pending", wildcard: true }, AccountsPending),
]),
Menu("Domain Permissions", { icon: "fa-hubzilla" }, [
Item("Blocks", { icon: "fa-close", url: "block", wildcard: true }, DomainPerms),
Item("Allows", { icon: "fa-check", url: "allow", wildcard: true }, DomainPerms),
Item("Import/Export", { icon: "fa-floppy-o", url: "import-export", wildcard: true }, DomainPermsImportExport),
]),
]),
Menu("Administration", {
url: "admin",
defaultUrl: "/settings/admin/settings",
permissions: ["admin"]
}, [
Menu("Actions", { icon: "fa-bolt" }, [
Item("Media", { icon: "fa-photo" }, AdminMedia),
Item("Keys", { icon: "fa-key-modern" }, AdminKeys),
]),
Menu("Custom Emoji", { icon: "fa-smile-o" }, [
Item("Local", { icon: "fa-home", wildcard: true }, LocalEmoji),
Item("Remote", { icon: "fa-cloud" }, RemoteEmoji),
]),
Menu("Settings", { icon: "fa-sliders" }, [
Item("Settings", { icon: "fa-sliders", url: "" }, InstanceSettings),
Item("Rules", { icon: "fa-dot-circle-o", wildcard: true }, InstanceRules),
]),
])
]);
function App({ account }) {
const permissions = [account.role.name];
return (
<RoleContext.Provider value={permissions}>
<div className="sidebar">
<UserLogoutCard />
<Sidebar />
</div>
<section className="with-sidebar">
<ViewRouter />
</section>
</RoleContext.Provider>
);
}
function Main() {
return (
<Provider store={store}>
<PersistGate loading={<section><Loading /></section>} persistor={persistor}>
<Authorization App={App} />
</PersistGate>
</Provider>
);
}
const root = ReactDom.createRoot(document.getElementById("root"));
root.render(<React.StrictMode><Main /></React.StrictMode>);

View file

@ -0,0 +1,84 @@
/*
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, { StrictMode } from "react";
import "./style.css";
import { createRoot } from "react-dom/client";
import { Provider } from "react-redux";
import { PersistGate } from "redux-persist/integration/react";
import { store, persistor } from "./redux/store";
import { Authorization } from "./components/authorization";
import Loading from "./components/loading";
import { Account } from "./lib/types/account";
import { BaseUrlContext, RoleContext } from "./lib/navigation/util";
import { SidebarMenu } from "./lib/navigation/menu";
import { UserMenu, UserRouter } from "./views/user/routes";
import { ModerationMenu, ModerationRouter } from "./views/moderation/routes";
import { AdminMenu, AdminRouter } from "./views/admin/routes";
import { Redirect, Route, Router } from "wouter";
interface AppProps {
account: Account;
}
export function App({ account }: AppProps) {
const roles: string[] = [ account.role.name ];
return (
<RoleContext.Provider value={roles}>
<BaseUrlContext.Provider value={"/settings"}>
<SidebarMenu>
<UserMenu />
<ModerationMenu />
<AdminMenu />
</SidebarMenu>
<section className="with-sidebar">
<Router base="/settings">
<UserRouter />
<ModerationRouter />
<AdminRouter />
{/*
Redirect to first part of UserRouter if
just the bare settings page is open, so
user isn't greeted with a blank page.
*/}
<Route><Redirect to="/user/profile" /></Route>
</Router>
</section>
</BaseUrlContext.Provider>
</RoleContext.Provider>
);
}
function Main() {
return (
<Provider store={store}>
<PersistGate
loading={<section><Loading /></section>}
persistor={persistor}
>
<Authorization App={App} />
</PersistGate>
</Provider>
);
}
const root = createRoot(document.getElementById("root") as HTMLElement);
root.render(<StrictMode><Main /></StrictMode>);

View file

@ -1,201 +0,0 @@
/*
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/>.
*/
const React = require("react");
const { Link, Route, Redirect, Switch, useLocation, useRouter } = require("wouter");
const syncpipe = require("syncpipe");
const {
RoleContext,
useHasPermission,
checkPermission,
BaseUrlContext
} = require("./util");
const ActiveRouteCtx = React.createContext();
function useActiveRoute() {
return React.useContext(ActiveRouteCtx);
}
function Sidebar(menuTree, routing) {
const components = menuTree.map((m) => m.MenuEntry);
return function SidebarComponent() {
const router = useRouter();
const [location] = useLocation();
let activeRoute = routing.find((l) => {
let [match] = router.matcher(l.routingUrl, location);
return match;
})?.routingUrl;
return (
<nav className="menu-tree">
<ul className="top-level">
<ActiveRouteCtx.Provider value={activeRoute}>
{components}
</ActiveRouteCtx.Provider>
</ul>
</nav>
);
};
}
function ViewRouter(routing, defaultRoute) {
return function ViewRouterComponent() {
const permissions = React.useContext(RoleContext);
const filteredRoutes = React.useMemo(() => {
return syncpipe(routing, [
(_) => _.filter((route) => checkPermission(route.permissions, permissions)),
(_) => _.map((route) => {
return (
<Route path={route.routingUrl} key={route.key}>
<ErrorBoundary>
{/* FIXME: implement reset */}
<BaseUrlContext.Provider value={route.url}>
{route.view}
</BaseUrlContext.Provider>
</ErrorBoundary>
</Route>
);
})
]);
}, [permissions]);
return (
<Switch>
{filteredRoutes}
<Redirect to={defaultRoute} />
</Switch>
);
};
}
function MenuComponent({ type, name, url, icon, permissions, links, level, children }) {
const activeRoute = useActiveRoute();
if (!useHasPermission(permissions)) {
return null;
}
const classes = [type];
if (level == 0) {
classes.push("top-level");
} else if (level == 1) {
classes.push("expanding");
} else {
classes.push("nested");
}
const isActive = links.includes(activeRoute);
if (isActive) {
classes.push("active");
}
const className = classes.join(" ");
return (
<li className={className}>
<Link href={url}>
<a tabIndex={level == 0 ? "-1" : null} className="title">
{icon && <i className={`icon fa fa-fw ${icon}`} aria-hidden="true" />}
{name}
</a>
</Link>
{(type == "category" && (level == 0 || isActive) && children?.length > 0) &&
<ul>
{children}
</ul>
}
</li>
);
}
class ErrorBoundary extends React.Component {
constructor() {
super();
this.state = {};
this.resetErrorBoundary = () => {
this.setState({});
};
}
static getDerivedStateFromError(error) {
return { hadError: true, error };
}
componentDidCatch(_e, info) {
this.setState({
...this.state,
componentStack: info.componentStack
});
}
render() {
if (this.state.hadError) {
return (
<ErrorFallback
error={this.state.error}
componentStack={this.state.componentStack}
resetErrorBoundary={this.resetErrorBoundary}
/>
);
} else {
return this.props.children;
}
}
}
function ErrorFallback({ error, componentStack, resetErrorBoundary }) {
return (
<div className="error">
<p>
{"An error occured, please report this on the "}
<a href="https://github.com/superseriousbusiness/gotosocial/issues">GoToSocial issue tracker</a>
{" or "}
<a href="https://matrix.to/#/#gotosocial-help:superseriousbusiness.org">Matrix support room</a>.
<br />Include the details below:
</p>
<div className="details">
<pre>
{error.name}: {error.message}
{componentStack && [
"\n\nComponent trace:",
componentStack
]}
{["\n\nError trace: ", error.stack]}
</pre>
</div>
<p>
<button onClick={resetErrorBoundary}>Try again</button> or <a href="">refresh the page</a>
</p>
</div>
);
}
module.exports = {
Sidebar,
ViewRouter,
MenuComponent
};

View file

@ -0,0 +1,98 @@
/*
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, { Component, ReactNode } from "react";
interface ErrorBoundaryProps {
children?: ReactNode;
}
interface ErrorBoundaryState {
hadError?: boolean;
componentStack?;
error?;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
resetErrorBoundary: () => void;
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = {};
this.resetErrorBoundary = () => {
this.setState({});
};
}
static getDerivedStateFromError(error) {
return { hadError: true, error };
}
componentDidCatch(_e, info) {
this.setState({
...this.state,
componentStack: info.componentStack
});
}
render() {
if (this.state.hadError) {
return (
<ErrorFallback
error={this.state.error}
componentStack={this.state.componentStack}
resetErrorBoundary={this.resetErrorBoundary}
/>
);
} else {
return this.props.children;
}
}
}
function ErrorFallback({ error, componentStack, resetErrorBoundary }) {
return (
<div className="error">
<p>
{"An error occured, please report this on the "}
<a href="https://github.com/superseriousbusiness/gotosocial/issues">GoToSocial issue tracker</a>
{" or "}
<a href="https://matrix.to/#/#gotosocial-help:superseriousbusiness.org">Matrix support room</a>.
<br />Include the details below:
</p>
<div className="details">
<pre>
{error.name}: {error.message}
{componentStack && [
"\n\nComponent trace:",
componentStack
]}
{["\n\nError trace: ", error.stack]}
</pre>
</div>
<p>
<button onClick={resetErrorBoundary}>Try again</button> or <a href="">refresh the page</a>
</p>
</div>
);
}
export { ErrorBoundary };

View file

@ -1,136 +0,0 @@
/*
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/>.
*/
const React = require("react");
const { nanoid } = require("nanoid");
const { Redirect } = require("wouter");
const { urlSafe } = require("./util");
const {
Sidebar,
ViewRouter,
MenuComponent
} = require("./components");
function createNavigation(rootUrl, menus) {
const root = {
url: rootUrl,
links: [],
};
const routing = [];
const menuTree = menus.map((creatorFunc) =>
creatorFunc(root, routing)
);
return {
Sidebar: Sidebar(menuTree, routing),
ViewRouter: ViewRouter(routing, root.redirectUrl)
};
}
function MenuEntry(name, opts, contents) {
if (contents == undefined) { // opts argument is optional
contents = opts;
opts = {};
}
return function createMenuEntry(root, routing) {
const type = Array.isArray(contents) ? "category" : "view";
let urlParts = [root.url];
if (opts.url != "") {
urlParts.push(opts.url ?? urlSafe(name));
}
const url = urlParts.join("/");
let routingUrl = url;
if (opts.wildcard) {
routingUrl += "/:wildcard*";
}
const entry = {
name, type,
url, routingUrl,
key: nanoid(),
permissions: opts.permissions ?? false,
icon: opts.icon,
links: [routingUrl],
level: (root.level ?? -1) + 1,
redirectUrl: opts.defaultUrl
};
if (type == "category") {
let entries = contents.map((creatorFunc) => creatorFunc(entry, routing));
let routes = [];
entries.forEach((e) => {
// move empty wildcard routes to end of category, to prevent overlap
if (e.url == entry.url) {
routes.unshift(e);
} else {
routes.push(e);
}
});
routes.reverse();
routing.push(...routes);
if (opts.redirectUrl != entry.url) {
routing.push({
key: entry.key,
url: entry.url,
permissions: entry.permissions,
routingUrl: entry.redirectUrl + "/:fallback*",
view: React.createElement(Redirect, { to: entry.redirectUrl })
});
entry.url = entry.redirectUrl;
}
root.links.push(...entry.links);
entry.MenuEntry = React.createElement(
MenuComponent,
entry,
entries.map((e) => e.MenuEntry)
);
} else {
entry.links.push(routingUrl);
root.links.push(routingUrl);
entry.view = React.createElement(contents, { baseUrl: url });
entry.MenuEntry = React.createElement(MenuComponent, entry);
}
if (root.redirectUrl == undefined) {
root.redirectUrl = entry.url;
}
return entry;
};
}
module.exports = {
createNavigation,
Menu: MenuEntry,
Item: MenuEntry
};

View file

@ -0,0 +1,175 @@
/*
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, { PropsWithChildren } from "react";
import { Link, useRoute } from "wouter";
import {
BaseUrlContext,
MenuLevelContext,
useBaseUrl,
useHasPermission,
useMenuLevel,
} from "./util";
import UserLogoutCard from "../../components/user-logout-card";
import { nanoid } from "nanoid";
export interface MenuItemProps {
/**
* Name / title of this menu item.
*/
name?: string;
/**
* Url path component for this menu item.
*/
itemUrl: string;
/**
* If this menu item is a category containing
* children, which child should be selected by
* default when category title is clicked.
*
* Optional, use for categories only.
*/
defaultChild?: string;
/**
* Permissions required to access this
* menu item (none, "moderator", "admin").
*/
permissions?: string[];
/**
* Fork-awesome string to render
* icon for this menu item.
*/
icon?: string;
}
export function MenuItem(props: PropsWithChildren<MenuItemProps>) {
const {
name,
itemUrl,
defaultChild,
permissions,
icon,
children,
} = props;
// Derive where this item is
// in terms of URL routing.
const baseUrl = useBaseUrl();
const thisUrl = [ baseUrl, itemUrl ].join('/');
// Derive where this item is in
// terms of nesting within the menu.
const thisLevel = useMenuLevel();
const nextLevel = thisLevel+1;
const topLevel = thisLevel === 0;
// Check whether this item is currently active
// (ie., user has selected it in the menu).
//
// This uses a wildcard to mark both parent
// and relevant child as active.
//
// See:
// https://github.com/molefrog/wouter?tab=readme-ov-file#useroute-route-matching-and-parameters
const [isActive] = useRoute([ thisUrl, "*?" ].join("/"));
// Don't render item if logged-in user
// doesn't have permissions to use it.
if (!useHasPermission(permissions)) {
return null;
}
// Check whether this item has children.
const hasChildren = children !== undefined;
const childrenArray = hasChildren && Array.isArray(children);
// Class name of the item varies depending
// on where it is in the menu, and whether
// it has children beneath it or not.
const classNames: string[] = [];
if (topLevel) {
classNames.push("category", "top-level");
} else {
if (thisLevel === 1 && hasChildren) {
classNames.push("category", "expanding");
} else if (thisLevel === 1 && !hasChildren) {
classNames.push("view", "expanding");
} else if (thisLevel === 2) {
classNames.push("view", "nested");
}
}
if (isActive) {
classNames.push("active");
}
let content: React.JSX.Element | null;
if ((isActive || topLevel) && childrenArray) {
// Render children as a nested list.
content = <ul>{children}</ul>;
} else if (isActive && hasChildren) {
// Render child as solo element.
content = <>{children}</>;
} else {
// Not active: hide children.
content = null;
}
// If a default child is defined, this item should point to that.
const href = defaultChild ? [ thisUrl, defaultChild ].join("/") : thisUrl;
return (
<li key={nanoid()} className={classNames.join(" ")}>
<Link href={href} className="title">
<span>
{icon && <i className={`icon fa fa-fw ${icon}`} aria-hidden="true" />}
{name}
</span>
</Link>
{ content &&
<BaseUrlContext.Provider value={thisUrl}>
<MenuLevelContext.Provider value={nextLevel}>
{content}
</MenuLevelContext.Provider>
</BaseUrlContext.Provider>
}
</li>
);
}
export interface SidebarMenuProps{}
export function SidebarMenu({ children }: PropsWithChildren<SidebarMenuProps>) {
return (
<div className="sidebar">
<UserLogoutCard />
<nav className="menu-tree">
<MenuLevelContext.Provider value={0}>
<ul className="top-level">
{children}
</ul>
</MenuLevelContext.Provider>
</nav>
</div>
);
}

View file

@ -18,37 +18,62 @@
*/ */
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
const RoleContext = createContext([]); const RoleContext = createContext<string[]>([]);
const BaseUrlContext = createContext<string>(""); const BaseUrlContext = createContext<string>("");
const MenuLevelContext = createContext<number>(0);
function urlSafe(str) { function urlSafe(str: string) {
return str.toLowerCase().replace(/[\s/]+/g, "-"); return str.toLowerCase().replace(/[\s/]+/g, "-");
} }
function useHasPermission(permissions) { function useHasPermission(permissions: string[] | undefined) {
const roles = useContext(RoleContext); const roles = useContext<string[]>(RoleContext);
return checkPermission(permissions, roles); return checkPermission(permissions, roles);
} }
function checkPermission(requiredPermissisons, user) { // checkPermission returns true if the user's roles
// requiredPermissions can be 'false', in which case there are no restrictions // include requiredPermissions, or false otherwise.
if (requiredPermissisons === false) { function checkPermission(requiredPermissions: string[] | undefined, userRoles: string[]): boolean {
if (requiredPermissions === undefined) {
// No perms defined, so user
// implicitly has permission.
return true; return true;
} }
// or an array of roles, check if one of the user's roles is sufficient if (requiredPermissions.length === 0) {
return user.some((role) => requiredPermissisons.includes(role)); // No perms defined, so user
// implicitly has permission.
return true;
}
// Check if one of the user's
// roles is sufficient.
return userRoles.some((role) => {
if (role === "admin") {
// Admins can
// see everything.
return true;
}
return requiredPermissions.includes(role);
});
} }
function useBaseUrl() { function useBaseUrl() {
return useContext(BaseUrlContext); return useContext(BaseUrlContext);
} }
function useMenuLevel() {
return useContext(MenuLevelContext);
}
export { export {
urlSafe, urlSafe,
RoleContext, RoleContext,
useHasPermission, useHasPermission,
checkPermission, checkPermission,
BaseUrlContext, BaseUrlContext,
useBaseUrl useBaseUrl,
MenuLevelContext,
useMenuLevel,
}; };

View file

@ -21,6 +21,7 @@ import { replaceCacheOnMutation, removeFromCacheOnMutation } from "../query-modi
import { gtsApi } from "../gts-api"; import { gtsApi } from "../gts-api";
import { listToKeyedObject } from "../transforms"; import { listToKeyedObject } from "../transforms";
import { AdminAccount, HandleSignupParams, SearchAccountParams } from "../../types/account"; import { AdminAccount, HandleSignupParams, SearchAccountParams } from "../../types/account";
import { InstanceRule, MappedRules } from "../../types/rules";
const extended = gtsApi.injectEndpoints({ const extended = gtsApi.injectEndpoints({
endpoints: (build) => ({ endpoints: (build) => ({
@ -120,14 +121,14 @@ const extended = gtsApi.injectEndpoints({
], ],
}), }),
instanceRules: build.query({ instanceRules: build.query<MappedRules, void>({
query: () => ({ query: () => ({
url: `/api/v1/admin/instance/rules` url: `/api/v1/admin/instance/rules`
}), }),
transformResponse: listToKeyedObject<any>("id") transformResponse: listToKeyedObject<InstanceRule>("id")
}), }),
addInstanceRule: build.mutation({ addInstanceRule: build.mutation<MappedRules, any>({
query: (formData) => ({ query: (formData) => ({
method: "POST", method: "POST",
url: `/api/v1/admin/instance/rules`, url: `/api/v1/admin/instance/rules`,
@ -135,11 +136,7 @@ const extended = gtsApi.injectEndpoints({
body: formData, body: formData,
discardEmpty: true discardEmpty: true
}), }),
transformResponse: (data) => { transformResponse: listToKeyedObject<InstanceRule>("id"),
return {
[data.id]: data
};
},
...replaceCacheOnMutation("instanceRules"), ...replaceCacheOnMutation("instanceRules"),
}), }),

View file

@ -20,7 +20,15 @@
export interface CustomEmoji { export interface CustomEmoji {
id?: string; id?: string;
shortcode: string; shortcode: string;
url: string;
static_url: string;
visible_in_picker: boolean;
category?: string; category?: string;
disabled: boolean;
updated_at: string;
total_file_size: number;
content_type: string;
uri: string;
} }
/** /**

View file

@ -17,19 +17,13 @@
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import React from "react"; export interface InstanceRule {
import { Switch, Route } from "wouter"; id: string;
created_at: string;
import EmojiOverview from "./overview"; updated_at: string;
import EmojiDetail from "./detail"; text: string;
}
export default function CustomEmoji({ baseUrl }) {
return ( export interface MappedRules {
<Switch> [key: string]: InstanceRule;
<Route path={`${baseUrl}/:emojiId`}>
<EmojiDetail />
</Route>
<EmojiOverview />
</Switch>
);
} }

View file

@ -53,21 +53,13 @@ ul li::before {
& > div, & > div,
& > form { & > form {
border-left: 0.2rem solid $border-accent;
padding-left: 0.4rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
margin: 1rem 0; margin: 1rem 0;
h1, h2 { h1, h2, h3, h4, h5 {
margin: 0; margin: 0;
margin-top: 0.1rem;
}
&:only-child {
border-left: none;
padding-left: none;
} }
&:first-child { &:first-child {
@ -77,12 +69,6 @@ ul li::before {
&:last-child { &:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
&.without-border,
.without-border {
border-left: 0;
padding-left: 0;
}
} }
& > .error { & > .error {
@ -305,7 +291,8 @@ input, select, textarea {
) !important; ) !important;
} }
section.with-sidebar > div, section.with-sidebar > form { section.with-sidebar > div,
section.with-sidebar > form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
@ -348,10 +335,6 @@ section.with-sidebar > div, section.with-sidebar > form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.2rem; gap: 0.2rem;
h3 {
margin: 0;
}
} }
.labelinput .border { .labelinput .border {

View file

@ -18,13 +18,10 @@
*/ */
import React from "react"; import React from "react";
import { useInstanceKeysExpireMutation } from "../../../../lib/query";
import { useInstanceKeysExpireMutation } from "../../../lib/query"; import { TextInput } from "../../../../components/form/inputs";
import MutationButton from "../../../../components/form/mutation-button";
import { useTextInput } from "../../../lib/form"; import { useTextInput } from "../../../../lib/form";
import { TextInput } from "../../../components/form/inputs";
import MutationButton from "../../../components/form/mutation-button";
export default function ExpireRemote({}) { export default function ExpireRemote({}) {
const domainField = useTextInput("domain"); const domainField = useTextInput("domain");
@ -54,7 +51,7 @@ export default function ExpireRemote({}) {
placeholder="example.org" placeholder="example.org"
/> />
<MutationButton <MutationButton
disabled={false} disabled={!domainField.value}
label="Expire keys" label="Expire keys"
result={expireResult} result={expireResult}
/> />

View file

@ -19,12 +19,10 @@
import React from "react"; import React from "react";
import { useMediaCleanupMutation } from "../../../lib/query"; import { useMediaCleanupMutation } from "../../../../lib/query";
import { useTextInput } from "../../../../lib/form";
import { useTextInput } from "../../../lib/form"; import { TextInput } from "../../../../components/form/inputs";
import { TextInput } from "../../../components/form/inputs"; import MutationButton from "../../../../components/form/mutation-button";
import MutationButton from "../../../components/form/mutation-button";
export default function Cleanup({}) { export default function Cleanup({}) {
const daysField = useTextInput("days", { defaultValue: "30" }); const daysField = useTextInput("days", { defaultValue: "30" });
@ -52,7 +50,7 @@ export default function Cleanup({}) {
placeholder="30" placeholder="30"
/> />
<MutationButton <MutationButton
disabled={false} disabled={!daysField.value}
label="Remove old media" label="Remove old media"
result={mediaCleanupResult} result={mediaCleanupResult}
/> />

View file

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

View file

@ -18,36 +18,29 @@
*/ */
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useRoute, Link, Redirect } from "wouter"; import { Redirect, useParams } from "wouter";
import { useComboBoxInput, useFileInput, useValue } from "../../../../lib/form";
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 { CategorySelect } from "../category-select";
import BackButton from "../../../../components/back-button";
import useFormSubmit from "../../../lib/form/submit"; export default function EmojiDetail() {
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";
export default function EmojiDetailRoute({ }) {
const baseUrl = useBaseUrl(); const baseUrl = useBaseUrl();
let [_match, params] = useRoute(`${baseUrl}/:emojiId`); const params = useParams();
if (params?.emojiId == undefined) {
return <Redirect to={baseUrl} />;
} else {
return ( return (
<div className="emoji-detail"> <div className="emoji-detail">
<Link to={baseUrl}><a>&lt; go back</a></Link> <BackButton to={`~${baseUrl}/local`} />
<FormWithData dataQuery={useGetEmojiQuery} queryArg={params.emojiId} DataForm={EmojiDetailForm} /> <FormWithData dataQuery={useGetEmojiQuery} queryArg={params.emojiId} DataForm={EmojiDetailForm} />
</div> </div>
); );
}
} }
function EmojiDetailForm({ data: emoji }) { function EmojiDetailForm({ data: emoji }) {
@ -77,7 +70,7 @@ function EmojiDetailForm({ data: emoji }) {
const [deleteEmoji, deleteResult] = useDeleteEmojiMutation(); const [deleteEmoji, deleteResult] = useDeleteEmojiMutation();
if (deleteResult.isSuccess) { if (deleteResult.isSuccess) {
return <Redirect to={baseUrl} />; return <Redirect to={`~${baseUrl}/local`} />;
} }
return ( return (
@ -93,6 +86,7 @@ function EmojiDetailForm({ data: emoji }) {
className="danger" className="danger"
showError={false} showError={false}
result={deleteResult} result={deleteResult}
disabled={false}
/> />
</div> </div>
</div> </div>
@ -110,6 +104,7 @@ function EmojiDetailForm({ data: emoji }) {
result={result} result={result}
showError={false} showError={false}
style={{ visibility: (form.category.isNew ? "initial" : "hidden") }} style={{ visibility: (form.category.isNew ? "initial" : "hidden") }}
disabled={!form.category.value}
/> />
</CategorySelect> </CategorySelect>
</div> </div>
@ -126,12 +121,13 @@ function EmojiDetailForm({ data: emoji }) {
label="Replace image" label="Replace image"
showError={false} showError={false}
result={result} result={result}
disabled={!form.image.value}
/> />
<FakeToot> <FakeToot>
Look at this new custom emoji <img Look at this new custom emoji <img
className="emoji" className="emoji"
src={form.image.previewURL ?? emoji.url} src={form.image.previewValue ?? emoji.url}
title={`:${emoji.shortcode}:`} title={`:${emoji.shortcode}:`}
alt={emoji.shortcode} alt={emoji.shortcode}
/> isn&apos;t it cool? /> isn&apos;t it cool?

View file

@ -18,19 +18,15 @@
*/ */
import React, { useMemo, useEffect } from "react"; import React, { useMemo, useEffect } from "react";
import { useFileInput, useComboBoxInput } from "../../../../lib/form";
import { useFileInput, useComboBoxInput } from "../../../lib/form";
import useShortcode from "./use-shortcode"; import useShortcode from "./use-shortcode";
import useFormSubmit from "../../../../lib/form/submit";
import useFormSubmit from "../../../lib/form/submit"; import { TextInput, FileInput } from "../../../../components/form/inputs";
import { TextInput, FileInput } from "../../../components/form/inputs";
import { CategorySelect } from '../category-select'; import { CategorySelect } from '../category-select';
import FakeToot from "../../../components/fake-toot"; import FakeToot from "../../../../components/fake-toot";
import MutationButton from "../../../components/form/mutation-button"; import MutationButton from "../../../../components/form/mutation-button";
import { useAddEmojiMutation } from "../../../lib/query/admin/custom-emoji"; import { useAddEmojiMutation } from "../../../../lib/query/admin/custom-emoji";
import { useInstanceV1Query } from "../../../lib/query"; import { useInstanceV1Query } from "../../../../lib/query";
export default function NewEmojiForm() { export default function NewEmojiForm() {
const shortcode = useShortcode(); const shortcode = useShortcode();

View file

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

View file

@ -17,19 +17,19 @@
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
const React = require("react"); import { useMemo } from "react";
const { useTextInput } = require("../../../lib/form"); import { useTextInput } from "../../../../lib/form";
const { useListEmojiQuery } = require("../../../lib/query/admin/custom-emoji"); import { useListEmojiQuery } from "../../../../lib/query/admin/custom-emoji";
const shortcodeRegex = /^\w{2,30}$/; const shortcodeRegex = /^\w{2,30}$/;
module.exports = function useShortcode() { export default function useShortcode() {
const { data: emoji = [] } = useListEmojiQuery({ const { data: emoji = [] } = useListEmojiQuery({
filter: "domain:local" filter: "domain:local"
}); });
const emojiCodes = React.useMemo(() => { const emojiCodes = useMemo(() => {
return new Set(emoji.map((e) => e.shortcode)); return new Set(emoji.map((e) => e.shortcode));
}, [emoji]); }, [emoji]);
@ -53,4 +53,4 @@ module.exports = function useShortcode() {
return ""; return "";
} }
}); });
}; }

View file

@ -19,36 +19,28 @@
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import ParseFromToot from "./parse-from-toot"; import StealThisLook from "./steal-this-look";
import Loading from "../../../components/loading"; import Loading from "../../../../components/loading";
import { Error } from "../../../components/error"; import { Error } from "../../../../components/error";
import { useListEmojiQuery } from "../../../lib/query/admin/custom-emoji"; import { useListEmojiQuery } from "../../../../lib/query/admin/custom-emoji";
export default function RemoteEmoji() { export default function RemoteEmoji() {
// local emoji are queried for shortcode collision detection // Local emoji are queried for
// shortcode collision detection
const { const {
data: emoji = [], data: emoji = [],
isLoading, isLoading,
error error
} = useListEmojiQuery({ filter: "domain:local" }); } = useListEmojiQuery({ filter: "domain:local" });
const emojiCodes = useMemo(() => { const emojiCodes = useMemo(() => new Set(emoji.map((e) => e.shortcode)), [emoji]);
return new Set(emoji.map((e) => e.shortcode));
}, [emoji]);
return ( return (
<> <>
<h1>Custom Emoji (remote)</h1> <h1>Custom Emoji (remote)</h1>
{error && {error && <Error error={error} />}
<Error error={error} /> {isLoading ? <Loading /> : <StealThisLook emojiCodes={emojiCodes} />}
}
{isLoading
? <Loading />
: <>
<ParseFromToot emojiCodes={emojiCodes} />
</>
}
</> </>
); );
} }

View file

@ -19,19 +19,19 @@
import React, { useCallback, useEffect } from "react"; import React, { useCallback, useEffect } from "react";
import { useTextInput, useComboBoxInput, useCheckListInput } from "../../../lib/form"; import { useTextInput, useComboBoxInput, useCheckListInput } from "../../../../lib/form";
import useFormSubmit from "../../../lib/form/submit"; import useFormSubmit from "../../../../lib/form/submit";
import CheckList from "../../../components/check-list"; import CheckList from "../../../../components/check-list";
import { CategorySelect } from '../category-select'; import { CategorySelect } from '../category-select';
import { TextInput } from "../../../components/form/inputs"; import { TextInput } from "../../../../components/form/inputs";
import MutationButton from "../../../components/form/mutation-button"; import MutationButton from "../../../../components/form/mutation-button";
import { Error } from "../../../components/error"; import { Error } from "../../../../components/error";
import { useSearchItemForEmojiMutation, usePatchRemoteEmojisMutation } from "../../../lib/query/admin/custom-emoji"; import { useSearchItemForEmojiMutation, usePatchRemoteEmojisMutation } from "../../../../lib/query/admin/custom-emoji";
export default function ParseFromToot({ emojiCodes }) { export default function StealThisLook({ emojiCodes }) {
const [searchStatus, result] = useSearchItemForEmojiMutation(); const [searchStatus, result] = useSearchItemForEmojiMutation();
const urlField = useTextInput("url"); const urlField = useTextInput("url");
@ -48,7 +48,7 @@ export default function ParseFromToot({ emojiCodes }) {
<form onSubmit={submitSearch}> <form onSubmit={submitSearch}>
<div className="form-field text"> <div className="form-field text">
<label htmlFor="url"> <label htmlFor="url">
Link to a toot: Link to a status:
</label> </label>
<div className="row"> <div className="row">
<input <input
@ -85,13 +85,13 @@ function SearchResult({ result, localEmojiCodes }) {
if (error == "NONE_FOUND") { if (error == "NONE_FOUND") {
return "No results found"; return "No results found";
} else if (error == "LOCAL_INSTANCE") { } else if (error == "LOCAL_INSTANCE") {
return <b>This is a local user/toot, all referenced emoji are already on your instance</b>; return <b>This is a local user/status, all referenced emoji are already on your instance</b>;
} else if (error != undefined) { } else if (error != undefined) {
return <Error error={result.error} />; return <Error error={result.error} />;
} }
if (data.list.length == 0) { if (data.list.length == 0) {
return <b>This {data.type == "statuses" ? "toot" : "account"} doesn't use any custom emoji</b>; return <b>This {data.type == "statuses" ? "status" : "account"} doesn't use any custom emoji</b>;
} }
return ( return (
@ -143,7 +143,7 @@ function CopyEmojiForm({ localEmojiCodes, type, emojiList }) {
return ( return (
<div className="parsed"> <div className="parsed">
<span>This {type == "statuses" ? "toot" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span> <span>This {type == "statuses" ? "status" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span>
<form onSubmit={formSubmit}> <form onSubmit={formSubmit}>
<CheckList <CheckList
field={form.selectedEmoji} field={form.selectedEmoji}

View file

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

View file

@ -19,33 +19,33 @@
import React from "react"; import React from "react";
import { useTextInput, useFileInput } from "../../lib/form"; import { useTextInput, useFileInput } from "../../../lib/form";
const useFormSubmit = require("../../lib/form/submit").default; const useFormSubmit = require("../../../lib/form/submit").default;
import { TextInput, TextArea, FileInput } from "../../components/form/inputs"; import { TextInput, TextArea, FileInput } from "../../../components/form/inputs";
const FormWithData = require("../../lib/form/form-with-data").default; const FormWithData = require("../../../lib/form/form-with-data").default;
import MutationButton from "../../components/form/mutation-button"; import MutationButton from "../../../components/form/mutation-button";
import { useInstanceV1Query } from "../../lib/query"; import { useInstanceV1Query } from "../../../lib/query";
import { useUpdateInstanceMutation } from "../../lib/query/admin"; import { useUpdateInstanceMutation } from "../../../lib/query/admin";
import { InstanceV1 } from "../../lib/types/instance"; import { InstanceV1 } from "../../../lib/types/instance";
export default function AdminSettings() { export default function InstanceSettings() {
return ( return (
<FormWithData <FormWithData
dataQuery={useInstanceV1Query} dataQuery={useInstanceV1Query}
DataForm={AdminSettingsForm} DataForm={InstanceSettingsForm}
/> />
); );
} }
interface AdminSettingsFormProps{ interface InstanceSettingsFormProps{
data: InstanceV1; data: InstanceV1;
} }
function AdminSettingsForm({ data: instance }: AdminSettingsFormProps) { function InstanceSettingsForm({ data: instance }: InstanceSettingsFormProps) {
const titleLimit = 40; const titleLimit = 40;
const shortDescLimit = 500; const shortDescLimit = 500;
const descLimit = 5000; const descLimit = 5000;

View file

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

View file

@ -19,19 +19,19 @@
import React from "react"; import React from "react";
import { useActionAccountMutation } from "../../../lib/query"; import { useActionAccountMutation } from "../../../../lib/query";
import MutationButton from "../../../components/form/mutation-button"; import MutationButton from "../../../../components/form/mutation-button";
import useFormSubmit from "../../../lib/form/submit"; import useFormSubmit from "../../../../lib/form/submit";
import { import {
useValue, useValue,
useTextInput, useTextInput,
useBoolInput, useBoolInput,
} from "../../../lib/form"; } from "../../../../lib/form";
import { Checkbox, TextInput } from "../../../components/form/inputs"; import { Checkbox, TextInput } from "../../../../components/form/inputs";
import { AdminAccount } from "../../../lib/types/account"; import { AdminAccount } from "../../../../lib/types/account";
export interface AccountActionsProps { export interface AccountActionsProps {
account: AdminAccount, account: AdminAccount,

View file

@ -20,26 +20,26 @@
import React from "react"; import React from "react";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { useHandleSignupMutation } from "../../../lib/query"; import { useHandleSignupMutation } from "../../../../lib/query";
import MutationButton from "../../../components/form/mutation-button"; import MutationButton from "../../../../components/form/mutation-button";
import useFormSubmit from "../../../lib/form/submit"; import useFormSubmit from "../../../../lib/form/submit";
import { import {
useValue, useValue,
useTextInput, useTextInput,
useBoolInput, useBoolInput,
} from "../../../lib/form"; } from "../../../../lib/form";
import { Checkbox, Select, TextInput } from "../../../components/form/inputs"; import { Checkbox, Select, TextInput } from "../../../../components/form/inputs";
import { AdminAccount } from "../../../lib/types/account"; import { AdminAccount } from "../../../../lib/types/account";
export interface HandleSignupProps { export interface HandleSignupProps {
account: AdminAccount, account: AdminAccount,
accountsBaseUrl: string, backLocation: string,
} }
export function HandleSignup({account, accountsBaseUrl}: HandleSignupProps) { export function HandleSignup({account, backLocation}: HandleSignupProps) {
const form = { const form = {
id: useValue("id", account.id), id: useValue("id", account.id),
approveOrReject: useTextInput("approve_or_reject", { defaultValue: "approve" }), approveOrReject: useTextInput("approve_or_reject", { defaultValue: "approve" }),
@ -67,7 +67,7 @@ export function HandleSignup({account, accountsBaseUrl}: HandleSignupProps) {
if (res.data) { if (res.data) {
// "reject" successful, // "reject" successful,
// redirect to accounts page. // redirect to accounts page.
setLocation(accountsBaseUrl); setLocation(backLocation);
} }
} }
}); });

View file

@ -18,51 +18,39 @@
*/ */
import React from "react"; import React from "react";
import { useRoute, Redirect } from "wouter";
import { useGetAccountQuery } from "../../../lib/query"; import { useGetAccountQuery } from "../../../../lib/query";
import FormWithData from "../../../lib/form/form-with-data"; import FormWithData from "../../../../lib/form/form-with-data";
import { useBaseUrl } from "../../../lib/navigation/util"; import FakeProfile from "../../../../components/fake-profile";
import FakeProfile from "../../../components/fake-profile";
import { AdminAccount } from "../../../lib/types/account"; import { AdminAccount } from "../../../../lib/types/account";
import { HandleSignup } from "./handlesignup"; import { HandleSignup } from "./handlesignup";
import { AccountActions } from "./actions"; import { AccountActions } from "./actions";
import BackButton from "../../../components/back-button"; import { useParams } from "wouter";
export default function AccountDetail() { export default function AccountDetail() {
// /settings/admin/accounts const params: { accountID: string } = useParams();
const accountsBaseUrl = useBaseUrl();
let [_match, params] = useRoute(`${accountsBaseUrl}/:accountId`);
if (params?.accountId == undefined) {
return <Redirect to={accountsBaseUrl} />;
} else {
return ( return (
<div className="account-detail"> <div className="account-detail">
<h1 className="text-cutoff"> <h1>Account Details</h1>
<BackButton to={accountsBaseUrl} /> Account Details
</h1>
<FormWithData <FormWithData
dataQuery={useGetAccountQuery} dataQuery={useGetAccountQuery}
queryArg={params.accountId} queryArg={params.accountID}
DataForm={AccountDetailForm} DataForm={AccountDetailForm}
{...{accountsBaseUrl}}
/> />
</div> </div>
); );
}
} }
interface AccountDetailFormProps { interface AccountDetailFormProps {
accountsBaseUrl: string, backLocation: string,
data: AdminAccount, data: AdminAccount,
} }
function AccountDetailForm({ data: adminAcct, accountsBaseUrl }: AccountDetailFormProps) { function AccountDetailForm({ data: adminAcct, backLocation }: AccountDetailFormProps) {
let yesOrNo = (b: boolean) => { let yesOrNo = (b: boolean) => {
return b ? "yes" : "no"; return b ? "yes" : "no";
}; };
@ -169,7 +157,7 @@ function AccountDetailForm({ data: adminAcct, accountsBaseUrl }: AccountDetailFo
? ?
<HandleSignup <HandleSignup
account={adminAcct} account={adminAcct}
accountsBaseUrl={accountsBaseUrl} backLocation={backLocation}
/> />
: :
<AccountActions account={adminAcct} /> <AccountActions account={adminAcct} />

View file

@ -18,23 +18,9 @@
*/ */
import React from "react"; import React from "react";
import { Switch, Route } from "wouter";
import AccountDetail from "./detail";
import { AccountSearchForm } from "./search"; import { AccountSearchForm } from "./search";
export default function Accounts({ baseUrl }) { export default function AccountsOverview({ }) {
return (
<Switch>
<Route path={`${baseUrl}/:accountId`}>
<AccountDetail />
</Route>
<AccountOverview />
</Switch>
);
}
function AccountOverview({ }) {
return ( return (
<div className="accounts-view"> <div className="accounts-view">
<h1>Accounts Overview</h1> <h1>Accounts Overview</h1>

View file

@ -18,8 +18,8 @@
*/ */
import React from "react"; import React from "react";
import { useSearchAccountsQuery } from "../../../lib/query"; import { useSearchAccountsQuery } from "../../../../lib/query";
import { AccountList } from "../../../components/account-list"; import { AccountList } from "../../../../components/account-list";
export default function AccountsPending() { export default function AccountsPending() {
const searchRes = useSearchAccountsQuery({status: "pending"}); const searchRes = useSearchAccountsQuery({status: "pending"});

View file

@ -19,17 +19,15 @@
import React from "react"; import React from "react";
import { useLazySearchAccountsQuery } from "../../../lib/query"; import { useLazySearchAccountsQuery } from "../../../../lib/query";
import { useTextInput } from "../../../lib/form"; import { useTextInput } from "../../../../lib/form";
import { AccountList } from "../../../components/account-list"; import { AccountList } from "../../../../components/account-list";
import { SearchAccountParams } from "../../../lib/types/account"; import { SearchAccountParams } from "../../../../lib/types/account";
import { Select, TextInput } from "../../../components/form/inputs"; import { Select, TextInput } from "../../../../components/form/inputs";
import MutationButton from "../../../components/form/mutation-button"; import MutationButton from "../../../../components/form/mutation-button";
export function AccountSearchForm() { export function AccountSearchForm() {
const [searchAcct, searchRes] = useLazySearchAccountsQuery();
const form = { const form = {
origin: useTextInput("origin"), origin: useTextInput("origin"),
status: useTextInput("status"), status: useTextInput("status"),
@ -55,14 +53,20 @@ export function AccountSearchForm() {
// Remove any nulls. // Remove any nulls.
return kv || []; return kv || [];
}); });
const params: SearchAccountParams = Object.fromEntries(entries); const params: SearchAccountParams = Object.fromEntries(entries);
searchAcct(params); searchAcct(params);
} }
const [ searchAcct, searchRes ] = useLazySearchAccountsQuery();
return ( return (
<> <>
<form onSubmit={submitSearch}> <form
onSubmit={submitSearch}
// Prevent password managers trying
// to fill in username/email fields.
autoComplete="off"
>
<TextInput <TextInput
field={form.username} field={form.username}
label={"(Optional) username (without leading '@' symbol)"} label={"(Optional) username (without leading '@' symbol)"}
@ -88,6 +92,8 @@ export function AccountSearchForm() {
field={form.email} field={form.email}
label={"(Optional) email address (local accounts only)"} label={"(Optional) email address (local accounts only)"}
placeholder={"someone@example.org"} placeholder={"someone@example.org"}
// Get email validation for free.
{...{type: "email"}}
/> />
<TextInput <TextInput
field={form.ip} field={form.ip}

View file

@ -20,31 +20,35 @@
import React from "react"; import React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { useLocation } from "wouter"; import { useLocation, useParams, useSearch } from "wouter";
import { useTextInput, useBoolInput } from "../../lib/form"; import { useTextInput, useBoolInput } from "../../../lib/form";
import useFormSubmit from "../../lib/form/submit"; import useFormSubmit from "../../../lib/form/submit";
import { TextInput, Checkbox, TextArea } from "../../components/form/inputs"; import { TextInput, Checkbox, TextArea } from "../../../components/form/inputs";
import Loading from "../../components/loading"; import Loading from "../../../components/loading";
import BackButton from "../../components/back-button"; import BackButton from "../../../components/back-button";
import MutationButton from "../../components/form/mutation-button"; import MutationButton from "../../../components/form/mutation-button";
import { useDomainAllowsQuery, useDomainBlocksQuery } from "../../lib/query/admin/domain-permissions/get"; import { useDomainAllowsQuery, useDomainBlocksQuery } from "../../../lib/query/admin/domain-permissions/get";
import { useAddDomainAllowMutation, useAddDomainBlockMutation, useRemoveDomainAllowMutation, useRemoveDomainBlockMutation } from "../../lib/query/admin/domain-permissions/update"; import { useAddDomainAllowMutation, useAddDomainBlockMutation, useRemoveDomainAllowMutation, useRemoveDomainBlockMutation } from "../../../lib/query/admin/domain-permissions/update";
import { DomainPerm, PermType } from "../../lib/types/domain-permission"; import { DomainPerm, PermType } from "../../../lib/types/domain-permission";
import { NoArg } from "../../lib/types/query"; import { NoArg } from "../../../lib/types/query";
import { Error } from "../../components/error"; import { Error } from "../../../components/error";
import { useBaseUrl } from "../../../lib/navigation/util";
export interface DomainPermDetailProps { export default function DomainPermDetail() {
baseUrl: string; const baseUrl = useBaseUrl();
permType: PermType;
domain: string; // Parse perm type from routing params.
} let params = useParams();
if (params.permType !== "blocks" && params.permType !== "allows") {
throw "unrecognized perm type " + params.permType;
}
const permType = params.permType.slice(0, -1) as PermType;
export default function DomainPermDetail({ baseUrl, permType, domain }: DomainPermDetailProps) {
const { data: domainBlocks = {}, isLoading: isLoadingDomainBlocks } = useDomainBlocksQuery(NoArg, { skip: permType !== "block" }); const { data: domainBlocks = {}, isLoading: isLoadingDomainBlocks } = useDomainBlocksQuery(NoArg, { skip: permType !== "block" });
const { data: domainAllows = {}, isLoading: isLoadingDomainAllows } = useDomainAllowsQuery(NoArg, { skip: permType !== "allow" }); const { data: domainAllows = {}, isLoading: isLoadingDomainAllows } = useDomainAllowsQuery(NoArg, { skip: permType !== "allow" });
@ -60,13 +64,19 @@ export default function DomainPermDetail({ baseUrl, permType, domain }: DomainPe
throw "perm type unknown"; throw "perm type unknown";
} }
if (domain == "view") { // Parse domain from routing params.
let domain = params.domain ?? "unknown";
const search = useSearch();
if (domain === "view") {
// Retrieve domain from form field submission. // Retrieve domain from form field submission.
domain = (new URL(document.location.toString())).searchParams.get("domain")?? "unknown"; const searchParams = new URLSearchParams(search);
const searchDomain = searchParams.get("domain");
if (!searchDomain) {
throw "empty view domain";
} }
if (domain == "unknown") { domain = searchDomain;
throw "unknown domain";
} }
// Normalize / decode domain (it may be URL-encoded). // Normalize / decode domain (it may be URL-encoded).
@ -98,13 +108,12 @@ export default function DomainPermDetail({ baseUrl, permType, domain }: DomainPe
return ( return (
<div> <div>
<h1 className="text-cutoff"><BackButton to={baseUrl} /> Domain {permType} for: <span title={domain}>{domain}</span></h1> <h1 className="text-cutoff"><BackButton to={`~${baseUrl}/${permType}s`}/> Domain {permType} for: <span title={domain}>{domain}</span></h1>
{infoContent} {infoContent}
<DomainPermForm <DomainPermForm
defaultDomain={domain} defaultDomain={domain}
perm={existingPerm} perm={existingPerm}
permType={permType} permType={permType}
baseUrl={baseUrl}
/> />
</div> </div>
); );
@ -114,10 +123,9 @@ interface DomainPermFormProps {
defaultDomain: string; defaultDomain: string;
perm?: DomainPerm; perm?: DomainPerm;
permType: PermType; permType: PermType;
baseUrl: string;
} }
function DomainPermForm({ defaultDomain, perm, permType, baseUrl }: DomainPermFormProps) { function DomainPermForm({ defaultDomain, perm, permType }: DomainPermFormProps) {
const isExistingPerm = perm !== undefined; const isExistingPerm = perm !== undefined;
const disabledForm = isExistingPerm const disabledForm = isExistingPerm
? { ? {
@ -186,7 +194,7 @@ function DomainPermForm({ defaultDomain, perm, permType, baseUrl }: DomainPermFo
// but if domain input changes, that doesn't match anymore // but if domain input changes, that doesn't match anymore
// and causes issues later on so, before submitting the form, // and causes issues later on so, before submitting the form,
// silently change url, and THEN submit. // silently change url, and THEN submit.
let correctUrl = `${baseUrl}/${form.domain.value}`; let correctUrl = `/${permType}s/${form.domain.value}`;
if (location != correctUrl) { if (location != correctUrl) {
setLocation(correctUrl); setLocation(correctUrl);
} }

View file

@ -17,11 +17,11 @@
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
const React = require("react"); import React from "react";
module.exports = function ExportFormatTable() { export default function ExportFormatTable() {
return ( return (
<div className="export-format-table-wrapper without-border"> <div className="export-format-table-wrapper">
<table className="export-format-table"> <table className="export-format-table">
<thead> <thead>
<tr> <tr>
@ -44,7 +44,7 @@ module.exports = function ExportFormatTable() {
</table> </table>
</div> </div>
); );
}; }
function Format({ name, info }) { function Format({ name, info }) {
return ( return (

View file

@ -21,18 +21,18 @@ import React from "react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useExportDomainListMutation } from "../../lib/query/admin/domain-permissions/export"; import { useExportDomainListMutation } from "../../../lib/query/admin/domain-permissions/export";
import useFormSubmit from "../../lib/form/submit"; import useFormSubmit from "../../../lib/form/submit";
import { import {
RadioGroup, RadioGroup,
TextArea, TextArea,
Select, Select,
} from "../../components/form/inputs"; } from "../../../components/form/inputs";
import MutationButton from "../../components/form/mutation-button"; import MutationButton from "../../../components/form/mutation-button";
import { Error } from "../../components/error"; import { Error } from "../../../components/error";
import ExportFormatTable from "./export-format-table"; import ExportFormatTable from "./export-format-table";
import type { import type {
@ -40,7 +40,7 @@ import type {
FormSubmitResult, FormSubmitResult,
RadioFormInputHook, RadioFormInputHook,
TextFormInputHook, TextFormInputHook,
} from "../../lib/form/types"; } from "../../../lib/form/types";
export interface ImportExportFormProps { export interface ImportExportFormProps {
form: { form: {

View file

@ -20,20 +20,19 @@
import React from "react"; import React from "react";
import { Switch, Route, Redirect, useLocation } from "wouter"; import { Switch, Route, Redirect, useLocation } from "wouter";
import { useProcessDomainPermissionsMutation } from "../../../lib/query/admin/domain-permissions/process";
import { useProcessDomainPermissionsMutation } from "../../lib/query/admin/domain-permissions/process"; import { useTextInput, useRadioInput } from "../../../lib/form";
import useFormSubmit from "../../../lib/form/submit";
import { useTextInput, useRadioInput } from "../../lib/form";
import useFormSubmit from "../../lib/form/submit";
import { ProcessImport } from "./process"; import { ProcessImport } from "./process";
import ImportExportForm from "./form"; import ImportExportForm from "./form";
export default function ImportExport({ baseUrl }) { export default function ImportExport() {
const form = { const form = {
domains: useTextInput("domains"), domains: useTextInput("domains"),
exportType: useTextInput("exportType", { defaultValue: "plain", dontReset: true }), exportType: useTextInput("exportType", {
defaultValue: "plain",
dontReset: true,
}),
permType: useRadioInput("permType", { permType: useRadioInput("permType", {
options: { options: {
block: "Domain blocks", block: "Domain blocks",
@ -43,12 +42,11 @@ export default function ImportExport({ baseUrl }) {
}; };
const [submitParse, parseResult] = useFormSubmit(form, useProcessDomainPermissionsMutation(), { changedOnly: false }); const [submitParse, parseResult] = useFormSubmit(form, useProcessDomainPermissionsMutation(), { changedOnly: false });
const [_location, setLocation] = useLocation(); const [_location, setLocation] = useLocation();
return ( return (
<Switch> <Switch>
<Route path={`${baseUrl}/process`}> <Route path={"/process"}>
{ {
parseResult.isSuccess parseResult.isSuccess
? ( ? (
@ -58,7 +56,7 @@ export default function ImportExport({ baseUrl }) {
className="button" className="button"
onClick={() => { onClick={() => {
parseResult.reset(); parseResult.reset();
setLocation(baseUrl); setLocation("");
}} }}
> >
&lt; back &lt; back
@ -71,13 +69,13 @@ export default function ImportExport({ baseUrl }) {
/> />
</> </>
) )
: <Redirect to={baseUrl} /> : <Redirect to={""} />
} }
</Route> </Route>
<Route> <Route>
{ {
parseResult.isSuccess parseResult.isSuccess
? <Redirect to={`${baseUrl}/process`} /> ? <Redirect to={"/process"} />
: <ImportExportForm : <ImportExportForm
form={form} form={form}
submitParse={submitParse} submitParse={submitParse}

View file

@ -20,29 +20,25 @@
import React from "react"; import React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { Link, useLocation } from "wouter"; import { Link, useLocation, useParams } from "wouter";
import { matchSorter } from "match-sorter"; import { matchSorter } from "match-sorter";
import { useTextInput } from "../../lib/form"; import { useTextInput } from "../../../lib/form";
import { TextInput } from "../../components/form/inputs"; import { TextInput } from "../../../components/form/inputs";
import Loading from "../../components/loading"; import Loading from "../../../components/loading";
import { useDomainAllowsQuery, useDomainBlocksQuery } from "../../lib/query/admin/domain-permissions/get"; import { useDomainAllowsQuery, useDomainBlocksQuery } from "../../../lib/query/admin/domain-permissions/get";
import type { MappedDomainPerms, PermType } from "../../lib/types/domain-permission"; import type { MappedDomainPerms, PermType } from "../../../lib/types/domain-permission";
import { NoArg } from "../../lib/types/query"; import { NoArg } from "../../../lib/types/query";
export interface DomainPermissionsOverviewProps { export default function DomainPermissionsOverview() {
// Params injected by // Parse perm type from routing params.
// the wouter router. let params = useParams();
permType: PermType; if (params.permType !== "blocks" && params.permType !== "allows") {
baseUrl: string, throw "unrecognized perm type " + params.permType;
}
export default function DomainPermissionsOverview({ permType, baseUrl }: DomainPermissionsOverviewProps) {
if (permType !== "block" && permType !== "allow") {
throw "unrecognized perm type " + permType;
} }
const permType = params.permType.slice(0, -1) as PermType;
// Uppercase first letter of given permType. // Uppercase first letter of given permType.
const permTypeUpper = useMemo(() => { const permTypeUpper = useMemo(() => {
@ -69,30 +65,28 @@ export default function DomainPermissionsOverview({ permType, baseUrl }: DomainP
} }
return ( return (
<div> <>
<h1>Domain {permTypeUpper}s</h1> <h1>Domain {permTypeUpper}s</h1>
{ permType == "block" ? <BlockHelperText/> : <AllowHelperText/> } { permType == "block" ? <BlockHelperText/> : <AllowHelperText/> }
<DomainPermsList <DomainPermsList
data={data} data={data}
baseUrl={baseUrl}
permType={permType} permType={permType}
permTypeUpper={permTypeUpper} permTypeUpper={permTypeUpper}
/> />
<Link to="/settings/admin/domain-permissions/import-export"> <Link to="/settings/admin/domain-permissions/import-export">
<a>Or use the bulk import/export interface</a> Or use the bulk import/export interface
</Link> </Link>
</div> </>
); );
} }
interface DomainPermsListProps { interface DomainPermsListProps {
data: MappedDomainPerms; data: MappedDomainPerms;
baseUrl: string;
permType: PermType; permType: PermType;
permTypeUpper: string; permTypeUpper: string;
} }
function DomainPermsList({ data, baseUrl, permType, permTypeUpper }: DomainPermsListProps) { function DomainPermsList({ data, permType, permTypeUpper }: DomainPermsListProps) {
// Format perms into a list. // Format perms into a list.
const perms = useMemo(() => { const perms = useMemo(() => {
return Object.values(data); return Object.values(data);
@ -103,7 +97,7 @@ function DomainPermsList({ data, baseUrl, permType, permTypeUpper }: DomainPerms
function filterFormSubmit(e) { function filterFormSubmit(e) {
e.preventDefault(); e.preventDefault();
setLocation(`${baseUrl}/${filter}`); setLocation(`/${filter}`);
} }
const filter = filterField.value ?? ""; const filter = filterField.value ?? "";
@ -120,11 +114,13 @@ function DomainPermsList({ data, baseUrl, permType, permTypeUpper }: DomainPerms
const entries = filteredPerms.map((entry) => { const entries = filteredPerms.map((entry) => {
return ( return (
<Link key={entry.domain} to={`${baseUrl}/${entry.domain}`}> <Link
<a className="entry nounderline"> className="entry nounderline"
key={entry.domain}
to={`/${permType}s/${entry.domain}`}
>
<span id="domain">{entry.domain}</span> <span id="domain">{entry.domain}</span>
<span id="date">{new Date(entry.created_at ?? "").toLocaleString()}</span> <span id="date">{new Date(entry.created_at ?? "").toLocaleString()}</span>
</a>
</Link> </Link>
); );
}); });
@ -137,8 +133,11 @@ function DomainPermsList({ data, baseUrl, permType, permTypeUpper }: DomainPerms
placeholder="example.org" placeholder="example.org"
label={`Search or add domain ${permType}`} label={`Search or add domain ${permType}`}
/> />
<Link to={`${baseUrl}/${filter}`}> <Link
<a className="button">{permTypeUpper}&nbsp;{filter}</a> className="button"
to={`/${permType}s/${filter}`}
>
{permTypeUpper}&nbsp;{filter}
</Link> </Link>
</form> </form>
<div> <div>

View file

@ -21,14 +21,14 @@ import React from "react";
import { memo, useMemo, useCallback, useEffect } from "react"; import { memo, useMemo, useCallback, useEffect } from "react";
import { isValidDomainPermission, hasBetterScope } from "../../lib/util/domain-permission"; import { isValidDomainPermission, hasBetterScope } from "../../../lib/util/domain-permission";
import { import {
useTextInput, useTextInput,
useBoolInput, useBoolInput,
useRadioInput, useRadioInput,
useCheckListInput, useCheckListInput,
} from "../../lib/form"; } from "../../../lib/form";
import { import {
Select, Select,
@ -36,22 +36,22 @@ import {
RadioGroup, RadioGroup,
Checkbox, Checkbox,
TextInput, TextInput,
} from "../../components/form/inputs"; } from "../../../components/form/inputs";
import useFormSubmit from "../../lib/form/submit"; import useFormSubmit from "../../../lib/form/submit";
import CheckList from "../../components/check-list"; import CheckList from "../../../components/check-list";
import MutationButton from "../../components/form/mutation-button"; import MutationButton from "../../../components/form/mutation-button";
import FormWithData from "../../lib/form/form-with-data"; import FormWithData from "../../../lib/form/form-with-data";
import { useImportDomainPermsMutation } from "../../lib/query/admin/domain-permissions/import"; import { useImportDomainPermsMutation } from "../../../lib/query/admin/domain-permissions/import";
import { import {
useDomainAllowsQuery, useDomainAllowsQuery,
useDomainBlocksQuery useDomainBlocksQuery
} from "../../lib/query/admin/domain-permissions/get"; } from "../../../lib/query/admin/domain-permissions/get";
import type { DomainPerm, MappedDomainPerms } from "../../lib/types/domain-permission"; import type { DomainPerm, MappedDomainPerms } from "../../../lib/types/domain-permission";
import type { ChecklistInputHook, RadioFormInputHook } from "../../lib/form/types"; import type { ChecklistInputHook, RadioFormInputHook } from "../../../lib/form/types";
export interface ProcessImportProps { export interface ProcessImportProps {
list: DomainPerm[], list: DomainPerm[],
@ -61,7 +61,6 @@ export interface ProcessImportProps {
export const ProcessImport = memo( export const ProcessImport = memo(
function ProcessImport({ list, permType }: ProcessImportProps) { function ProcessImport({ list, permType }: ProcessImportProps) {
return ( return (
<div className="without-border">
<FormWithData <FormWithData
dataQuery={permType.value == "allow" dataQuery={permType.value == "allow"
? useDomainAllowsQuery ? useDomainAllowsQuery
@ -70,7 +69,6 @@ export const ProcessImport = memo(
DataForm={ImportList} DataForm={ImportList}
{...{ list, permType }} {...{ list, permType }}
/> />
</div>
); );
} }
); );

View file

@ -18,32 +18,24 @@
*/ */
import React, { useState } from "react"; import React, { useState } from "react";
import { useRoute, Redirect } from "wouter"; import { useParams } from "wouter";
import FormWithData from "../../../lib/form/form-with-data";
import FormWithData from "../../lib/form/form-with-data"; import BackButton from "../../../components/back-button";
import BackButton from "../../components/back-button"; import { useValue, useTextInput } from "../../../lib/form";
import useFormSubmit from "../../../lib/form/submit";
import { useValue, useTextInput } from "../../lib/form"; import { TextArea } from "../../../components/form/inputs";
import useFormSubmit from "../../lib/form/submit"; import MutationButton from "../../../components/form/mutation-button";
import { TextArea } from "../../components/form/inputs";
import MutationButton from "../../components/form/mutation-button";
import Username from "./username"; import Username from "./username";
import { useBaseUrl } from "../../lib/navigation/util"; import { useGetReportQuery, useResolveReportMutation } from "../../../lib/query/admin/reports";
import { useGetReportQuery, useResolveReportMutation } from "../../lib/query/admin/reports"; import { useBaseUrl } from "../../../lib/navigation/util";
export default function ReportDetail({ }) { export default function ReportDetail({ }) {
const baseUrl = useBaseUrl(); const baseUrl = useBaseUrl();
let [_match, params] = useRoute(`${baseUrl}/:reportId`); const params = useParams();
if (params?.reportId == undefined) {
return <Redirect to={baseUrl} />;
} else {
return ( return (
<div className="report-detail"> <div className="reports">
<h1> <h1><BackButton to={`~${baseUrl}`}/> Report Details</h1>
<BackButton to={baseUrl} /> Report Details
</h1>
<FormWithData <FormWithData
dataQuery={useGetReportQuery} dataQuery={useGetReportQuery}
queryArg={params.reportId} queryArg={params.reportId}
@ -51,7 +43,6 @@ export default function ReportDetail({ }) {
/> />
</div> </div>
); );
}
} }
function ReportDetailForm({ data: report }) { function ReportDetailForm({ data: report }) {

View file

@ -18,57 +18,50 @@
*/ */
import React from "react"; import React from "react";
import { Link, Switch, Route } from "wouter"; import { Link } from "wouter";
import FormWithData from "../../lib/form/form-with-data"; import FormWithData from "../../../lib/form/form-with-data";
import ReportDetail from "./detail";
import Username from "./username"; import Username from "./username";
import { useBaseUrl } from "../../lib/navigation/util"; import { useListReportsQuery } from "../../../lib/query/admin/reports";
import { useListReportsQuery } from "../../lib/query/admin/reports";
export default function Reports({ baseUrl }) { export function ReportOverview({ }) {
return ( return (
<div className="reports">
<Switch>
<Route path={`${baseUrl}/:reportId`}>
<ReportDetail />
</Route>
<ReportOverview />
</Switch>
</div>
);
}
function ReportOverview({ }) {
return (
<>
<h1>Reports</h1>
<div>
<p>
Here you can view and resolve reports made to your instance, originating from local and remote users.
</p>
</div>
<FormWithData <FormWithData
dataQuery={useListReportsQuery} dataQuery={useListReportsQuery}
DataForm={ReportsList} DataForm={ReportsList}
/> />
</>
); );
} }
function ReportsList({ data: reports }) { function ReportsList({ data: reports }) {
return ( return (
<div className="reports">
<div className="form-section-docs">
<h1>Reports</h1>
<p>
Here you can view and resolve reports made to your
instance, originating from local and remote users.
</p>
<a
href="https://docs.gotosocial.org/en/latest/admin/settings/#reports"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about this (opens in a new tab)
</a>
</div>
<div className="list"> <div className="list">
{reports.map((report) => ( {reports.map((report) => (
<ReportEntry key={report.id} report={report} /> <ReportEntry key={report.id} report={report} />
))} ))}
</div> </div>
</div>
); );
} }
function ReportEntry({ report }) { function ReportEntry({ report }) {
const baseUrl = useBaseUrl();
const from = report.account; const from = report.account;
const target = report.target_account; const target = report.target_account;
@ -77,8 +70,11 @@ function ReportEntry({ report }) {
: report.comment; : report.comment;
return ( return (
<Link to={`${baseUrl}/${report.id}`}> <Link
<a className={`report entry${report.action_taken ? " resolved" : ""}`}> to={`/${report.id}`}
className="nounderline"
>
<div className={`report entry${report.action_taken ? " resolved" : ""}`}>
<div className="byline"> <div className="byline">
<div className="usernames"> <div className="usernames">
<Username user={from} link={false} /> reported <Username user={target} link={false} /> <Username user={from} link={false} /> reported <Username user={target} link={false} />
@ -97,7 +93,7 @@ function ReportEntry({ report }) {
: <i className="no-comment">none provided</i> : <i className="no-comment">none provided</i>
} }
</div> </div>
</a> </div>
</Link> </Link>
); );
} }

View file

@ -0,0 +1,201 @@
/*
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 { Redirect, Route, Router, Switch } from "wouter";
import AccountsOverview from "./accounts";
import AccountsPending from "./accounts/pending";
import AccountDetail from "./accounts/detail";
import { ReportOverview } from "./reports/overview";
import DomainPermissionsOverview from "./domain-permissions/overview";
import DomainPermDetail from "./domain-permissions/detail";
import ImportExport from "./domain-permissions/import-export";
import ReportDetail from "./reports/detail";
/*
EXPORTED COMPONENTS
*/
/**
* Moderation menu. Reports, accounts,
* domain permissions import + export.
*/
export function ModerationMenu() {
return (
<MenuItem
name="Moderation"
itemUrl="moderation"
defaultChild="reports"
permissions={["moderator"]}
>
<ModerationReportsMenu />
<ModerationAccountsMenu />
<ModerationDomainPermsMenu />
</MenuItem>
);
}
/**
* Moderation router. Reports, accounts,
* domain permissions import + export.
*/
export function ModerationRouter() {
const parentUrl = useBaseUrl();
const thisBase = "/moderation";
const absBase = parentUrl + thisBase;
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<ModerationReportsRouter />
<ModerationAccountsRouter />
<ModerationDomainPermsRouter />
</Router>
</BaseUrlContext.Provider>
);
}
/*
INTERNAL COMPONENTS
*/
/*
MENUS
*/
function ModerationReportsMenu() {
return (
<MenuItem
name="Reports"
itemUrl="reports"
icon="fa-flag"
/>
);
}
function ModerationAccountsMenu() {
return (
<MenuItem
name="Accounts"
itemUrl="accounts"
defaultChild="overview"
icon="fa-users"
>
<MenuItem
name="Overview"
itemUrl="overview"
icon="fa-list"
/>
<MenuItem
name="Pending"
itemUrl="pending"
icon="fa-question"
/>
</MenuItem>
);
}
function ModerationDomainPermsMenu() {
return (
<MenuItem
name="Domain Permissions"
itemUrl="domain-permissions"
defaultChild="blocks"
icon="fa-hubzilla"
>
<MenuItem
name="Blocks"
itemUrl="blocks"
icon="fa-close"
/>
<MenuItem
name="Allows"
itemUrl="allows"
icon="fa-check"
/>
<MenuItem
name="Import/Export"
itemUrl="import-export"
icon="fa-floppy-o"
/>
</MenuItem>
);
}
/*
ROUTERS
*/
function ModerationReportsRouter() {
const parentUrl = useBaseUrl();
const thisBase = "/reports";
const absBase = parentUrl + thisBase;
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<Switch>
<Route path={"/:reportId"} component={ReportDetail} />
<Route component={ReportOverview}/>
</Switch>
</Router>
</BaseUrlContext.Provider>
);
}
function ModerationAccountsRouter() {
const parentUrl = useBaseUrl();
const thisBase = "/accounts";
const absBase = parentUrl + thisBase;
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<Switch>
<Route path="/overview" component={AccountsOverview}/>
<Route path="/pending" component={AccountsPending}/>
<Route path="/:accountID" component={AccountDetail}/>
<Route><Redirect to="/overview"/></Route>
</Switch>
</Router>
</BaseUrlContext.Provider>
);
}
function ModerationDomainPermsRouter() {
const parentUrl = useBaseUrl();
const thisBase = "/domain-permissions";
const absBase = parentUrl + thisBase;
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<Switch>
<Route path="/import-export" component={ImportExport} />
<Route path="/process" component={ImportExport} />
<Route path="/:permType/:domain" component={DomainPermDetail} />
<Route path="/:permType" component={DomainPermissionsOverview} />
<Route><Redirect to="/blocks"/></Route>
</Switch>
</Router>
</BaseUrlContext.Provider>
);
}

View file

@ -19,16 +19,16 @@
import React from "react"; import React from "react";
import FormWithData from "../lib/form/form-with-data"; import FormWithData from "../../lib/form/form-with-data";
import { useVerifyCredentialsQuery } from "../lib/query/oauth"; import { useVerifyCredentialsQuery } from "../../lib/query/oauth";
import { useArrayInput, useTextInput } from "../lib/form"; import { useArrayInput, useTextInput } from "../../lib/form";
import { TextInput } from "../components/form/inputs"; import { TextInput } from "../../components/form/inputs";
import useFormSubmit from "../lib/form/submit"; import useFormSubmit from "../../lib/form/submit";
import MutationButton from "../components/form/mutation-button"; import MutationButton from "../../components/form/mutation-button";
import { useAliasAccountMutation, useMoveAccountMutation } from "../lib/query/user"; import { useAliasAccountMutation, useMoveAccountMutation } from "../../lib/query/user";
import { FormContext, useWithFormContext } from "../lib/form/context"; import { FormContext, useWithFormContext } from "../../lib/form/context";
import { store } from "../redux/store"; import { store } from "../../redux/store";
export default function UserMigration() { export default function UserMigration() {
return ( return (
@ -81,7 +81,7 @@ function AliasForm({ data: profile }) {
return ( return (
<form className="user-migration-alias" onSubmit={submitForm}> <form className="user-migration-alias" onSubmit={submitForm}>
<div className="form-section-docs without-border"> <div className="form-section-docs">
<h3>Alias Account</h3> <h3>Alias Account</h3>
<a <a
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#alias-account" href="https://docs.gotosocial.org/en/latest/user_guide/settings/#alias-account"
@ -157,15 +157,12 @@ function MoveForm({ data: profile }) {
return ( return (
<form className="user-migration-move" onSubmit={submitForm}> <form className="user-migration-move" onSubmit={submitForm}>
<div className="form-section-docs without-border"> <div className="form-section-docs">
<h3>Move Account</h3> <h3>Move Account</h3>
<p>
<p> <p>
For a move to be successful, you must have already set an alias from the For a move to be successful, you must have already set an alias from the
target account back to the account you're moving from (ie., this account), target account back to the account you're moving from (ie., this account),
using the settings panel of the instance on which the target account resides. using the settings panel of the instance on which the target account resides.
</p>
<p>
To do this, provide the following details to the other instance: To do this, provide the following details to the other instance:
</p> </p>
<dl className="migration-details"> <dl className="migration-details">
@ -187,7 +184,6 @@ function MoveForm({ data: profile }) {
> >
Learn more about moving your account (opens in a new tab) Learn more about moving your account (opens in a new tab)
</a> </a>
</p>
</div> </div>
<TextInput <TextInput
disabled={false} disabled={false}

View file

@ -25,10 +25,10 @@ import {
useBoolInput, useBoolInput,
useFieldArrayInput, useFieldArrayInput,
useRadioInput useRadioInput
} from "../lib/form"; } from "../../lib/form";
import useFormSubmit from "../lib/form/submit"; import useFormSubmit from "../../lib/form/submit";
import { useWithFormContext, FormContext } from "../lib/form/context"; import { useWithFormContext, FormContext } from "../../lib/form/context";
import { import {
TextInput, TextInput,
@ -36,15 +36,15 @@ import {
FileInput, FileInput,
Checkbox, Checkbox,
RadioGroup RadioGroup
} from "../components/form/inputs"; } from "../../components/form/inputs";
import FormWithData from "../lib/form/form-with-data"; import FormWithData from "../../lib/form/form-with-data";
import FakeProfile from "../components/fake-profile"; import FakeProfile from "../../components/fake-profile";
import MutationButton from "../components/form/mutation-button"; import MutationButton from "../../components/form/mutation-button";
import { useAccountThemesQuery, useInstanceV1Query } from "../lib/query"; import { useAccountThemesQuery, useInstanceV1Query } from "../../lib/query";
import { useUpdateCredentialsMutation } from "../lib/query/user"; import { useUpdateCredentialsMutation } from "../../lib/query/user";
import { useVerifyCredentialsQuery } from "../lib/query/oauth"; import { useVerifyCredentialsQuery } from "../../lib/query/oauth";
export default function UserProfile() { export default function UserProfile() {
return ( return (

View file

@ -0,0 +1,80 @@
/*
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 UserProfile from "./profile";
import UserSettings from "./settings";
import UserMigration from "./migration";
import { Redirect, Route, Router, Switch } from "wouter";
/**
*
* Basic user menu. Profile + accounts
* settings, post settings, migration.
*/
export function UserMenu() {
return (
<MenuItem
name="User"
itemUrl="user"
defaultChild="profile"
>
{/* Profile */}
<MenuItem
name="Profile"
itemUrl="profile"
icon="fa-user"
/>
{/* Settings */}
<MenuItem
name="Settings"
itemUrl="settings"
icon="fa-cogs"
/>
{/* Migration */}
<MenuItem
name="Migration"
itemUrl="migration"
icon="fa-exchange"
/>
</MenuItem>
);
}
export function UserRouter() {
const baseUrl = useBaseUrl();
const thisBase = "/user";
const absBase = baseUrl + thisBase;
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<Switch>
<Route path="/profile" component={UserProfile} />
<Route path="/settings" component={UserSettings} />
<Route path="/migration" component={UserMigration} />
{/* Fallback component */}
<Route><Redirect to="/profile" /></Route>
</Switch>
</Router>
</BaseUrlContext.Provider>
);
}

View file

@ -18,18 +18,13 @@
*/ */
import React from "react"; import React from "react";
import query from "../../lib/query";
import query from "../lib/query"; import { useTextInput, useBoolInput } from "../../lib/form";
import useFormSubmit from "../../lib/form/submit";
import { useTextInput, useBoolInput } from "../lib/form"; import { Select, TextInput, Checkbox } from "../../components/form/inputs";
import FormWithData from "../../lib/form/form-with-data";
import useFormSubmit from "../lib/form/submit"; import Languages from "../../components/languages";
import MutationButton from "../../components/form/mutation-button";
import { Select, TextInput, Checkbox } from "../components/form/inputs";
import FormWithData from "../lib/form/form-with-data";
import Languages from "../components/languages";
import MutationButton from "../components/form/mutation-button";
export default function UserSettings() { export default function UserSettings() {
return ( return (
@ -59,8 +54,19 @@ function UserSettingsForm({ data }) {
return ( return (
<> <>
<h1>Account Settings</h1>
<form className="user-settings" onSubmit={submitForm}> <form className="user-settings" onSubmit={submitForm}>
<h1>Post settings</h1> <div className="form-section-docs">
<h3>Post Settings</h3>
<a
href="https://docs.gotosocial.org/en/latest/user_guide/posts"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about these settings (opens in a new tab)
</a>
</div>
<Select field={form.language} label="Default post language" options={ <Select field={form.language} label="Default post language" options={
<Languages /> <Languages />
}> }>
@ -72,7 +78,6 @@ function UserSettingsForm({ data }) {
<option value="public">Public</option> <option value="public">Public</option>
</> </>
}> }>
<a href="https://docs.gotosocial.org/en/latest/user_guide/posts/#privacy-settings" target="_blank" className="docslink" rel="noreferrer">Learn more about post privacy settings (opens in a new tab)</a>
</Select> </Select>
<Select field={form.statusContentType} label="Default post (and bio) format" options={ <Select field={form.statusContentType} label="Default post (and bio) format" options={
<> <>
@ -80,13 +85,11 @@ function UserSettingsForm({ data }) {
<option value="text/markdown">Markdown</option> <option value="text/markdown">Markdown</option>
</> </>
}> }>
<a href="https://docs.gotosocial.org/en/latest/user_guide/posts/#input-types" target="_blank" className="docslink" rel="noreferrer">Learn more about post format settings (opens in a new tab)</a>
</Select> </Select>
<Checkbox <Checkbox
field={form.isSensitive} field={form.isSensitive}
label="Mark my posts as sensitive by default" label="Mark my posts as sensitive by default"
/> />
<MutationButton <MutationButton
disabled={false} disabled={false}
label="Save settings" label="Save settings"
@ -124,24 +127,37 @@ function PasswordChange() {
return ( return (
<form className="change-password" onSubmit={submitForm}> <form className="change-password" onSubmit={submitForm}>
<h1>Change password</h1> <div className="form-section-docs">
<h3>Change Password</h3>
<a
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#password-change"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about this (opens in a new tab)
</a>
</div>
<TextInput <TextInput
type="password" type="password"
name="password" name="password"
field={form.oldPassword} field={form.oldPassword}
label="Current password" label="Current password"
autoComplete="current-password"
/> />
<TextInput <TextInput
type="password" type="password"
name="newPassword" name="newPassword"
field={form.newPassword} field={form.newPassword}
label="New password" label="New password"
autoComplete="new-password"
/> />
<TextInput <TextInput
type="password" type="password"
name="confirmNewPassword" name="confirmNewPassword"
field={verifyNewPassword} field={verifyNewPassword}
label="Confirm new password" label="Confirm new password"
autoComplete="new-password"
/> />
<MutationButton <MutationButton
disabled={false} disabled={false}

View file

@ -1229,11 +1229,6 @@
resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9"
integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==
"@types/bluebird@^3.5.39":
version "3.5.39"
resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.39.tgz#6aaf8bcbf005bb091d06ddaa0f620be078bf6a73"
integrity sha512-0h2lKudcFwHih8NHAgt/uyAIUQDO0AdfJYlWBXD8r+gFDulUi2CMZoQSh2Q5ol1FMaHV9k7/4HtcbA8ABtexmA==
"@types/hoist-non-react-statics@^3.3.1": "@types/hoist-non-react-statics@^3.3.1":
version "3.3.2" version "3.3.2"
resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#dc1e9ded53375d37603c479cc12c693b0878aa2a" resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#dc1e9ded53375d37603c479cc12c693b0878aa2a"
@ -2056,14 +2051,14 @@ asynciterator.prototype@^1.0.0:
dependencies: dependencies:
has-symbols "^1.0.3" has-symbols "^1.0.3"
autoprefixer@^10.4.13: autoprefixer@^10.4.19:
version "10.4.16" version "10.4.19"
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.16.tgz#fad1411024d8670880bdece3970aa72e3572feb8" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.19.tgz#ad25a856e82ee9d7898c59583c1afeb3fa65f89f"
integrity sha512-7vd3UC6xKp0HLfua5IjZlcXvGAGy7cBAXTg2lyQ/8WpNhd6SiZ8Be+xm3FyBSYJx5GKcpRCzBh7RH4/0dnY+uQ== integrity sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==
dependencies: dependencies:
browserslist "^4.21.10" browserslist "^4.23.0"
caniuse-lite "^1.0.30001538" caniuse-lite "^1.0.30001599"
fraction.js "^4.3.6" fraction.js "^4.3.7"
normalize-range "^0.1.2" normalize-range "^0.1.2"
picocolors "^1.0.0" picocolors "^1.0.0"
postcss-value-parser "^4.2.0" postcss-value-parser "^4.2.0"
@ -2339,7 +2334,7 @@ browserify@^17.0.0:
vm-browserify "^1.0.0" vm-browserify "^1.0.0"
xtend "^4.0.0" xtend "^4.0.0"
browserslist@^4.21.10, browserslist@^4.21.9, browserslist@^4.22.1: browserslist@^4.21.9, browserslist@^4.22.1:
version "4.22.1" version "4.22.1"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.22.1.tgz#ba91958d1a59b87dab6fed8dfbcb3da5e2e9c619" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.22.1.tgz#ba91958d1a59b87dab6fed8dfbcb3da5e2e9c619"
integrity sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ== integrity sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==
@ -2349,6 +2344,16 @@ browserslist@^4.21.10, browserslist@^4.21.9, browserslist@^4.22.1:
node-releases "^2.0.13" node-releases "^2.0.13"
update-browserslist-db "^1.0.13" update-browserslist-db "^1.0.13"
browserslist@^4.23.0:
version "4.23.0"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.23.0.tgz#8f3acc2bbe73af7213399430890f86c63a5674ab"
integrity sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==
dependencies:
caniuse-lite "^1.0.30001587"
electron-to-chromium "^1.4.668"
node-releases "^2.0.14"
update-browserslist-db "^1.0.13"
buffer-from@^1.0.0: buffer-from@^1.0.0:
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
@ -2408,11 +2413,16 @@ callsites@^3.0.0:
resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73"
integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==
caniuse-lite@^1.0.30001538, caniuse-lite@^1.0.30001541: caniuse-lite@^1.0.30001541:
version "1.0.30001543" version "1.0.30001543"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001543.tgz#478a3e9dddbb353c5ab214b0ecb0dbed529ed1d8" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001543.tgz#478a3e9dddbb353c5ab214b0ecb0dbed529ed1d8"
integrity sha512-qxdO8KPWPQ+Zk6bvNpPeQIOH47qZSYdFZd6dXQzb2KzhnSXju4Kd7H1PkSJx6NICSMgo/IhRZRhhfPTHYpJUCA== integrity sha512-qxdO8KPWPQ+Zk6bvNpPeQIOH47qZSYdFZd6dXQzb2KzhnSXju4Kd7H1PkSJx6NICSMgo/IhRZRhhfPTHYpJUCA==
caniuse-lite@^1.0.30001587, caniuse-lite@^1.0.30001599:
version "1.0.30001612"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001612.tgz#d34248b4ec1f117b70b24ad9ee04c90e0b8a14ae"
integrity sha512-lFgnZ07UhaCcsSZgWW0K5j4e69dK1u/ltrL9lTUiFOwNHs12S3UMIEYgBV0Z6C6hRDev7iRnMzzYmKabYdXF9g==
chalk@^2.4.2: chalk@^2.4.2:
version "2.4.2" version "2.4.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
@ -2933,6 +2943,11 @@ electron-to-chromium@^1.4.535:
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.540.tgz#c685f2f035e93eb21dd6a9cfe2c735bad8f77401" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.540.tgz#c685f2f035e93eb21dd6a9cfe2c735bad8f77401"
integrity sha512-aoCqgU6r9+o9/S7wkcSbmPRFi7OWZWiXS9rtjEd+Ouyu/Xyw5RSq2XN8s5Qp8IaFOLiRrhQCphCIjAxgG3eCAg== integrity sha512-aoCqgU6r9+o9/S7wkcSbmPRFi7OWZWiXS9rtjEd+Ouyu/Xyw5RSq2XN8s5Qp8IaFOLiRrhQCphCIjAxgG3eCAg==
electron-to-chromium@^1.4.668:
version "1.4.746"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.746.tgz#787213e75f6c7bccb55dfe8b68170555c548d093"
integrity sha512-jeWaIta2rIG2FzHaYIhSuVWqC6KJYo7oSBX4Jv7g+aVujKztfvdpf+n6MGwZdC5hQXbax4nntykLH2juIQrfPg==
elliptic@^6.5.3, elliptic@^6.5.4: elliptic@^6.5.3, elliptic@^6.5.4:
version "6.5.4" version "6.5.4"
resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb" resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb"
@ -3537,10 +3552,10 @@ forwarded@0.2.0:
resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811"
integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==
fraction.js@^4.3.6: fraction.js@^4.3.7:
version "4.3.6" version "4.3.7"
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.6.tgz#e9e3acec6c9a28cf7bc36cbe35eea4ceb2c5c92d" resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
integrity sha512-n2aZ9tNfYDwaHhvFTkhFErqOMIb8uyzSQ+vGJBjZyanAKZVbGUQ1sngfk9FdkBw7G26O7AgNjLcecLffD1c7eg== integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
fresh@0.5.2: fresh@0.5.2:
version "0.5.2" version "0.5.2"
@ -4649,6 +4664,11 @@ minimist@~0.2.0:
resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.2.4.tgz#0085d5501e29033748a2f2a4da0180142697a475" resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.2.4.tgz#0085d5501e29033748a2f2a4da0180142697a475"
integrity sha512-Pkrrm8NjyQ8yVt8Am9M+yUt74zE3iokhzbG1bFVNjLB92vwM71hf40RkEsryg98BujhVOncKm/C1xROxZ030LQ== integrity sha512-Pkrrm8NjyQ8yVt8Am9M+yUt74zE3iokhzbG1bFVNjLB92vwM71hf40RkEsryg98BujhVOncKm/C1xROxZ030LQ==
mitt@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.1.tgz#ea36cf0cc30403601ae074c8f77b7092cdab36d1"
integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==
mkdirp-classic@^0.5.2: mkdirp-classic@^0.5.2:
version "0.5.3" version "0.5.3"
resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
@ -4735,6 +4755,11 @@ node-releases@^2.0.13:
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.13.tgz#d5ed1627c23e3461e819b02e57b75e4899b1c81d" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.13.tgz#d5ed1627c23e3461e819b02e57b75e4899b1c81d"
integrity sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ== integrity sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==
node-releases@^2.0.14:
version "2.0.14"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b"
integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==
normalize-path@^3.0.0, normalize-path@~3.0.0: normalize-path@^3.0.0, normalize-path@~3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
@ -5436,6 +5461,11 @@ regexp.prototype.flags@^1.5.0, regexp.prototype.flags@^1.5.1:
define-properties "^1.2.0" define-properties "^1.2.0"
set-function-name "^2.0.0" set-function-name "^2.0.0"
regexparam@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/regexparam/-/regexparam-3.0.0.tgz#1673e09d41cb7fd41eaafd4040a6aa90daa0a21a"
integrity sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==
regexpu-core@^5.3.1: regexpu-core@^5.3.1:
version "5.3.2" version "5.3.2"
resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-5.3.2.tgz#11a2b06884f3527aec3e93dbbf4a3b958a95546b" resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-5.3.2.tgz#11a2b06884f3527aec3e93dbbf4a3b958a95546b"
@ -5841,7 +5871,7 @@ sourcemap-codec@^1.4.1:
resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
split-filter-n@^1.1.2, split-filter-n@^1.1.3: split-filter-n@^1.1.2:
version "1.1.3" version "1.1.3"
resolved "https://registry.yarnpkg.com/split-filter-n/-/split-filter-n-1.1.3.tgz#c983ae1e52402e70071f711a7af767a91f09f740" resolved "https://registry.yarnpkg.com/split-filter-n/-/split-filter-n-1.1.3.tgz#c983ae1e52402e70071f711a7af767a91f09f740"
integrity sha512-EU0EjvBI/mYBQMSAHq+ua/YNCuThuDjbU5h036k01+xieFW1aNvLNKb90xLihXIz5xJQX4VkEKan4LjSIyv7lg== integrity sha512-EU0EjvBI/mYBQMSAHq+ua/YNCuThuDjbU5h036k01+xieFW1aNvLNKb90xLihXIz5xJQX4VkEKan4LjSIyv7lg==
@ -6592,11 +6622,13 @@ word-wrap@~1.2.3:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
wouter@^2.8.0-alpha.2: wouter@^3.1.0:
version "2.11.0" version "3.1.2"
resolved "https://registry.yarnpkg.com/wouter/-/wouter-2.11.0.tgz#3db485dec158115b67330821e7673bf3e2f78678" resolved "https://registry.yarnpkg.com/wouter/-/wouter-3.1.2.tgz#8fe1d1c08a415b64d7d2583090bb66f2166636ef"
integrity sha512-Y2CzNCwIN8kHjR2Q10D+UAgQND6TvBNmwXxgYw5ltXjjTlL7cLDUDpCip3a927Svxrmxr6vJMcPUysFxSvriCw== integrity sha512-oyYrbwnIbal7Hz6LzeqRoyWFEkNA64SCmF9r48f6hkUcLnT0y0o+hthuT1X1OIbj80YBT9zE+mH4GYUWH98nIg==
dependencies: dependencies:
mitt "^3.0.1"
regexparam "^3.0.0"
use-sync-external-store "^1.0.0" use-sync-external-store "^1.0.0"
wrap-ansi@^6.0.1: wrap-ansi@^6.0.1: