summaryrefslogtreecommitdiff
path: root/web/source/settings/lib/navigation
diff options
context:
space:
mode:
Diffstat (limited to 'web/source/settings/lib/navigation')
-rw-r--r--web/source/settings/lib/navigation/components.jsx201
-rw-r--r--web/source/settings/lib/navigation/error.tsx98
-rw-r--r--web/source/settings/lib/navigation/index.js136
-rw-r--r--web/source/settings/lib/navigation/menu.tsx175
-rw-r--r--web/source/settings/lib/navigation/util.ts45
5 files changed, 308 insertions, 347 deletions
diff --git a/web/source/settings/lib/navigation/components.jsx b/web/source/settings/lib/navigation/components.jsx
deleted file mode 100644
index 64ed160b6..000000000
--- a/web/source/settings/lib/navigation/components.jsx
+++ /dev/null
@@ -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
-}; \ No newline at end of file
diff --git a/web/source/settings/lib/navigation/error.tsx b/web/source/settings/lib/navigation/error.tsx
new file mode 100644
index 000000000..08edc83f0
--- /dev/null
+++ b/web/source/settings/lib/navigation/error.tsx
@@ -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 };
diff --git a/web/source/settings/lib/navigation/index.js b/web/source/settings/lib/navigation/index.js
deleted file mode 100644
index 2e8f062f4..000000000
--- a/web/source/settings/lib/navigation/index.js
+++ /dev/null
@@ -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
-}; \ No newline at end of file
diff --git a/web/source/settings/lib/navigation/menu.tsx b/web/source/settings/lib/navigation/menu.tsx
new file mode 100644
index 000000000..514e3ea2f
--- /dev/null
+++ b/web/source/settings/lib/navigation/menu.tsx
@@ -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>
+ );
+}
diff --git a/web/source/settings/lib/navigation/util.ts b/web/source/settings/lib/navigation/util.ts
index e6f8ee697..2c5885c4d 100644
--- a/web/source/settings/lib/navigation/util.ts
+++ b/web/source/settings/lib/navigation/util.ts
@@ -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,
};