mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-11-27 14:16:39 +00:00
[chore] Refactor settings panel routing (and other fixes) (#2864)
This commit is contained in:
parent
62788aa116
commit
7a1e639483
11
.vscode/settings.json
vendored
11
.vscode/settings.json
vendored
|
@ -10,5 +10,14 @@
|
|||
},
|
||||
"eslint.workingDirectories": ["web/source"],
|
||||
"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"
|
||||
}
|
||||
}
|
|
@ -78,7 +78,7 @@ skulk({
|
|||
// commonjs here, no need for the typescript preset.
|
||||
["babelify", {
|
||||
global: true,
|
||||
ignore: [/node_modules\/(?!nanoid)/],
|
||||
ignore: [/node_modules\/(?!(nanoid)|(wouter))/],
|
||||
}]
|
||||
],
|
||||
presets: [
|
||||
|
|
|
@ -13,7 +13,6 @@
|
|||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.8.6",
|
||||
"ariakit": "^2.0.0-next.41",
|
||||
"bluebird": "^3.7.2",
|
||||
"get-by-dot": "^1.0.2",
|
||||
"is-valid-domain": "^0.1.6",
|
||||
"js-file-download": "^0.4.12",
|
||||
|
@ -33,9 +32,7 @@
|
|||
"redux": "^4.2.0",
|
||||
"redux-persist": "^6.0.0",
|
||||
"skulk": "^0.0.8-fix",
|
||||
"split-filter-n": "^1.1.3",
|
||||
"syncpipe": "^1.0.0",
|
||||
"wouter": "^2.8.0-alpha.2"
|
||||
"wouter": "^3.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.23.0",
|
||||
|
@ -45,14 +42,13 @@
|
|||
"@browserify/envify": "^6.0.0",
|
||||
"@browserify/uglifyify": "^6.0.0",
|
||||
"@joepie91/eslint-config": "^1.1.1",
|
||||
"@types/bluebird": "^3.5.39",
|
||||
"@types/is-valid-domain": "^0.0.2",
|
||||
"@types/papaparse": "^5.3.9",
|
||||
"@types/psl": "^1.1.1",
|
||||
"@types/react-dom": "^18.2.8",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
||||
"@typescript-eslint/parser": "^6.7.4",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"babelify": "^10.0.0",
|
||||
"css-extract": "^2.0.0",
|
||||
"eslint": "^8.26.0",
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>< 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>
|
||||
);
|
||||
}
|
|
@ -68,7 +68,7 @@ export function AccountList({
|
|||
<Link
|
||||
key={acc.acct}
|
||||
className="account entry"
|
||||
href={`/settings/admin/accounts/${acc.id}`}
|
||||
href={`/${acc.id}`}
|
||||
>
|
||||
{acc.display_name?.length > 0
|
||||
? acc.display_name
|
||||
|
|
|
@ -17,13 +17,11 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
const { Link } = require("wouter");
|
||||
import React from "react";
|
||||
import { Link } from "wouter";
|
||||
|
||||
module.exports = function BackButton({ to }) {
|
||||
export default function BackButton({ to }) {
|
||||
return (
|
||||
<Link to={to}>
|
||||
<a className="button">< back</a>
|
||||
</Link>
|
||||
<Link className="button" to={to}>< back</Link>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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>);
|
84
web/source/settings/index.tsx
Normal file
84
web/source/settings/index.tsx
Normal 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>);
|
|
@ -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
|
||||
};
|
98
web/source/settings/lib/navigation/error.tsx
Normal file
98
web/source/settings/lib/navigation/error.tsx
Normal 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 };
|
|
@ -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
|
||||
};
|
175
web/source/settings/lib/navigation/menu.tsx
Normal file
175
web/source/settings/lib/navigation/menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -18,37 +18,62 @@
|
|||
*/
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
const RoleContext = createContext([]);
|
||||
const RoleContext = createContext<string[]>([]);
|
||||
const BaseUrlContext = createContext<string>("");
|
||||
const MenuLevelContext = createContext<number>(0);
|
||||
|
||||
function urlSafe(str) {
|
||||
function urlSafe(str: string) {
|
||||
return str.toLowerCase().replace(/[\s/]+/g, "-");
|
||||
}
|
||||
|
||||
function useHasPermission(permissions) {
|
||||
const roles = useContext(RoleContext);
|
||||
function useHasPermission(permissions: string[] | undefined) {
|
||||
const roles = useContext<string[]>(RoleContext);
|
||||
return checkPermission(permissions, roles);
|
||||
}
|
||||
|
||||
function checkPermission(requiredPermissisons, user) {
|
||||
// requiredPermissions can be 'false', in which case there are no restrictions
|
||||
if (requiredPermissisons === false) {
|
||||
// checkPermission returns true if the user's roles
|
||||
// include requiredPermissions, or false otherwise.
|
||||
function checkPermission(requiredPermissions: string[] | undefined, userRoles: string[]): boolean {
|
||||
if (requiredPermissions === undefined) {
|
||||
// No perms defined, so user
|
||||
// implicitly has permission.
|
||||
return true;
|
||||
}
|
||||
|
||||
// or an array of roles, check if one of the user's roles is sufficient
|
||||
return user.some((role) => requiredPermissisons.includes(role));
|
||||
if (requiredPermissions.length === 0) {
|
||||
// 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() {
|
||||
return useContext(BaseUrlContext);
|
||||
}
|
||||
|
||||
function useMenuLevel() {
|
||||
return useContext(MenuLevelContext);
|
||||
}
|
||||
|
||||
export {
|
||||
urlSafe,
|
||||
RoleContext,
|
||||
useHasPermission,
|
||||
checkPermission,
|
||||
BaseUrlContext,
|
||||
useBaseUrl
|
||||
useBaseUrl,
|
||||
MenuLevelContext,
|
||||
useMenuLevel,
|
||||
};
|
||||
|
|
|
@ -21,6 +21,7 @@ import { replaceCacheOnMutation, removeFromCacheOnMutation } from "../query-modi
|
|||
import { gtsApi } from "../gts-api";
|
||||
import { listToKeyedObject } from "../transforms";
|
||||
import { AdminAccount, HandleSignupParams, SearchAccountParams } from "../../types/account";
|
||||
import { InstanceRule, MappedRules } from "../../types/rules";
|
||||
|
||||
const extended = gtsApi.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
|
@ -120,14 +121,14 @@ const extended = gtsApi.injectEndpoints({
|
|||
],
|
||||
}),
|
||||
|
||||
instanceRules: build.query({
|
||||
instanceRules: build.query<MappedRules, void>({
|
||||
query: () => ({
|
||||
url: `/api/v1/admin/instance/rules`
|
||||
}),
|
||||
transformResponse: listToKeyedObject<any>("id")
|
||||
transformResponse: listToKeyedObject<InstanceRule>("id")
|
||||
}),
|
||||
|
||||
addInstanceRule: build.mutation({
|
||||
addInstanceRule: build.mutation<MappedRules, any>({
|
||||
query: (formData) => ({
|
||||
method: "POST",
|
||||
url: `/api/v1/admin/instance/rules`,
|
||||
|
@ -135,11 +136,7 @@ const extended = gtsApi.injectEndpoints({
|
|||
body: formData,
|
||||
discardEmpty: true
|
||||
}),
|
||||
transformResponse: (data) => {
|
||||
return {
|
||||
[data.id]: data
|
||||
};
|
||||
},
|
||||
transformResponse: listToKeyedObject<InstanceRule>("id"),
|
||||
...replaceCacheOnMutation("instanceRules"),
|
||||
}),
|
||||
|
||||
|
|
|
@ -20,7 +20,15 @@
|
|||
export interface CustomEmoji {
|
||||
id?: string;
|
||||
shortcode: string;
|
||||
url: string;
|
||||
static_url: string;
|
||||
visible_in_picker: boolean;
|
||||
category?: string;
|
||||
disabled: boolean;
|
||||
updated_at: string;
|
||||
total_file_size: number;
|
||||
content_type: string;
|
||||
uri: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -17,19 +17,13 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Switch, Route } from "wouter";
|
||||
|
||||
import EmojiOverview from "./overview";
|
||||
import EmojiDetail from "./detail";
|
||||
|
||||
export default function CustomEmoji({ baseUrl }) {
|
||||
return (
|
||||
<Switch>
|
||||
<Route path={`${baseUrl}/:emojiId`}>
|
||||
<EmojiDetail />
|
||||
</Route>
|
||||
<EmojiOverview />
|
||||
</Switch>
|
||||
);
|
||||
export interface InstanceRule {
|
||||
id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface MappedRules {
|
||||
[key: string]: InstanceRule;
|
||||
}
|
|
@ -53,21 +53,13 @@ ul li::before {
|
|||
|
||||
& > div,
|
||||
& > form {
|
||||
border-left: 0.2rem solid $border-accent;
|
||||
padding-left: 0.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
|
||||
h1, h2 {
|
||||
h1, h2, h3, h4, h5 {
|
||||
margin: 0;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
&:only-child {
|
||||
border-left: none;
|
||||
padding-left: none;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
|
@ -77,12 +69,6 @@ ul li::before {
|
|||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&.without-border,
|
||||
.without-border {
|
||||
border-left: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
& > .error {
|
||||
|
@ -305,7 +291,8 @@ input, select, textarea {
|
|||
) !important;
|
||||
}
|
||||
|
||||
section.with-sidebar > div, section.with-sidebar > form {
|
||||
section.with-sidebar > div,
|
||||
section.with-sidebar > form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
@ -348,10 +335,6 @@ section.with-sidebar > div, section.with-sidebar > form {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.labelinput .border {
|
||||
|
|
|
@ -18,13 +18,10 @@
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { useInstanceKeysExpireMutation } from "../../../lib/query";
|
||||
|
||||
import { useTextInput } from "../../../lib/form";
|
||||
import { TextInput } from "../../../components/form/inputs";
|
||||
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
import { useInstanceKeysExpireMutation } from "../../../../lib/query";
|
||||
import { TextInput } from "../../../../components/form/inputs";
|
||||
import MutationButton from "../../../../components/form/mutation-button";
|
||||
import { useTextInput } from "../../../../lib/form";
|
||||
|
||||
export default function ExpireRemote({}) {
|
||||
const domainField = useTextInput("domain");
|
||||
|
@ -54,7 +51,7 @@ export default function ExpireRemote({}) {
|
|||
placeholder="example.org"
|
||||
/>
|
||||
<MutationButton
|
||||
disabled={false}
|
||||
disabled={!domainField.value}
|
||||
label="Expire keys"
|
||||
result={expireResult}
|
||||
/>
|
|
@ -19,12 +19,10 @@
|
|||
|
||||
import React from "react";
|
||||
|
||||
import { useMediaCleanupMutation } from "../../../lib/query";
|
||||
|
||||
import { useTextInput } from "../../../lib/form";
|
||||
import { TextInput } from "../../../components/form/inputs";
|
||||
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
import { useMediaCleanupMutation } from "../../../../lib/query";
|
||||
import { useTextInput } from "../../../../lib/form";
|
||||
import { TextInput } from "../../../../components/form/inputs";
|
||||
import MutationButton from "../../../../components/form/mutation-button";
|
||||
|
||||
export default function Cleanup({}) {
|
||||
const daysField = useTextInput("days", { defaultValue: "30" });
|
||||
|
@ -52,7 +50,7 @@ export default function Cleanup({}) {
|
|||
placeholder="30"
|
||||
/>
|
||||
<MutationButton
|
||||
disabled={false}
|
||||
disabled={!daysField.value}
|
||||
label="Remove old media"
|
||||
result={mediaCleanupResult}
|
||||
/>
|
134
web/source/settings/views/admin/emoji/category-select.tsx
Normal file
134
web/source/settings/views/admin/emoji/category-select.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -18,36 +18,29 @@
|
|||
*/
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { useRoute, Link, Redirect } from "wouter";
|
||||
|
||||
import { useComboBoxInput, useFileInput, useValue } from "../../../lib/form";
|
||||
import { Redirect, useParams } from "wouter";
|
||||
import { useComboBoxInput, useFileInput, useValue } from "../../../../lib/form";
|
||||
import useFormSubmit from "../../../../lib/form/submit";
|
||||
import { useBaseUrl } from "../../../../lib/navigation/util";
|
||||
import FakeToot from "../../../../components/fake-toot";
|
||||
import FormWithData from "../../../../lib/form/form-with-data";
|
||||
import Loading from "../../../../components/loading";
|
||||
import { FileInput } from "../../../../components/form/inputs";
|
||||
import MutationButton from "../../../../components/form/mutation-button";
|
||||
import { Error } from "../../../../components/error";
|
||||
import { useGetEmojiQuery, useEditEmojiMutation, useDeleteEmojiMutation } from "../../../../lib/query/admin/custom-emoji";
|
||||
import { CategorySelect } from "../category-select";
|
||||
import BackButton from "../../../../components/back-button";
|
||||
|
||||
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";
|
||||
|
||||
export default function EmojiDetailRoute({ }) {
|
||||
export default function EmojiDetail() {
|
||||
const baseUrl = useBaseUrl();
|
||||
let [_match, params] = useRoute(`${baseUrl}/:emojiId`);
|
||||
if (params?.emojiId == undefined) {
|
||||
return <Redirect to={baseUrl} />;
|
||||
} else {
|
||||
const params = useParams();
|
||||
return (
|
||||
<div className="emoji-detail">
|
||||
<Link to={baseUrl}><a>< go back</a></Link>
|
||||
<BackButton to={`~${baseUrl}/local`} />
|
||||
<FormWithData dataQuery={useGetEmojiQuery} queryArg={params.emojiId} DataForm={EmojiDetailForm} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function EmojiDetailForm({ data: emoji }) {
|
||||
|
@ -77,7 +70,7 @@ function EmojiDetailForm({ data: emoji }) {
|
|||
const [deleteEmoji, deleteResult] = useDeleteEmojiMutation();
|
||||
|
||||
if (deleteResult.isSuccess) {
|
||||
return <Redirect to={baseUrl} />;
|
||||
return <Redirect to={`~${baseUrl}/local`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -93,6 +86,7 @@ function EmojiDetailForm({ data: emoji }) {
|
|||
className="danger"
|
||||
showError={false}
|
||||
result={deleteResult}
|
||||
disabled={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -110,6 +104,7 @@ function EmojiDetailForm({ data: emoji }) {
|
|||
result={result}
|
||||
showError={false}
|
||||
style={{ visibility: (form.category.isNew ? "initial" : "hidden") }}
|
||||
disabled={!form.category.value}
|
||||
/>
|
||||
</CategorySelect>
|
||||
</div>
|
||||
|
@ -126,12 +121,13 @@ function EmojiDetailForm({ data: emoji }) {
|
|||
label="Replace image"
|
||||
showError={false}
|
||||
result={result}
|
||||
disabled={!form.image.value}
|
||||
/>
|
||||
|
||||
<FakeToot>
|
||||
Look at this new custom emoji <img
|
||||
className="emoji"
|
||||
src={form.image.previewURL ?? emoji.url}
|
||||
src={form.image.previewValue ?? emoji.url}
|
||||
title={`:${emoji.shortcode}:`}
|
||||
alt={emoji.shortcode}
|
||||
/> isn't it cool?
|
|
@ -18,19 +18,15 @@
|
|||
*/
|
||||
|
||||
import React, { useMemo, useEffect } from "react";
|
||||
|
||||
import { useFileInput, useComboBoxInput } from "../../../lib/form";
|
||||
import { useFileInput, useComboBoxInput } from "../../../../lib/form";
|
||||
import useShortcode from "./use-shortcode";
|
||||
|
||||
import useFormSubmit from "../../../lib/form/submit";
|
||||
|
||||
import { TextInput, FileInput } from "../../../components/form/inputs";
|
||||
|
||||
import useFormSubmit from "../../../../lib/form/submit";
|
||||
import { TextInput, FileInput } from "../../../../components/form/inputs";
|
||||
import { CategorySelect } from '../category-select';
|
||||
import FakeToot from "../../../components/fake-toot";
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
import { useAddEmojiMutation } from "../../../lib/query/admin/custom-emoji";
|
||||
import { useInstanceV1Query } from "../../../lib/query";
|
||||
import FakeToot from "../../../../components/fake-toot";
|
||||
import MutationButton from "../../../../components/form/mutation-button";
|
||||
import { useAddEmojiMutation } from "../../../../lib/query/admin/custom-emoji";
|
||||
import { useInstanceV1Query } from "../../../../lib/query";
|
||||
|
||||
export default function NewEmojiForm() {
|
||||
const shortcode = useShortcode();
|
173
web/source/settings/views/admin/emoji/local/overview.tsx
Normal file
173
web/source/settings/views/admin/emoji/local/overview.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -17,19 +17,19 @@
|
|||
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");
|
||||
const { useListEmojiQuery } = require("../../../lib/query/admin/custom-emoji");
|
||||
import { useTextInput } from "../../../../lib/form";
|
||||
import { useListEmojiQuery } from "../../../../lib/query/admin/custom-emoji";
|
||||
|
||||
const shortcodeRegex = /^\w{2,30}$/;
|
||||
|
||||
module.exports = function useShortcode() {
|
||||
export default function useShortcode() {
|
||||
const { data: emoji = [] } = useListEmojiQuery({
|
||||
filter: "domain:local"
|
||||
});
|
||||
|
||||
const emojiCodes = React.useMemo(() => {
|
||||
const emojiCodes = useMemo(() => {
|
||||
return new Set(emoji.map((e) => e.shortcode));
|
||||
}, [emoji]);
|
||||
|
||||
|
@ -53,4 +53,4 @@ module.exports = function useShortcode() {
|
|||
return "";
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
|
@ -19,36 +19,28 @@
|
|||
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
import ParseFromToot from "./parse-from-toot";
|
||||
import StealThisLook from "./steal-this-look";
|
||||
|
||||
import Loading from "../../../components/loading";
|
||||
import { Error } from "../../../components/error";
|
||||
import { useListEmojiQuery } from "../../../lib/query/admin/custom-emoji";
|
||||
import Loading from "../../../../components/loading";
|
||||
import { Error } from "../../../../components/error";
|
||||
import { useListEmojiQuery } from "../../../../lib/query/admin/custom-emoji";
|
||||
|
||||
export default function RemoteEmoji() {
|
||||
// local emoji are queried for shortcode collision detection
|
||||
// Local emoji are queried for
|
||||
// shortcode collision detection
|
||||
const {
|
||||
data: emoji = [],
|
||||
isLoading,
|
||||
error
|
||||
} = useListEmojiQuery({ filter: "domain:local" });
|
||||
|
||||
const emojiCodes = useMemo(() => {
|
||||
return new Set(emoji.map((e) => e.shortcode));
|
||||
}, [emoji]);
|
||||
const emojiCodes = useMemo(() => new Set(emoji.map((e) => e.shortcode)), [emoji]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Custom Emoji (remote)</h1>
|
||||
{error &&
|
||||
<Error error={error} />
|
||||
}
|
||||
{isLoading
|
||||
? <Loading />
|
||||
: <>
|
||||
<ParseFromToot emojiCodes={emojiCodes} />
|
||||
</>
|
||||
}
|
||||
{error && <Error error={error} />}
|
||||
{isLoading ? <Loading /> : <StealThisLook emojiCodes={emojiCodes} />}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -19,19 +19,19 @@
|
|||
|
||||
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 { TextInput } from "../../../components/form/inputs";
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
import { Error } from "../../../components/error";
|
||||
import { useSearchItemForEmojiMutation, usePatchRemoteEmojisMutation } from "../../../lib/query/admin/custom-emoji";
|
||||
import { TextInput } from "../../../../components/form/inputs";
|
||||
import MutationButton from "../../../../components/form/mutation-button";
|
||||
import { Error } from "../../../../components/error";
|
||||
import { useSearchItemForEmojiMutation, usePatchRemoteEmojisMutation } from "../../../../lib/query/admin/custom-emoji";
|
||||
|
||||
export default function ParseFromToot({ emojiCodes }) {
|
||||
export default function StealThisLook({ emojiCodes }) {
|
||||
const [searchStatus, result] = useSearchItemForEmojiMutation();
|
||||
const urlField = useTextInput("url");
|
||||
|
||||
|
@ -48,7 +48,7 @@ export default function ParseFromToot({ emojiCodes }) {
|
|||
<form onSubmit={submitSearch}>
|
||||
<div className="form-field text">
|
||||
<label htmlFor="url">
|
||||
Link to a toot:
|
||||
Link to a status:
|
||||
</label>
|
||||
<div className="row">
|
||||
<input
|
||||
|
@ -85,13 +85,13 @@ function SearchResult({ result, localEmojiCodes }) {
|
|||
if (error == "NONE_FOUND") {
|
||||
return "No results found";
|
||||
} 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) {
|
||||
return <Error error={result.error} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
|
@ -143,7 +143,7 @@ function CopyEmojiForm({ localEmojiCodes, type, emojiList }) {
|
|||
|
||||
return (
|
||||
<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}>
|
||||
<CheckList
|
||||
field={form.selectedEmoji}
|
177
web/source/settings/views/admin/routes.tsx
Normal file
177
web/source/settings/views/admin/routes.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -19,33 +19,33 @@
|
|||
|
||||
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;
|
||||
import MutationButton from "../../components/form/mutation-button";
|
||||
const FormWithData = require("../../../lib/form/form-with-data").default;
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
|
||||
import { useInstanceV1Query } from "../../lib/query";
|
||||
import { useUpdateInstanceMutation } from "../../lib/query/admin";
|
||||
import { InstanceV1 } from "../../lib/types/instance";
|
||||
import { useInstanceV1Query } from "../../../lib/query";
|
||||
import { useUpdateInstanceMutation } from "../../../lib/query/admin";
|
||||
import { InstanceV1 } from "../../../lib/types/instance";
|
||||
|
||||
export default function AdminSettings() {
|
||||
export default function InstanceSettings() {
|
||||
return (
|
||||
<FormWithData
|
||||
dataQuery={useInstanceV1Query}
|
||||
DataForm={AdminSettingsForm}
|
||||
DataForm={InstanceSettingsForm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface AdminSettingsFormProps{
|
||||
interface InstanceSettingsFormProps{
|
||||
data: InstanceV1;
|
||||
}
|
||||
|
||||
function AdminSettingsForm({ data: instance }: AdminSettingsFormProps) {
|
||||
function InstanceSettingsForm({ data: instance }: InstanceSettingsFormProps) {
|
||||
const titleLimit = 40;
|
||||
const shortDescLimit = 500;
|
||||
const descLimit = 5000;
|
151
web/source/settings/views/admin/settings/rules.tsx
Normal file
151
web/source/settings/views/admin/settings/rules.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -19,19 +19,19 @@
|
|||
|
||||
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 {
|
||||
useValue,
|
||||
useTextInput,
|
||||
useBoolInput,
|
||||
} from "../../../lib/form";
|
||||
} from "../../../../lib/form";
|
||||
|
||||
import { Checkbox, TextInput } from "../../../components/form/inputs";
|
||||
import { AdminAccount } from "../../../lib/types/account";
|
||||
import { Checkbox, TextInput } from "../../../../components/form/inputs";
|
||||
import { AdminAccount } from "../../../../lib/types/account";
|
||||
|
||||
export interface AccountActionsProps {
|
||||
account: AdminAccount,
|
|
@ -20,26 +20,26 @@
|
|||
import React from "react";
|
||||
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 {
|
||||
useValue,
|
||||
useTextInput,
|
||||
useBoolInput,
|
||||
} from "../../../lib/form";
|
||||
} from "../../../../lib/form";
|
||||
|
||||
import { Checkbox, Select, TextInput } from "../../../components/form/inputs";
|
||||
import { AdminAccount } from "../../../lib/types/account";
|
||||
import { Checkbox, Select, TextInput } from "../../../../components/form/inputs";
|
||||
import { AdminAccount } from "../../../../lib/types/account";
|
||||
|
||||
export interface HandleSignupProps {
|
||||
account: AdminAccount,
|
||||
accountsBaseUrl: string,
|
||||
backLocation: string,
|
||||
}
|
||||
|
||||
export function HandleSignup({account, accountsBaseUrl}: HandleSignupProps) {
|
||||
export function HandleSignup({account, backLocation}: HandleSignupProps) {
|
||||
const form = {
|
||||
id: useValue("id", account.id),
|
||||
approveOrReject: useTextInput("approve_or_reject", { defaultValue: "approve" }),
|
||||
|
@ -67,7 +67,7 @@ export function HandleSignup({account, accountsBaseUrl}: HandleSignupProps) {
|
|||
if (res.data) {
|
||||
// "reject" successful,
|
||||
// redirect to accounts page.
|
||||
setLocation(accountsBaseUrl);
|
||||
setLocation(backLocation);
|
||||
}
|
||||
}
|
||||
});
|
|
@ -18,51 +18,39 @@
|
|||
*/
|
||||
|
||||
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 { AccountActions } from "./actions";
|
||||
import BackButton from "../../../components/back-button";
|
||||
import { useParams } from "wouter";
|
||||
|
||||
export default function AccountDetail() {
|
||||
// /settings/admin/accounts
|
||||
const accountsBaseUrl = useBaseUrl();
|
||||
const params: { accountID: string } = useParams();
|
||||
|
||||
let [_match, params] = useRoute(`${accountsBaseUrl}/:accountId`);
|
||||
|
||||
if (params?.accountId == undefined) {
|
||||
return <Redirect to={accountsBaseUrl} />;
|
||||
} else {
|
||||
return (
|
||||
<div className="account-detail">
|
||||
<h1 className="text-cutoff">
|
||||
<BackButton to={accountsBaseUrl} /> Account Details
|
||||
</h1>
|
||||
<h1>Account Details</h1>
|
||||
<FormWithData
|
||||
dataQuery={useGetAccountQuery}
|
||||
queryArg={params.accountId}
|
||||
queryArg={params.accountID}
|
||||
DataForm={AccountDetailForm}
|
||||
{...{accountsBaseUrl}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface AccountDetailFormProps {
|
||||
accountsBaseUrl: string,
|
||||
backLocation: string,
|
||||
data: AdminAccount,
|
||||
}
|
||||
|
||||
function AccountDetailForm({ data: adminAcct, accountsBaseUrl }: AccountDetailFormProps) {
|
||||
function AccountDetailForm({ data: adminAcct, backLocation }: AccountDetailFormProps) {
|
||||
let yesOrNo = (b: boolean) => {
|
||||
return b ? "yes" : "no";
|
||||
};
|
||||
|
@ -169,7 +157,7 @@ function AccountDetailForm({ data: adminAcct, accountsBaseUrl }: AccountDetailFo
|
|||
?
|
||||
<HandleSignup
|
||||
account={adminAcct}
|
||||
accountsBaseUrl={accountsBaseUrl}
|
||||
backLocation={backLocation}
|
||||
/>
|
||||
:
|
||||
<AccountActions account={adminAcct} />
|
|
@ -18,23 +18,9 @@
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Switch, Route } from "wouter";
|
||||
|
||||
import AccountDetail from "./detail";
|
||||
import { AccountSearchForm } from "./search";
|
||||
|
||||
export default function Accounts({ baseUrl }) {
|
||||
return (
|
||||
<Switch>
|
||||
<Route path={`${baseUrl}/:accountId`}>
|
||||
<AccountDetail />
|
||||
</Route>
|
||||
<AccountOverview />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountOverview({ }) {
|
||||
export default function AccountsOverview({ }) {
|
||||
return (
|
||||
<div className="accounts-view">
|
||||
<h1>Accounts Overview</h1>
|
|
@ -18,8 +18,8 @@
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useSearchAccountsQuery } from "../../../lib/query";
|
||||
import { AccountList } from "../../../components/account-list";
|
||||
import { useSearchAccountsQuery } from "../../../../lib/query";
|
||||
import { AccountList } from "../../../../components/account-list";
|
||||
|
||||
export default function AccountsPending() {
|
||||
const searchRes = useSearchAccountsQuery({status: "pending"});
|
|
@ -19,17 +19,15 @@
|
|||
|
||||
import React from "react";
|
||||
|
||||
import { useLazySearchAccountsQuery } from "../../../lib/query";
|
||||
import { useTextInput } from "../../../lib/form";
|
||||
import { useLazySearchAccountsQuery } from "../../../../lib/query";
|
||||
import { useTextInput } from "../../../../lib/form";
|
||||
|
||||
import { AccountList } from "../../../components/account-list";
|
||||
import { SearchAccountParams } from "../../../lib/types/account";
|
||||
import { Select, TextInput } from "../../../components/form/inputs";
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
import { AccountList } from "../../../../components/account-list";
|
||||
import { SearchAccountParams } from "../../../../lib/types/account";
|
||||
import { Select, TextInput } from "../../../../components/form/inputs";
|
||||
import MutationButton from "../../../../components/form/mutation-button";
|
||||
|
||||
export function AccountSearchForm() {
|
||||
const [searchAcct, searchRes] = useLazySearchAccountsQuery();
|
||||
|
||||
const form = {
|
||||
origin: useTextInput("origin"),
|
||||
status: useTextInput("status"),
|
||||
|
@ -55,14 +53,20 @@ export function AccountSearchForm() {
|
|||
// Remove any nulls.
|
||||
return kv || [];
|
||||
});
|
||||
|
||||
const params: SearchAccountParams = Object.fromEntries(entries);
|
||||
searchAcct(params);
|
||||
}
|
||||
|
||||
const [ searchAcct, searchRes ] = useLazySearchAccountsQuery();
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={submitSearch}>
|
||||
<form
|
||||
onSubmit={submitSearch}
|
||||
// Prevent password managers trying
|
||||
// to fill in username/email fields.
|
||||
autoComplete="off"
|
||||
>
|
||||
<TextInput
|
||||
field={form.username}
|
||||
label={"(Optional) username (without leading '@' symbol)"}
|
||||
|
@ -88,6 +92,8 @@ export function AccountSearchForm() {
|
|||
field={form.email}
|
||||
label={"(Optional) email address (local accounts only)"}
|
||||
placeholder={"someone@example.org"}
|
||||
// Get email validation for free.
|
||||
{...{type: "email"}}
|
||||
/>
|
||||
<TextInput
|
||||
field={form.ip}
|
|
@ -20,31 +20,35 @@
|
|||
import React 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 BackButton from "../../components/back-button";
|
||||
import MutationButton from "../../components/form/mutation-button";
|
||||
import Loading from "../../../components/loading";
|
||||
import BackButton from "../../../components/back-button";
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
|
||||
import { useDomainAllowsQuery, useDomainBlocksQuery } from "../../lib/query/admin/domain-permissions/get";
|
||||
import { useAddDomainAllowMutation, useAddDomainBlockMutation, useRemoveDomainAllowMutation, useRemoveDomainBlockMutation } from "../../lib/query/admin/domain-permissions/update";
|
||||
import { DomainPerm, PermType } from "../../lib/types/domain-permission";
|
||||
import { NoArg } from "../../lib/types/query";
|
||||
import { Error } from "../../components/error";
|
||||
import { useDomainAllowsQuery, useDomainBlocksQuery } from "../../../lib/query/admin/domain-permissions/get";
|
||||
import { useAddDomainAllowMutation, useAddDomainBlockMutation, useRemoveDomainAllowMutation, useRemoveDomainBlockMutation } from "../../../lib/query/admin/domain-permissions/update";
|
||||
import { DomainPerm, PermType } from "../../../lib/types/domain-permission";
|
||||
import { NoArg } from "../../../lib/types/query";
|
||||
import { Error } from "../../../components/error";
|
||||
import { useBaseUrl } from "../../../lib/navigation/util";
|
||||
|
||||
export interface DomainPermDetailProps {
|
||||
baseUrl: string;
|
||||
permType: PermType;
|
||||
domain: string;
|
||||
}
|
||||
export default function DomainPermDetail() {
|
||||
const baseUrl = useBaseUrl();
|
||||
|
||||
// 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: domainAllows = {}, isLoading: isLoadingDomainAllows } = useDomainAllowsQuery(NoArg, { skip: permType !== "allow" });
|
||||
|
||||
|
@ -60,13 +64,19 @@ export default function DomainPermDetail({ baseUrl, permType, domain }: DomainPe
|
|||
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.
|
||||
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") {
|
||||
throw "unknown domain";
|
||||
domain = searchDomain;
|
||||
}
|
||||
|
||||
// Normalize / decode domain (it may be URL-encoded).
|
||||
|
@ -98,13 +108,12 @@ export default function DomainPermDetail({ baseUrl, permType, domain }: DomainPe
|
|||
|
||||
return (
|
||||
<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}
|
||||
<DomainPermForm
|
||||
defaultDomain={domain}
|
||||
perm={existingPerm}
|
||||
permType={permType}
|
||||
baseUrl={baseUrl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -114,10 +123,9 @@ interface DomainPermFormProps {
|
|||
defaultDomain: string;
|
||||
perm?: DomainPerm;
|
||||
permType: PermType;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
function DomainPermForm({ defaultDomain, perm, permType, baseUrl }: DomainPermFormProps) {
|
||||
function DomainPermForm({ defaultDomain, perm, permType }: DomainPermFormProps) {
|
||||
const isExistingPerm = perm !== undefined;
|
||||
const disabledForm = isExistingPerm
|
||||
? {
|
||||
|
@ -186,7 +194,7 @@ function DomainPermForm({ defaultDomain, perm, permType, baseUrl }: DomainPermFo
|
|||
// but if domain input changes, that doesn't match anymore
|
||||
// and causes issues later on so, before submitting the form,
|
||||
// silently change url, and THEN submit.
|
||||
let correctUrl = `${baseUrl}/${form.domain.value}`;
|
||||
let correctUrl = `/${permType}s/${form.domain.value}`;
|
||||
if (location != correctUrl) {
|
||||
setLocation(correctUrl);
|
||||
}
|
|
@ -17,11 +17,11 @@
|
|||
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 (
|
||||
<div className="export-format-table-wrapper without-border">
|
||||
<div className="export-format-table-wrapper">
|
||||
<table className="export-format-table">
|
||||
<thead>
|
||||
<tr>
|
||||
|
@ -44,7 +44,7 @@ module.exports = function ExportFormatTable() {
|
|||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function Format({ name, info }) {
|
||||
return (
|
|
@ -21,18 +21,18 @@ import React from "react";
|
|||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useExportDomainListMutation } from "../../lib/query/admin/domain-permissions/export";
|
||||
import useFormSubmit from "../../lib/form/submit";
|
||||
import { useExportDomainListMutation } from "../../../lib/query/admin/domain-permissions/export";
|
||||
import useFormSubmit from "../../../lib/form/submit";
|
||||
|
||||
import {
|
||||
RadioGroup,
|
||||
TextArea,
|
||||
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 type {
|
||||
|
@ -40,7 +40,7 @@ import type {
|
|||
FormSubmitResult,
|
||||
RadioFormInputHook,
|
||||
TextFormInputHook,
|
||||
} from "../../lib/form/types";
|
||||
} from "../../../lib/form/types";
|
||||
|
||||
export interface ImportExportFormProps {
|
||||
form: {
|
|
@ -20,20 +20,19 @@
|
|||
import React from "react";
|
||||
|
||||
import { Switch, Route, Redirect, useLocation } from "wouter";
|
||||
|
||||
import { useProcessDomainPermissionsMutation } from "../../lib/query/admin/domain-permissions/process";
|
||||
|
||||
import { useTextInput, useRadioInput } from "../../lib/form";
|
||||
|
||||
import useFormSubmit from "../../lib/form/submit";
|
||||
|
||||
import { useProcessDomainPermissionsMutation } from "../../../lib/query/admin/domain-permissions/process";
|
||||
import { useTextInput, useRadioInput } from "../../../lib/form";
|
||||
import useFormSubmit from "../../../lib/form/submit";
|
||||
import { ProcessImport } from "./process";
|
||||
import ImportExportForm from "./form";
|
||||
|
||||
export default function ImportExport({ baseUrl }) {
|
||||
export default function ImportExport() {
|
||||
const form = {
|
||||
domains: useTextInput("domains"),
|
||||
exportType: useTextInput("exportType", { defaultValue: "plain", dontReset: true }),
|
||||
exportType: useTextInput("exportType", {
|
||||
defaultValue: "plain",
|
||||
dontReset: true,
|
||||
}),
|
||||
permType: useRadioInput("permType", {
|
||||
options: {
|
||||
block: "Domain blocks",
|
||||
|
@ -43,12 +42,11 @@ export default function ImportExport({ baseUrl }) {
|
|||
};
|
||||
|
||||
const [submitParse, parseResult] = useFormSubmit(form, useProcessDomainPermissionsMutation(), { changedOnly: false });
|
||||
|
||||
const [_location, setLocation] = useLocation();
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route path={`${baseUrl}/process`}>
|
||||
<Route path={"/process"}>
|
||||
{
|
||||
parseResult.isSuccess
|
||||
? (
|
||||
|
@ -58,7 +56,7 @@ export default function ImportExport({ baseUrl }) {
|
|||
className="button"
|
||||
onClick={() => {
|
||||
parseResult.reset();
|
||||
setLocation(baseUrl);
|
||||
setLocation("");
|
||||
}}
|
||||
>
|
||||
< back
|
||||
|
@ -71,13 +69,13 @@ export default function ImportExport({ baseUrl }) {
|
|||
/>
|
||||
</>
|
||||
)
|
||||
: <Redirect to={baseUrl} />
|
||||
: <Redirect to={""} />
|
||||
}
|
||||
</Route>
|
||||
<Route>
|
||||
{
|
||||
parseResult.isSuccess
|
||||
? <Redirect to={`${baseUrl}/process`} />
|
||||
? <Redirect to={"/process"} />
|
||||
: <ImportExportForm
|
||||
form={form}
|
||||
submitParse={submitParse}
|
|
@ -20,29 +20,25 @@
|
|||
import React from "react";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { Link, useLocation, useParams } from "wouter";
|
||||
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 { useDomainAllowsQuery, useDomainBlocksQuery } from "../../lib/query/admin/domain-permissions/get";
|
||||
import type { MappedDomainPerms, PermType } from "../../lib/types/domain-permission";
|
||||
import { NoArg } from "../../lib/types/query";
|
||||
import Loading from "../../../components/loading";
|
||||
import { useDomainAllowsQuery, useDomainBlocksQuery } from "../../../lib/query/admin/domain-permissions/get";
|
||||
import type { MappedDomainPerms, PermType } from "../../../lib/types/domain-permission";
|
||||
import { NoArg } from "../../../lib/types/query";
|
||||
|
||||
export interface DomainPermissionsOverviewProps {
|
||||
// Params injected by
|
||||
// the wouter router.
|
||||
permType: PermType;
|
||||
baseUrl: string,
|
||||
}
|
||||
|
||||
export default function DomainPermissionsOverview({ permType, baseUrl }: DomainPermissionsOverviewProps) {
|
||||
if (permType !== "block" && permType !== "allow") {
|
||||
throw "unrecognized perm type " + permType;
|
||||
export default function DomainPermissionsOverview() {
|
||||
// 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;
|
||||
|
||||
// Uppercase first letter of given permType.
|
||||
const permTypeUpper = useMemo(() => {
|
||||
|
@ -69,30 +65,28 @@ export default function DomainPermissionsOverview({ permType, baseUrl }: DomainP
|
|||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
<h1>Domain {permTypeUpper}s</h1>
|
||||
{ permType == "block" ? <BlockHelperText/> : <AllowHelperText/> }
|
||||
<DomainPermsList
|
||||
data={data}
|
||||
baseUrl={baseUrl}
|
||||
permType={permType}
|
||||
permTypeUpper={permTypeUpper}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface DomainPermsListProps {
|
||||
data: MappedDomainPerms;
|
||||
baseUrl: string;
|
||||
permType: PermType;
|
||||
permTypeUpper: string;
|
||||
}
|
||||
|
||||
function DomainPermsList({ data, baseUrl, permType, permTypeUpper }: DomainPermsListProps) {
|
||||
function DomainPermsList({ data, permType, permTypeUpper }: DomainPermsListProps) {
|
||||
// Format perms into a list.
|
||||
const perms = useMemo(() => {
|
||||
return Object.values(data);
|
||||
|
@ -103,7 +97,7 @@ function DomainPermsList({ data, baseUrl, permType, permTypeUpper }: DomainPerms
|
|||
|
||||
function filterFormSubmit(e) {
|
||||
e.preventDefault();
|
||||
setLocation(`${baseUrl}/${filter}`);
|
||||
setLocation(`/${filter}`);
|
||||
}
|
||||
|
||||
const filter = filterField.value ?? "";
|
||||
|
@ -120,11 +114,13 @@ function DomainPermsList({ data, baseUrl, permType, permTypeUpper }: DomainPerms
|
|||
|
||||
const entries = filteredPerms.map((entry) => {
|
||||
return (
|
||||
<Link key={entry.domain} to={`${baseUrl}/${entry.domain}`}>
|
||||
<a className="entry nounderline">
|
||||
<Link
|
||||
className="entry nounderline"
|
||||
key={entry.domain}
|
||||
to={`/${permType}s/${entry.domain}`}
|
||||
>
|
||||
<span id="domain">{entry.domain}</span>
|
||||
<span id="date">{new Date(entry.created_at ?? "").toLocaleString()}</span>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
@ -137,8 +133,11 @@ function DomainPermsList({ data, baseUrl, permType, permTypeUpper }: DomainPerms
|
|||
placeholder="example.org"
|
||||
label={`Search or add domain ${permType}`}
|
||||
/>
|
||||
<Link to={`${baseUrl}/${filter}`}>
|
||||
<a className="button">{permTypeUpper} {filter}</a>
|
||||
<Link
|
||||
className="button"
|
||||
to={`/${permType}s/${filter}`}
|
||||
>
|
||||
{permTypeUpper} {filter}
|
||||
</Link>
|
||||
</form>
|
||||
<div>
|
|
@ -21,14 +21,14 @@ import React 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 {
|
||||
useTextInput,
|
||||
useBoolInput,
|
||||
useRadioInput,
|
||||
useCheckListInput,
|
||||
} from "../../lib/form";
|
||||
} from "../../../lib/form";
|
||||
|
||||
import {
|
||||
Select,
|
||||
|
@ -36,22 +36,22 @@ import {
|
|||
RadioGroup,
|
||||
Checkbox,
|
||||
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 MutationButton from "../../components/form/mutation-button";
|
||||
import FormWithData from "../../lib/form/form-with-data";
|
||||
import CheckList from "../../../components/check-list";
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
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 {
|
||||
useDomainAllowsQuery,
|
||||
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 { ChecklistInputHook, RadioFormInputHook } from "../../lib/form/types";
|
||||
import type { DomainPerm, MappedDomainPerms } from "../../../lib/types/domain-permission";
|
||||
import type { ChecklistInputHook, RadioFormInputHook } from "../../../lib/form/types";
|
||||
|
||||
export interface ProcessImportProps {
|
||||
list: DomainPerm[],
|
||||
|
@ -61,7 +61,6 @@ export interface ProcessImportProps {
|
|||
export const ProcessImport = memo(
|
||||
function ProcessImport({ list, permType }: ProcessImportProps) {
|
||||
return (
|
||||
<div className="without-border">
|
||||
<FormWithData
|
||||
dataQuery={permType.value == "allow"
|
||||
? useDomainAllowsQuery
|
||||
|
@ -70,7 +69,6 @@ export const ProcessImport = memo(
|
|||
DataForm={ImportList}
|
||||
{...{ list, permType }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -18,32 +18,24 @@
|
|||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useRoute, Redirect } from "wouter";
|
||||
|
||||
import FormWithData from "../../lib/form/form-with-data";
|
||||
import BackButton from "../../components/back-button";
|
||||
|
||||
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 { useParams } from "wouter";
|
||||
import FormWithData from "../../../lib/form/form-with-data";
|
||||
import BackButton from "../../../components/back-button";
|
||||
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 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({ }) {
|
||||
const baseUrl = useBaseUrl();
|
||||
let [_match, params] = useRoute(`${baseUrl}/:reportId`);
|
||||
if (params?.reportId == undefined) {
|
||||
return <Redirect to={baseUrl} />;
|
||||
} else {
|
||||
const params = useParams();
|
||||
|
||||
return (
|
||||
<div className="report-detail">
|
||||
<h1>
|
||||
<BackButton to={baseUrl} /> Report Details
|
||||
</h1>
|
||||
<div className="reports">
|
||||
<h1><BackButton to={`~${baseUrl}`}/> Report Details</h1>
|
||||
<FormWithData
|
||||
dataQuery={useGetReportQuery}
|
||||
queryArg={params.reportId}
|
||||
|
@ -51,7 +43,6 @@ export default function ReportDetail({ }) {
|
|||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ReportDetailForm({ data: report }) {
|
|
@ -18,57 +18,50 @@
|
|||
*/
|
||||
|
||||
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 { 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 (
|
||||
<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
|
||||
dataQuery={useListReportsQuery}
|
||||
DataForm={ReportsList}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportsList({ data: reports }) {
|
||||
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">
|
||||
{reports.map((report) => (
|
||||
<ReportEntry key={report.id} report={report} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportEntry({ report }) {
|
||||
const baseUrl = useBaseUrl();
|
||||
const from = report.account;
|
||||
const target = report.target_account;
|
||||
|
||||
|
@ -77,8 +70,11 @@ function ReportEntry({ report }) {
|
|||
: report.comment;
|
||||
|
||||
return (
|
||||
<Link to={`${baseUrl}/${report.id}`}>
|
||||
<a className={`report entry${report.action_taken ? " resolved" : ""}`}>
|
||||
<Link
|
||||
to={`/${report.id}`}
|
||||
className="nounderline"
|
||||
>
|
||||
<div className={`report entry${report.action_taken ? " resolved" : ""}`}>
|
||||
<div className="byline">
|
||||
<div className="usernames">
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
201
web/source/settings/views/moderation/routes.tsx
Normal file
201
web/source/settings/views/moderation/routes.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -19,16 +19,16 @@
|
|||
|
||||
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 { useArrayInput, useTextInput } from "../lib/form";
|
||||
import { TextInput } from "../components/form/inputs";
|
||||
import useFormSubmit from "../lib/form/submit";
|
||||
import MutationButton from "../components/form/mutation-button";
|
||||
import { useAliasAccountMutation, useMoveAccountMutation } from "../lib/query/user";
|
||||
import { FormContext, useWithFormContext } from "../lib/form/context";
|
||||
import { store } from "../redux/store";
|
||||
import { useVerifyCredentialsQuery } from "../../lib/query/oauth";
|
||||
import { useArrayInput, useTextInput } from "../../lib/form";
|
||||
import { TextInput } from "../../components/form/inputs";
|
||||
import useFormSubmit from "../../lib/form/submit";
|
||||
import MutationButton from "../../components/form/mutation-button";
|
||||
import { useAliasAccountMutation, useMoveAccountMutation } from "../../lib/query/user";
|
||||
import { FormContext, useWithFormContext } from "../../lib/form/context";
|
||||
import { store } from "../../redux/store";
|
||||
|
||||
export default function UserMigration() {
|
||||
return (
|
||||
|
@ -81,7 +81,7 @@ function AliasForm({ data: profile }) {
|
|||
|
||||
return (
|
||||
<form className="user-migration-alias" onSubmit={submitForm}>
|
||||
<div className="form-section-docs without-border">
|
||||
<div className="form-section-docs">
|
||||
<h3>Alias Account</h3>
|
||||
<a
|
||||
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#alias-account"
|
||||
|
@ -157,15 +157,12 @@ function MoveForm({ data: profile }) {
|
|||
|
||||
return (
|
||||
<form className="user-migration-move" onSubmit={submitForm}>
|
||||
<div className="form-section-docs without-border">
|
||||
<div className="form-section-docs">
|
||||
<h3>Move Account</h3>
|
||||
<p>
|
||||
<p>
|
||||
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),
|
||||
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:
|
||||
</p>
|
||||
<dl className="migration-details">
|
||||
|
@ -187,7 +184,6 @@ function MoveForm({ data: profile }) {
|
|||
>
|
||||
Learn more about moving your account (opens in a new tab)
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<TextInput
|
||||
disabled={false}
|
|
@ -25,10 +25,10 @@ import {
|
|||
useBoolInput,
|
||||
useFieldArrayInput,
|
||||
useRadioInput
|
||||
} from "../lib/form";
|
||||
} from "../../lib/form";
|
||||
|
||||
import useFormSubmit from "../lib/form/submit";
|
||||
import { useWithFormContext, FormContext } from "../lib/form/context";
|
||||
import useFormSubmit from "../../lib/form/submit";
|
||||
import { useWithFormContext, FormContext } from "../../lib/form/context";
|
||||
|
||||
import {
|
||||
TextInput,
|
||||
|
@ -36,15 +36,15 @@ import {
|
|||
FileInput,
|
||||
Checkbox,
|
||||
RadioGroup
|
||||
} from "../components/form/inputs";
|
||||
} from "../../components/form/inputs";
|
||||
|
||||
import FormWithData from "../lib/form/form-with-data";
|
||||
import FakeProfile from "../components/fake-profile";
|
||||
import MutationButton from "../components/form/mutation-button";
|
||||
import FormWithData from "../../lib/form/form-with-data";
|
||||
import FakeProfile from "../../components/fake-profile";
|
||||
import MutationButton from "../../components/form/mutation-button";
|
||||
|
||||
import { useAccountThemesQuery, useInstanceV1Query } from "../lib/query";
|
||||
import { useUpdateCredentialsMutation } from "../lib/query/user";
|
||||
import { useVerifyCredentialsQuery } from "../lib/query/oauth";
|
||||
import { useAccountThemesQuery, useInstanceV1Query } from "../../lib/query";
|
||||
import { useUpdateCredentialsMutation } from "../../lib/query/user";
|
||||
import { useVerifyCredentialsQuery } from "../../lib/query/oauth";
|
||||
|
||||
export default function UserProfile() {
|
||||
return (
|
80
web/source/settings/views/user/routes.tsx
Normal file
80
web/source/settings/views/user/routes.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -18,18 +18,13 @@
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import query from "../lib/query";
|
||||
|
||||
import { useTextInput, useBoolInput } from "../lib/form";
|
||||
|
||||
import useFormSubmit from "../lib/form/submit";
|
||||
|
||||
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";
|
||||
import query from "../../lib/query";
|
||||
import { useTextInput, useBoolInput } from "../../lib/form";
|
||||
import useFormSubmit from "../../lib/form/submit";
|
||||
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() {
|
||||
return (
|
||||
|
@ -59,8 +54,19 @@ function UserSettingsForm({ data }) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<h1>Account Settings</h1>
|
||||
<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={
|
||||
<Languages />
|
||||
}>
|
||||
|
@ -72,7 +78,6 @@ function UserSettingsForm({ data }) {
|
|||
<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 field={form.statusContentType} label="Default post (and bio) format" options={
|
||||
<>
|
||||
|
@ -80,13 +85,11 @@ function UserSettingsForm({ data }) {
|
|||
<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>
|
||||
<Checkbox
|
||||
field={form.isSensitive}
|
||||
label="Mark my posts as sensitive by default"
|
||||
/>
|
||||
|
||||
<MutationButton
|
||||
disabled={false}
|
||||
label="Save settings"
|
||||
|
@ -124,24 +127,37 @@ function PasswordChange() {
|
|||
|
||||
return (
|
||||
<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
|
||||
type="password"
|
||||
name="password"
|
||||
field={form.oldPassword}
|
||||
label="Current password"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<TextInput
|
||||
type="password"
|
||||
name="newPassword"
|
||||
field={form.newPassword}
|
||||
label="New password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<TextInput
|
||||
type="password"
|
||||
name="confirmNewPassword"
|
||||
field={verifyNewPassword}
|
||||
label="Confirm new password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<MutationButton
|
||||
disabled={false}
|
|
@ -1229,11 +1229,6 @@
|
|||
resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9"
|
||||
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":
|
||||
version "3.3.2"
|
||||
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:
|
||||
has-symbols "^1.0.3"
|
||||
|
||||
autoprefixer@^10.4.13:
|
||||
version "10.4.16"
|
||||
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.16.tgz#fad1411024d8670880bdece3970aa72e3572feb8"
|
||||
integrity sha512-7vd3UC6xKp0HLfua5IjZlcXvGAGy7cBAXTg2lyQ/8WpNhd6SiZ8Be+xm3FyBSYJx5GKcpRCzBh7RH4/0dnY+uQ==
|
||||
autoprefixer@^10.4.19:
|
||||
version "10.4.19"
|
||||
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.19.tgz#ad25a856e82ee9d7898c59583c1afeb3fa65f89f"
|
||||
integrity sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==
|
||||
dependencies:
|
||||
browserslist "^4.21.10"
|
||||
caniuse-lite "^1.0.30001538"
|
||||
fraction.js "^4.3.6"
|
||||
browserslist "^4.23.0"
|
||||
caniuse-lite "^1.0.30001599"
|
||||
fraction.js "^4.3.7"
|
||||
normalize-range "^0.1.2"
|
||||
picocolors "^1.0.0"
|
||||
postcss-value-parser "^4.2.0"
|
||||
|
@ -2339,7 +2334,7 @@ browserify@^17.0.0:
|
|||
vm-browserify "^1.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"
|
||||
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.22.1.tgz#ba91958d1a59b87dab6fed8dfbcb3da5e2e9c619"
|
||||
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"
|
||||
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:
|
||||
version "1.1.2"
|
||||
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"
|
||||
integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==
|
||||
|
||||
caniuse-lite@^1.0.30001538, caniuse-lite@^1.0.30001541:
|
||||
caniuse-lite@^1.0.30001541:
|
||||
version "1.0.30001543"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001543.tgz#478a3e9dddbb353c5ab214b0ecb0dbed529ed1d8"
|
||||
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:
|
||||
version "2.4.2"
|
||||
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"
|
||||
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:
|
||||
version "6.5.4"
|
||||
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"
|
||||
integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==
|
||||
|
||||
fraction.js@^4.3.6:
|
||||
version "4.3.6"
|
||||
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.6.tgz#e9e3acec6c9a28cf7bc36cbe35eea4ceb2c5c92d"
|
||||
integrity sha512-n2aZ9tNfYDwaHhvFTkhFErqOMIb8uyzSQ+vGJBjZyanAKZVbGUQ1sngfk9FdkBw7G26O7AgNjLcecLffD1c7eg==
|
||||
fraction.js@^4.3.7:
|
||||
version "4.3.7"
|
||||
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
|
||||
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
|
||||
|
||||
fresh@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"
|
||||
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:
|
||||
version "0.5.3"
|
||||
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"
|
||||
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:
|
||||
version "3.0.0"
|
||||
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"
|
||||
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:
|
||||
version "5.3.2"
|
||||
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"
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/split-filter-n/-/split-filter-n-1.1.3.tgz#c983ae1e52402e70071f711a7af767a91f09f740"
|
||||
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"
|
||||
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
|
||||
|
||||
wouter@^2.8.0-alpha.2:
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/wouter/-/wouter-2.11.0.tgz#3db485dec158115b67330821e7673bf3e2f78678"
|
||||
integrity sha512-Y2CzNCwIN8kHjR2Q10D+UAgQND6TvBNmwXxgYw5ltXjjTlL7cLDUDpCip3a927Svxrmxr6vJMcPUysFxSvriCw==
|
||||
wouter@^3.1.0:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/wouter/-/wouter-3.1.2.tgz#8fe1d1c08a415b64d7d2583090bb66f2166636ef"
|
||||
integrity sha512-oyYrbwnIbal7Hz6LzeqRoyWFEkNA64SCmF9r48f6hkUcLnT0y0o+hthuT1X1OIbj80YBT9zE+mH4GYUWH98nIg==
|
||||
dependencies:
|
||||
mitt "^3.0.1"
|
||||
regexparam "^3.0.0"
|
||||
use-sync-external-store "^1.0.0"
|
||||
|
||||
wrap-ansi@^6.0.1:
|
||||
|
|
Loading…
Reference in a new issue