diff options
Diffstat (limited to 'web/source/settings/lib/navigation')
-rw-r--r-- | web/source/settings/lib/navigation/components.jsx | 201 | ||||
-rw-r--r-- | web/source/settings/lib/navigation/error.tsx | 98 | ||||
-rw-r--r-- | web/source/settings/lib/navigation/index.js | 136 | ||||
-rw-r--r-- | web/source/settings/lib/navigation/menu.tsx | 175 | ||||
-rw-r--r-- | web/source/settings/lib/navigation/util.ts | 45 |
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, }; |