mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2025-02-06 13:35:02 +00:00
[chore] Refactor settings panel routing (and other fixes) (#2864)
This commit is contained in:
parent
62788aa116
commit
7a1e639483
13
.vscode/settings.json
vendored
13
.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>
|
||||
);
|
||||
}
|
|
@ -64,11 +64,11 @@ export function AccountList({
|
|||
|
||||
return (
|
||||
<div className="list">
|
||||
{data.map(({ account: acc }) => (
|
||||
{data.map(({ account: acc }) => (
|
||||
<Link
|
||||
key={acc.acct}
|
||||
className="account entry"
|
||||
href={`/settings/admin/accounts/${acc.id}`}
|
||||
href={`/${acc.id}`}
|
||||
>
|
||||
{acc.display_name?.length > 0
|
||||
? acc.display_name
|
||||
|
@ -79,4 +79,4 @@ export function AccountList({
|
|||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||